Extension Points
This page is the architecture-level reference for Sluice’s extension surface. If you want a tutorial on writing plugins, read Plugin System first; this page is for plugin authors who want to know what the registry actually does and where the boundary between engine and plugin code sits.
Module boundary
Section titled “Module boundary”Plugins live in src/plugins/. The module exports four interfaces and two registries; src/plugins/ is imported by runner, dq, transform, and merge, but it does not import any of them. That keeps the dependency graph acyclic and means you can add new plugin types without restructuring the engine.
plugins/├── types.ts ← RulePlugin, TransformPlugin, MergeStrategyPlugin, PluginPackage├── registry.ts ← RuleRegistry, TransformRegistry (custom plugin holders)└── loader.ts ← loadPlugins (file-based), loadNpmPlugins (sluice.config.yaml)The four extension interfaces
Section titled “The four extension interfaces”RulePlugin
Section titled “RulePlugin”export interface RulePlugin { readonly id: string; readonly description?: string; validate( value: unknown, config: CheckConfig, rowIndex: number, field: string, ): RuleViolation | null;}Returns null if the value passes; returns a RuleViolation if it fails. severity and an optional message come from config.
TransformPlugin
Section titled “TransformPlugin”export interface TransformPlugin { readonly id: string; readonly description?: string; apply( input: unknown, config: { options?: Record<string, unknown> }, row: Record<string, unknown>, ): unknown | Promise<unknown>;}Custom transforms are referenced from transform.fields[] as type: custom with customOp: <id>. Sluice passes the source value (input), any per-field options: from the YAML, and the whole row (in case the transform needs sibling fields).
MergeStrategyPlugin
Section titled “MergeStrategyPlugin”export interface MergeStrategyPlugin { readonly id: string; readonly description?: string; merge( store: StagingStore, sources: MergeSourceMeta[], // priority-ordered (priority 1 first) config: MergeConfig, ): Promise<MergeResult>;}Strategies receive an open StagingStore and a priority-ordered list of source tables. The strategy is responsible for materialising stg_merged (and optionally stg_merge_conflicts) and returning a MergeResult. The four built-in strategies (coalesce, priority-override, union, intersect) are the canonical reference implementations — see src/merge/strategies/.
SourceAdapter and TargetAdapter
Section titled “SourceAdapter and TargetAdapter”These are the heaviest extension points — they’re what drives every Extract and Load. Their interfaces live in src/adapters/source/types.ts and src/adapters/target/types.ts:
export interface SourceAdapter { readonly id: string; connect(config: SourceConfig): Promise<void>; extract( config: SourceConfig, store: StagingStore, runConfig: RunConfig, onProgress: (rows: number) => void, targetTable?: string, ): Promise<ExtractResult>; disconnect(): Promise<void>;}
export interface TargetAdapter { readonly id: string; connect(config: TargetConfig): Promise<void>; load( config: TargetConfig, store: StagingStore, runConfig: RunConfig, onProgress: (rows: number) => void, ): Promise<LoadResult>; disconnect(): Promise<void>;}Custom adapters register themselves via a Tier 3 npm package’s register() function, the same way the paid IFS / Business Central / BlueCherry adapters do.
Registries
Section titled “Registries”Two registries live in src/plugins/registry.ts:
RuleRegistry— holds custom DQ rules (built-in rules live insrc/dq/rules/).TransformRegistry— holds custom transforms (built-in transforms are baked intosrc/transform/engine.ts).
A separate registry lives in src/merge/index.ts:
MergeStrategyRegistry— pre-loaded withcoalesce,priority-override,union,intersect; custom strategies are added by Tier 2 file plugins or Tier 3 packages.
Adapters use their own registries:
SourceAdapterRegistry(src/adapters/source/registry.ts) — pre-loaded withcsv,mssql,pg,xlsx,rest.TargetAdapterRegistry(src/adapters/target/registry.ts) — pre-loaded withcsv,pg. Paid ERP adapters add themselves on import.
Each registry exposes add(plugin), get(id), has(id), and list().
Discovery: the resolution order
Section titled “Discovery: the resolution order”PipelineRunner.loadAllPlugins() is called at the start of every run. It walks four sources in order:
- Built-ins — registered eagerly when their barrel modules are imported.
- Composite rules (Tier 1) — expanded by
ConfigLoaderfrom the file atdq.rulesFile. - File plugins (Tier 2) — discovered by globbing
plugins/*.{rule,transform,merge}.{ts,js}next to the pipeline YAML, plus any directory passed via--plugins. - npm package plugins (Tier 3) — listed in
sluice.config.yamlat the repo root; each package’s exportedregister()function is called once.
Later registrations override earlier ones for the same id. Use distinctive ids (e.g. ifsCustomerNo, not customerNo) to avoid surprise overrides.
File plugin loading on Windows
Section titled “File plugin loading on Windows”Tier 2 plugins are loaded with import() from absolute paths. Node 24’s stricter ESM loader rejects raw C:\… paths, so the loader wraps them in pathToFileURL() first. This is invisible to plugin authors but is the reason Tier 2 works on Windows where it would otherwise silently fail.
How sluice plugins introspects
Section titled “How sluice plugins introspects”The sluice plugins CLI command iterates all five registries and prints each entry with its id, description, and source (built-in / composite / file / npm package). When debugging “why isn’t my custom rule being picked up”, that’s the first place to look.
See also
Section titled “See also”- Plugin System — how to write plugins
- PLUGINS.md — full author guide
- How It Works — runtime architecture