@eventferry/schema-registry 3.2.3 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # @eventferry/schema-registry
2
2
 
3
+ ## 3.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d983d90: Schema Registry serializer gains three production-grade controls without breaking the existing API:
8
+
9
+ - **Subject naming strategy** — `subjectStrategy: "TopicNameStrategy" | "RecordNameStrategy" | "TopicRecordNameStrategy"` mirrors Confluent's three built-ins. The two record-based strategies take a `recordName: (record, isKey) => string` resolver. Default stays `TopicNameStrategy`; the existing `subject` callable still wins when set (now optionally receiving `(topic, isKey, record)` — the single-arg legacy form keeps working).
10
+ - **Avro key serialization** — new `keySchemas` option + `serializeKey(record): Promise<Buffer | null>` method. Returns `null` for keyless records (matching the kafka "no key" semantics) and uses the `-key` subject by default. Key and value subject ids cache independently. Not wired into the relay automatically — call it from your publish glue when you want Avro-encoded keys instead of UTF-8 strings.
11
+ - **`autoRegister: false`** — never call `register()`, always resolve schemas via `getLatestSchemaId` on the computed subject. Locally supplied `schemas` / `keySchemas` bytes become docs-only in this mode. Matches `auto.register.schemas=false` on every Confluent client, for production clusters where schemas are managed out-of-band.
12
+
3
13
  ## 3.2.3
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -32,6 +32,64 @@ Topics without a configured schema use the subject's latest registered schema
32
32
  (default subject: `${topic}-value`). On the consumer, decode with the same client:
33
33
  `await registry.decode(message.value)`.
34
34
 
35
+ ## Subject naming strategies
36
+
37
+ Pick one of Confluent's three built-in strategies (default `TopicNameStrategy`):
38
+
39
+ ```ts
40
+ new SchemaRegistrySerializer({
41
+ host,
42
+ subjectStrategy: "RecordNameStrategy", // subject = recordName
43
+ // or: "TopicRecordNameStrategy" // subject = `${topic}-${recordName}`
44
+ recordName: (record) => `com.example.${record.aggregateType}.Created`,
45
+ });
46
+ ```
47
+
48
+ `recordName` is required for the `RecordName` and `TopicRecordName` strategies — typical implementation reads `${namespace}.${name}` from your avsc.
49
+
50
+ Need full control? Skip the preset and pass an explicit `subject` function:
51
+
52
+ ```ts
53
+ new SchemaRegistrySerializer({
54
+ host,
55
+ subject: (topic, isKey, record) => `acme.${topic}.${isKey ? "key" : "value"}`,
56
+ });
57
+ ```
58
+
59
+ ## Avro key serialization
60
+
61
+ Configure per-topic key schemas and call `serializeKey` from your publish path:
62
+
63
+ ```ts
64
+ const serializer = new SchemaRegistrySerializer({
65
+ host,
66
+ schemas: { "orders.created": { type: "AVRO", schema: valueAvsc } },
67
+ keySchemas: { "orders.created": { type: "AVRO", schema: keyAvsc } },
68
+ });
69
+
70
+ // Inside your custom publish glue:
71
+ const encodedValue = await serializer.serialize(record);
72
+ const encodedKey = await serializer.serializeKey(record); // null when record.key is null
73
+ ```
74
+
75
+ The relay does NOT call `serializeKey` automatically — Avro keys are an application-level convention, and adding them silently would break consumers expecting UTF-8 string keys. Wire it in your publish path explicitly.
76
+
77
+ Key and value subjects cache their schema ids independently — registering one doesn't affect the other.
78
+
79
+ ## Auto-register toggle
80
+
81
+ For production clusters where schemas are managed out-of-band (Confluent Cloud, regulated environments), turn auto-registration off:
82
+
83
+ ```ts
84
+ new SchemaRegistrySerializer({
85
+ host,
86
+ schemas: { /* ignored when autoRegister is false */ },
87
+ autoRegister: false,
88
+ });
89
+ ```
90
+
91
+ With `autoRegister: false`, the serializer ALWAYS resolves by `getLatestSchemaId` on the computed subject — locally supplied `schemas` / `keySchemas` bytes are ignored. Matches the Confluent client's `auto.register.schemas=false`.
92
+
35
93
  📖 **Full documentation:** [github.com/SametGoktepe/eventferry](https://github.com/SametGoktepe/eventferry#readme)
36
94
 
37
95
  ## License
package/dist/index.cjs CHANGED
@@ -39,8 +39,13 @@ var DEFAULT_CONTENT_TYPE = "application/vnd.confluent.avro";
39
39
  var SchemaRegistrySerializer = class {
40
40
  contentType;
41
41
  schemas;
42
- subject;
42
+ keySchemas;
43
+ subjectStrategy;
44
+ recordName;
45
+ subjectFn;
43
46
  host;
47
+ autoRegister;
48
+ // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.
44
49
  idCache = /* @__PURE__ */ new Map();
45
50
  registry;
46
51
  constructor(opts) {
@@ -52,25 +57,74 @@ var SchemaRegistrySerializer = class {
52
57
  this.registry = opts.registry ?? null;
53
58
  this.host = opts.host ?? null;
54
59
  this.schemas = opts.schemas ?? {};
55
- this.subject = opts.subject ?? ((topic) => `${topic}-value`);
60
+ this.keySchemas = opts.keySchemas ?? {};
61
+ this.subjectStrategy = opts.subjectStrategy ?? "TopicNameStrategy";
62
+ this.recordName = opts.recordName ?? null;
63
+ this.subjectFn = opts.subject ?? null;
56
64
  this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;
65
+ this.autoRegister = opts.autoRegister ?? true;
57
66
  }
58
67
  async serialize(record) {
59
68
  const registry = await this.getRegistry();
60
- const id = await this.schemaId(registry, record.topic);
69
+ const subject = this.resolveSubject(record, false);
70
+ const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);
61
71
  return registry.encode(id, record.payload);
62
72
  }
63
- schemaId(registry, topic) {
64
- const cached = this.idCache.get(topic);
73
+ /**
74
+ * Avro-encode the record's KEY using the registered key schema. Returns
75
+ * `null` when the record has no key (kafkajs/confluent treat null keys
76
+ * as the producer-side "no key" signal).
77
+ *
78
+ * Not part of the core `Serializer` interface — callers wire it into
79
+ * their publish path manually when they want Avro keys instead of raw
80
+ * UTF-8 strings.
81
+ */
82
+ async serializeKey(record) {
83
+ if (record.key === null || record.key === void 0) return null;
84
+ const registry = await this.getRegistry();
85
+ const subject = this.resolveSubject(record, true);
86
+ const id = await this.schemaId(
87
+ registry,
88
+ subject,
89
+ this.keySchemas[record.topic],
90
+ true,
91
+ record.topic
92
+ );
93
+ return registry.encode(id, record.key);
94
+ }
95
+ /**
96
+ * Resolve the subject for this (record, isKey) tuple. Order of
97
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
98
+ */
99
+ resolveSubject(record, isKey) {
100
+ if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);
101
+ switch (this.subjectStrategy) {
102
+ case "TopicNameStrategy":
103
+ return `${record.topic}-${isKey ? "key" : "value"}`;
104
+ case "RecordNameStrategy":
105
+ return this.recordNameFor(record, isKey);
106
+ case "TopicRecordNameStrategy":
107
+ return `${record.topic}-${this.recordNameFor(record, isKey)}`;
108
+ }
109
+ }
110
+ recordNameFor(record, isKey) {
111
+ if (!this.recordName) {
112
+ throw new Error(
113
+ `SchemaRegistrySerializer: subjectStrategy "${this.subjectStrategy}" requires a \`recordName\` resolver.`
114
+ );
115
+ }
116
+ return this.recordName(record, isKey);
117
+ }
118
+ schemaId(registry, subject, spec, isKey, topic) {
119
+ const cacheKey = `${topic}:${isKey ? "key" : "value"}`;
120
+ const cached = this.idCache.get(cacheKey);
65
121
  if (cached) return cached;
66
- const subject = this.subject(topic);
67
- const spec = this.schemas[topic];
68
- const lookup = spec ? registry.register({ type: spec.type, schema: spec.schema }, { subject }).then((r) => r.id) : registry.getLatestSchemaId(subject);
122
+ const lookup = spec && this.autoRegister ? registry.register({ type: spec.type, schema: spec.schema }, { subject }).then((r) => r.id) : registry.getLatestSchemaId(subject);
69
123
  const guarded = lookup.catch((err) => {
70
- this.idCache.delete(topic);
124
+ this.idCache.delete(cacheKey);
71
125
  throw err;
72
126
  });
73
- this.idCache.set(topic, guarded);
127
+ this.idCache.set(cacheKey, guarded);
74
128
  return guarded;
75
129
  }
76
130
  async getRegistry() {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/serializer.ts"],"sourcesContent":["export * from \"./serializer.js\";\n","import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /** Per-topic schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /** Subject naming. Default TopicNameStrategy: `${topic}-value`. */\n subject?: (topic: string) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per topic is resolved once and cached.\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly subject: (topic: string) => string;\n private readonly host: string | null;\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.schemas = opts.schemas ?? {};\n this.subject = opts.subject ?? ((topic) => `${topic}-value`);\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const id = await this.schemaId(registry, record.topic);\n return registry.encode(id, record.payload);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n topic: string,\n ): Promise<number> {\n const cached = this.idCache.get(topic);\n if (cached) return cached;\n\n const subject = this.subject(topic);\n const spec = this.schemas[topic];\n const lookup = spec\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(topic);\n throw err;\n });\n this.idCache.set(topic, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n this.registry = new mod.SchemaRegistry({ host: this.host as string });\n return this.registry;\n }\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (cfg: { host: string }) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACoCA,IAAM,uBAAuB;AAOtB,IAAM,2BAAN,MAAqD;AAAA,EACjD;AAAA,EACQ;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU,oBAAI,IAA6B;AAAA,EACpD;AAAA,EAER,YAAY,MAAuC;AACjD,QAAI,CAAC,KAAK,YAAY,CAAC,KAAK,MAAM;AAChC,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,SAAK,WAAW,KAAK,YAAY;AACjC,SAAK,OAAO,KAAK,QAAQ;AACzB,SAAK,UAAU,KAAK,WAAW,CAAC;AAChC,SAAK,UAAU,KAAK,YAAY,CAAC,UAAU,GAAG,KAAK;AACnD,SAAK,cAAc,KAAK,eAAe;AAAA,EACzC;AAAA,EAEA,MAAM,UAAU,QAAuC;AACrD,UAAM,WAAW,MAAM,KAAK,YAAY;AACxC,UAAM,KAAK,MAAM,KAAK,SAAS,UAAU,OAAO,KAAK;AACrD,WAAO,SAAS,OAAO,IAAI,OAAO,OAAO;AAAA,EAC3C;AAAA,EAEQ,SACN,UACA,OACiB;AACjB,UAAM,SAAS,KAAK,QAAQ,IAAI,KAAK;AACrC,QAAI,OAAQ,QAAO;AAEnB,UAAM,UAAU,KAAK,QAAQ,KAAK;AAClC,UAAM,OAAO,KAAK,QAAQ,KAAK;AAC/B,UAAM,SAAS,OACX,SACG,SAAS,EAAE,MAAM,KAAK,MAAM,QAAQ,KAAK,OAAO,GAAG,EAAE,QAAQ,CAAC,EAC9D,KAAK,CAAC,MAAM,EAAE,EAAE,IACnB,SAAS,kBAAkB,OAAO;AAItC,UAAM,UAAU,OAAO,MAAM,CAAC,QAAQ;AACpC,WAAK,QAAQ,OAAO,KAAK;AACzB,YAAM;AAAA,IACR,CAAC;AACD,SAAK,QAAQ,IAAI,OAAO,OAAO;AAC/B,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,cAA6C;AACzD,QAAI,KAAK,SAAU,QAAO,KAAK;AAC/B,UAAM,MAAM,MAAM,qBAAqB;AACvC,SAAK,WAAW,IAAI,IAAI,eAAe,EAAE,MAAM,KAAK,KAAe,CAAC;AACpE,WAAO,KAAK;AAAA,EACd;AACF;AAEA,eAAe,uBAEZ;AACD,MAAI;AAEF,WAAQ,MAAM,OAAO,oCAAoC;AAAA,EAC3D,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/serializer.ts"],"sourcesContent":["export * from \"./serializer.js\";\n","import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * Subject naming strategy. Mirrors Confluent's three built-ins:\n *\n * - `\"TopicNameStrategy\"` (default) — `${topic}-value` / `${topic}-key`.\n * The conventional default; one schema per (topic, isKey) tuple.\n * - `\"RecordNameStrategy\"` — `${recordName}`. Same record type can flow\n * on multiple topics. Requires a `recordName` resolver.\n * - `\"TopicRecordNameStrategy\"` — `${topic}-${recordName}`. Multiple record\n * types per topic. Requires a `recordName` resolver.\n *\n * Set `subject` (function form) to override entirely.\n */\nexport type SubjectNameStrategy =\n | \"TopicNameStrategy\"\n | \"RecordNameStrategy\"\n | \"TopicRecordNameStrategy\";\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /**\n * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes\n * the record key for the matching topic. Topics omitted here fall back\n * to the subject's latest (or, with `autoRegister: false`, ALWAYS the\n * subject's latest).\n */\n keySchemas?: Record<string, SchemaSpec>;\n /**\n * Subject naming strategy preset. Default `\"TopicNameStrategy\"`.\n * Setting `subject` (function) overrides this entirely.\n */\n subjectStrategy?: SubjectNameStrategy;\n /**\n * Resolve the schema's record name (used by `RecordNameStrategy` and\n * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to\n * one of those — throws on first serialize if absent.\n *\n * Typical implementation: read `${namespace}.${name}` from the avsc you\n * already supply via `schemas` / `keySchemas`.\n */\n recordName?: (record: OutboxRecord, isKey: boolean) => string;\n /**\n * Custom subject function — overrides BOTH `subjectStrategy` and\n * `recordName`. Receives `(topic, isKey, record)` for full flexibility.\n *\n * Backwards-compatible with the single-argument legacy form\n * `(topic) => string` — extra args are ignored by JavaScript.\n */\n subject?: (\n topic: string,\n isKey?: boolean,\n record?: OutboxRecord,\n ) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n /**\n * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.\n * Default `true` — matches Confluent client behavior.\n *\n * Set to `false` for production clusters where schemas are managed\n * out-of-band (Confluent Cloud, regulated environments). With\n * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`\n * on the computed subject — and the locally-supplied schema bytes are\n * ignored.\n */\n autoRegister?: boolean;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per (topic, isKey) tuple is resolved once\n * and cached.\n *\n * Also exposes `serializeKey(record)` for users who want Avro-encoded message\n * keys — call it manually when building the publish path; the relay does NOT\n * call it automatically (key encoding is application-level by convention).\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly keySchemas: Record<string, SchemaSpec>;\n private readonly subjectStrategy: SubjectNameStrategy;\n private readonly recordName:\n | ((record: OutboxRecord, isKey: boolean) => string)\n | null;\n private readonly subjectFn:\n | ((topic: string, isKey?: boolean, record?: OutboxRecord) => string)\n | null;\n private readonly host: string | null;\n private readonly autoRegister: boolean;\n // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.schemas = opts.schemas ?? {};\n this.keySchemas = opts.keySchemas ?? {};\n this.subjectStrategy = opts.subjectStrategy ?? \"TopicNameStrategy\";\n this.recordName = opts.recordName ?? null;\n this.subjectFn = opts.subject ?? null;\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n this.autoRegister = opts.autoRegister ?? true;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, false);\n const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);\n return registry.encode(id, record.payload);\n }\n\n /**\n * Avro-encode the record's KEY using the registered key schema. Returns\n * `null` when the record has no key (kafkajs/confluent treat null keys\n * as the producer-side \"no key\" signal).\n *\n * Not part of the core `Serializer` interface — callers wire it into\n * their publish path manually when they want Avro keys instead of raw\n * UTF-8 strings.\n */\n async serializeKey(record: OutboxRecord): Promise<Buffer | null> {\n if (record.key === null || record.key === undefined) return null;\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, true);\n const id = await this.schemaId(\n registry,\n subject,\n this.keySchemas[record.topic],\n true,\n record.topic,\n );\n return registry.encode(id, record.key);\n }\n\n /**\n * Resolve the subject for this (record, isKey) tuple. Order of\n * precedence: explicit `subject` function → `subjectStrategy` preset.\n */\n private resolveSubject(record: OutboxRecord, isKey: boolean): string {\n if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);\n switch (this.subjectStrategy) {\n case \"TopicNameStrategy\":\n return `${record.topic}-${isKey ? \"key\" : \"value\"}`;\n case \"RecordNameStrategy\":\n return this.recordNameFor(record, isKey);\n case \"TopicRecordNameStrategy\":\n return `${record.topic}-${this.recordNameFor(record, isKey)}`;\n }\n }\n\n private recordNameFor(record: OutboxRecord, isKey: boolean): string {\n if (!this.recordName) {\n throw new Error(\n `SchemaRegistrySerializer: subjectStrategy \"${this.subjectStrategy}\" requires a \\`recordName\\` resolver.`,\n );\n }\n return this.recordName(record, isKey);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n subject: string,\n spec: SchemaSpec | undefined,\n isKey: boolean,\n topic: string,\n ): Promise<number> {\n const cacheKey = `${topic}:${isKey ? \"key\" : \"value\"}`;\n const cached = this.idCache.get(cacheKey);\n if (cached) return cached;\n\n // autoRegister=false → ALWAYS resolve by latest; the local spec\n // (if any) is ignored. Matches Confluent's auto.register.schemas=false.\n const lookup =\n spec && this.autoRegister\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(cacheKey);\n throw err;\n });\n this.idCache.set(cacheKey, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n this.registry = new mod.SchemaRegistry({ host: this.host as string });\n return this.registry;\n }\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (cfg: { host: string }) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -6,6 +6,19 @@ interface SchemaSpec {
6
6
  /** Schema definition string (avsc JSON / .proto / JSON Schema). */
7
7
  schema: string;
8
8
  }
9
+ /**
10
+ * Subject naming strategy. Mirrors Confluent's three built-ins:
11
+ *
12
+ * - `"TopicNameStrategy"` (default) — `${topic}-value` / `${topic}-key`.
13
+ * The conventional default; one schema per (topic, isKey) tuple.
14
+ * - `"RecordNameStrategy"` — `${recordName}`. Same record type can flow
15
+ * on multiple topics. Requires a `recordName` resolver.
16
+ * - `"TopicRecordNameStrategy"` — `${topic}-${recordName}`. Multiple record
17
+ * types per topic. Requires a `recordName` resolver.
18
+ *
19
+ * Set `subject` (function form) to override entirely.
20
+ */
21
+ type SubjectNameStrategy = "TopicNameStrategy" | "RecordNameStrategy" | "TopicRecordNameStrategy";
9
22
  /**
10
23
  * The subset of a Confluent Schema Registry client this serializer uses. The
11
24
  * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.
@@ -27,29 +40,92 @@ interface SchemaRegistrySerializerOptions {
27
40
  registry?: SchemaRegistryClient;
28
41
  /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */
29
42
  host?: string;
30
- /** Per-topic schema to register. Topics omitted here use the subject's latest. */
43
+ /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */
31
44
  schemas?: Record<string, SchemaSpec>;
32
- /** Subject naming. Default TopicNameStrategy: `${topic}-value`. */
33
- subject?: (topic: string) => string;
45
+ /**
46
+ * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes
47
+ * the record key for the matching topic. Topics omitted here fall back
48
+ * to the subject's latest (or, with `autoRegister: false`, ALWAYS the
49
+ * subject's latest).
50
+ */
51
+ keySchemas?: Record<string, SchemaSpec>;
52
+ /**
53
+ * Subject naming strategy preset. Default `"TopicNameStrategy"`.
54
+ * Setting `subject` (function) overrides this entirely.
55
+ */
56
+ subjectStrategy?: SubjectNameStrategy;
57
+ /**
58
+ * Resolve the schema's record name (used by `RecordNameStrategy` and
59
+ * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to
60
+ * one of those — throws on first serialize if absent.
61
+ *
62
+ * Typical implementation: read `${namespace}.${name}` from the avsc you
63
+ * already supply via `schemas` / `keySchemas`.
64
+ */
65
+ recordName?: (record: OutboxRecord, isKey: boolean) => string;
66
+ /**
67
+ * Custom subject function — overrides BOTH `subjectStrategy` and
68
+ * `recordName`. Receives `(topic, isKey, record)` for full flexibility.
69
+ *
70
+ * Backwards-compatible with the single-argument legacy form
71
+ * `(topic) => string` — extra args are ignored by JavaScript.
72
+ */
73
+ subject?: (topic: string, isKey?: boolean, record?: OutboxRecord) => string;
34
74
  /** content-type header value. Default "application/vnd.confluent.avro". */
35
75
  contentType?: string;
76
+ /**
77
+ * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.
78
+ * Default `true` — matches Confluent client behavior.
79
+ *
80
+ * Set to `false` for production clusters where schemas are managed
81
+ * out-of-band (Confluent Cloud, regulated environments). With
82
+ * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`
83
+ * on the computed subject — and the locally-supplied schema bytes are
84
+ * ignored.
85
+ */
86
+ autoRegister?: boolean;
36
87
  }
37
88
  /**
38
89
  * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry
39
90
  * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s
40
- * `serializer` option. The schema id per topic is resolved once and cached.
91
+ * `serializer` option. The schema id per (topic, isKey) tuple is resolved once
92
+ * and cached.
93
+ *
94
+ * Also exposes `serializeKey(record)` for users who want Avro-encoded message
95
+ * keys — call it manually when building the publish path; the relay does NOT
96
+ * call it automatically (key encoding is application-level by convention).
41
97
  */
42
98
  declare class SchemaRegistrySerializer implements Serializer {
43
99
  readonly contentType: string;
44
100
  private readonly schemas;
45
- private readonly subject;
101
+ private readonly keySchemas;
102
+ private readonly subjectStrategy;
103
+ private readonly recordName;
104
+ private readonly subjectFn;
46
105
  private readonly host;
106
+ private readonly autoRegister;
47
107
  private readonly idCache;
48
108
  private registry;
49
109
  constructor(opts: SchemaRegistrySerializerOptions);
50
110
  serialize(record: OutboxRecord): Promise<Buffer>;
111
+ /**
112
+ * Avro-encode the record's KEY using the registered key schema. Returns
113
+ * `null` when the record has no key (kafkajs/confluent treat null keys
114
+ * as the producer-side "no key" signal).
115
+ *
116
+ * Not part of the core `Serializer` interface — callers wire it into
117
+ * their publish path manually when they want Avro keys instead of raw
118
+ * UTF-8 strings.
119
+ */
120
+ serializeKey(record: OutboxRecord): Promise<Buffer | null>;
121
+ /**
122
+ * Resolve the subject for this (record, isKey) tuple. Order of
123
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
124
+ */
125
+ private resolveSubject;
126
+ private recordNameFor;
51
127
  private schemaId;
52
128
  private getRegistry;
53
129
  }
54
130
 
55
- export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType };
131
+ export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,19 @@ interface SchemaSpec {
6
6
  /** Schema definition string (avsc JSON / .proto / JSON Schema). */
7
7
  schema: string;
8
8
  }
9
+ /**
10
+ * Subject naming strategy. Mirrors Confluent's three built-ins:
11
+ *
12
+ * - `"TopicNameStrategy"` (default) — `${topic}-value` / `${topic}-key`.
13
+ * The conventional default; one schema per (topic, isKey) tuple.
14
+ * - `"RecordNameStrategy"` — `${recordName}`. Same record type can flow
15
+ * on multiple topics. Requires a `recordName` resolver.
16
+ * - `"TopicRecordNameStrategy"` — `${topic}-${recordName}`. Multiple record
17
+ * types per topic. Requires a `recordName` resolver.
18
+ *
19
+ * Set `subject` (function form) to override entirely.
20
+ */
21
+ type SubjectNameStrategy = "TopicNameStrategy" | "RecordNameStrategy" | "TopicRecordNameStrategy";
9
22
  /**
10
23
  * The subset of a Confluent Schema Registry client this serializer uses. The
11
24
  * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.
@@ -27,29 +40,92 @@ interface SchemaRegistrySerializerOptions {
27
40
  registry?: SchemaRegistryClient;
28
41
  /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */
29
42
  host?: string;
30
- /** Per-topic schema to register. Topics omitted here use the subject's latest. */
43
+ /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */
31
44
  schemas?: Record<string, SchemaSpec>;
32
- /** Subject naming. Default TopicNameStrategy: `${topic}-value`. */
33
- subject?: (topic: string) => string;
45
+ /**
46
+ * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes
47
+ * the record key for the matching topic. Topics omitted here fall back
48
+ * to the subject's latest (or, with `autoRegister: false`, ALWAYS the
49
+ * subject's latest).
50
+ */
51
+ keySchemas?: Record<string, SchemaSpec>;
52
+ /**
53
+ * Subject naming strategy preset. Default `"TopicNameStrategy"`.
54
+ * Setting `subject` (function) overrides this entirely.
55
+ */
56
+ subjectStrategy?: SubjectNameStrategy;
57
+ /**
58
+ * Resolve the schema's record name (used by `RecordNameStrategy` and
59
+ * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to
60
+ * one of those — throws on first serialize if absent.
61
+ *
62
+ * Typical implementation: read `${namespace}.${name}` from the avsc you
63
+ * already supply via `schemas` / `keySchemas`.
64
+ */
65
+ recordName?: (record: OutboxRecord, isKey: boolean) => string;
66
+ /**
67
+ * Custom subject function — overrides BOTH `subjectStrategy` and
68
+ * `recordName`. Receives `(topic, isKey, record)` for full flexibility.
69
+ *
70
+ * Backwards-compatible with the single-argument legacy form
71
+ * `(topic) => string` — extra args are ignored by JavaScript.
72
+ */
73
+ subject?: (topic: string, isKey?: boolean, record?: OutboxRecord) => string;
34
74
  /** content-type header value. Default "application/vnd.confluent.avro". */
35
75
  contentType?: string;
76
+ /**
77
+ * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.
78
+ * Default `true` — matches Confluent client behavior.
79
+ *
80
+ * Set to `false` for production clusters where schemas are managed
81
+ * out-of-band (Confluent Cloud, regulated environments). With
82
+ * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`
83
+ * on the computed subject — and the locally-supplied schema bytes are
84
+ * ignored.
85
+ */
86
+ autoRegister?: boolean;
36
87
  }
37
88
  /**
38
89
  * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry
39
90
  * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s
40
- * `serializer` option. The schema id per topic is resolved once and cached.
91
+ * `serializer` option. The schema id per (topic, isKey) tuple is resolved once
92
+ * and cached.
93
+ *
94
+ * Also exposes `serializeKey(record)` for users who want Avro-encoded message
95
+ * keys — call it manually when building the publish path; the relay does NOT
96
+ * call it automatically (key encoding is application-level by convention).
41
97
  */
42
98
  declare class SchemaRegistrySerializer implements Serializer {
43
99
  readonly contentType: string;
44
100
  private readonly schemas;
45
- private readonly subject;
101
+ private readonly keySchemas;
102
+ private readonly subjectStrategy;
103
+ private readonly recordName;
104
+ private readonly subjectFn;
46
105
  private readonly host;
106
+ private readonly autoRegister;
47
107
  private readonly idCache;
48
108
  private registry;
49
109
  constructor(opts: SchemaRegistrySerializerOptions);
50
110
  serialize(record: OutboxRecord): Promise<Buffer>;
111
+ /**
112
+ * Avro-encode the record's KEY using the registered key schema. Returns
113
+ * `null` when the record has no key (kafkajs/confluent treat null keys
114
+ * as the producer-side "no key" signal).
115
+ *
116
+ * Not part of the core `Serializer` interface — callers wire it into
117
+ * their publish path manually when they want Avro keys instead of raw
118
+ * UTF-8 strings.
119
+ */
120
+ serializeKey(record: OutboxRecord): Promise<Buffer | null>;
121
+ /**
122
+ * Resolve the subject for this (record, isKey) tuple. Order of
123
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
124
+ */
125
+ private resolveSubject;
126
+ private recordNameFor;
51
127
  private schemaId;
52
128
  private getRegistry;
53
129
  }
54
130
 
55
- export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType };
131
+ export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy };
package/dist/index.js CHANGED
@@ -3,8 +3,13 @@ var DEFAULT_CONTENT_TYPE = "application/vnd.confluent.avro";
3
3
  var SchemaRegistrySerializer = class {
4
4
  contentType;
5
5
  schemas;
6
- subject;
6
+ keySchemas;
7
+ subjectStrategy;
8
+ recordName;
9
+ subjectFn;
7
10
  host;
11
+ autoRegister;
12
+ // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.
8
13
  idCache = /* @__PURE__ */ new Map();
9
14
  registry;
10
15
  constructor(opts) {
@@ -16,25 +21,74 @@ var SchemaRegistrySerializer = class {
16
21
  this.registry = opts.registry ?? null;
17
22
  this.host = opts.host ?? null;
18
23
  this.schemas = opts.schemas ?? {};
19
- this.subject = opts.subject ?? ((topic) => `${topic}-value`);
24
+ this.keySchemas = opts.keySchemas ?? {};
25
+ this.subjectStrategy = opts.subjectStrategy ?? "TopicNameStrategy";
26
+ this.recordName = opts.recordName ?? null;
27
+ this.subjectFn = opts.subject ?? null;
20
28
  this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;
29
+ this.autoRegister = opts.autoRegister ?? true;
21
30
  }
22
31
  async serialize(record) {
23
32
  const registry = await this.getRegistry();
24
- const id = await this.schemaId(registry, record.topic);
33
+ const subject = this.resolveSubject(record, false);
34
+ const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);
25
35
  return registry.encode(id, record.payload);
26
36
  }
27
- schemaId(registry, topic) {
28
- const cached = this.idCache.get(topic);
37
+ /**
38
+ * Avro-encode the record's KEY using the registered key schema. Returns
39
+ * `null` when the record has no key (kafkajs/confluent treat null keys
40
+ * as the producer-side "no key" signal).
41
+ *
42
+ * Not part of the core `Serializer` interface — callers wire it into
43
+ * their publish path manually when they want Avro keys instead of raw
44
+ * UTF-8 strings.
45
+ */
46
+ async serializeKey(record) {
47
+ if (record.key === null || record.key === void 0) return null;
48
+ const registry = await this.getRegistry();
49
+ const subject = this.resolveSubject(record, true);
50
+ const id = await this.schemaId(
51
+ registry,
52
+ subject,
53
+ this.keySchemas[record.topic],
54
+ true,
55
+ record.topic
56
+ );
57
+ return registry.encode(id, record.key);
58
+ }
59
+ /**
60
+ * Resolve the subject for this (record, isKey) tuple. Order of
61
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
62
+ */
63
+ resolveSubject(record, isKey) {
64
+ if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);
65
+ switch (this.subjectStrategy) {
66
+ case "TopicNameStrategy":
67
+ return `${record.topic}-${isKey ? "key" : "value"}`;
68
+ case "RecordNameStrategy":
69
+ return this.recordNameFor(record, isKey);
70
+ case "TopicRecordNameStrategy":
71
+ return `${record.topic}-${this.recordNameFor(record, isKey)}`;
72
+ }
73
+ }
74
+ recordNameFor(record, isKey) {
75
+ if (!this.recordName) {
76
+ throw new Error(
77
+ `SchemaRegistrySerializer: subjectStrategy "${this.subjectStrategy}" requires a \`recordName\` resolver.`
78
+ );
79
+ }
80
+ return this.recordName(record, isKey);
81
+ }
82
+ schemaId(registry, subject, spec, isKey, topic) {
83
+ const cacheKey = `${topic}:${isKey ? "key" : "value"}`;
84
+ const cached = this.idCache.get(cacheKey);
29
85
  if (cached) return cached;
30
- const subject = this.subject(topic);
31
- const spec = this.schemas[topic];
32
- const lookup = spec ? registry.register({ type: spec.type, schema: spec.schema }, { subject }).then((r) => r.id) : registry.getLatestSchemaId(subject);
86
+ const lookup = spec && this.autoRegister ? registry.register({ type: spec.type, schema: spec.schema }, { subject }).then((r) => r.id) : registry.getLatestSchemaId(subject);
33
87
  const guarded = lookup.catch((err) => {
34
- this.idCache.delete(topic);
88
+ this.idCache.delete(cacheKey);
35
89
  throw err;
36
90
  });
37
- this.idCache.set(topic, guarded);
91
+ this.idCache.set(cacheKey, guarded);
38
92
  return guarded;
39
93
  }
40
94
  async getRegistry() {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/serializer.ts"],"sourcesContent":["import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /** Per-topic schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /** Subject naming. Default TopicNameStrategy: `${topic}-value`. */\n subject?: (topic: string) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per topic is resolved once and cached.\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly subject: (topic: string) => string;\n private readonly host: string | null;\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.schemas = opts.schemas ?? {};\n this.subject = opts.subject ?? ((topic) => `${topic}-value`);\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const id = await this.schemaId(registry, record.topic);\n return registry.encode(id, record.payload);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n topic: string,\n ): Promise<number> {\n const cached = this.idCache.get(topic);\n if (cached) return cached;\n\n const subject = this.subject(topic);\n const spec = this.schemas[topic];\n const lookup = spec\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(topic);\n throw err;\n });\n this.idCache.set(topic, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n this.registry = new mod.SchemaRegistry({ host: this.host as string });\n return this.registry;\n }\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (cfg: { host: string }) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":";AAoCA,IAAM,uBAAuB;AAOtB,IAAM,2BAAN,MAAqD;AAAA,EACjD;AAAA,EACQ;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU,oBAAI,IAA6B;AAAA,EACpD;AAAA,EAER,YAAY,MAAuC;AACjD,QAAI,CAAC,KAAK,YAAY,CAAC,KAAK,MAAM;AAChC,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,SAAK,WAAW,KAAK,YAAY;AACjC,SAAK,OAAO,KAAK,QAAQ;AACzB,SAAK,UAAU,KAAK,WAAW,CAAC;AAChC,SAAK,UAAU,KAAK,YAAY,CAAC,UAAU,GAAG,KAAK;AACnD,SAAK,cAAc,KAAK,eAAe;AAAA,EACzC;AAAA,EAEA,MAAM,UAAU,QAAuC;AACrD,UAAM,WAAW,MAAM,KAAK,YAAY;AACxC,UAAM,KAAK,MAAM,KAAK,SAAS,UAAU,OAAO,KAAK;AACrD,WAAO,SAAS,OAAO,IAAI,OAAO,OAAO;AAAA,EAC3C;AAAA,EAEQ,SACN,UACA,OACiB;AACjB,UAAM,SAAS,KAAK,QAAQ,IAAI,KAAK;AACrC,QAAI,OAAQ,QAAO;AAEnB,UAAM,UAAU,KAAK,QAAQ,KAAK;AAClC,UAAM,OAAO,KAAK,QAAQ,KAAK;AAC/B,UAAM,SAAS,OACX,SACG,SAAS,EAAE,MAAM,KAAK,MAAM,QAAQ,KAAK,OAAO,GAAG,EAAE,QAAQ,CAAC,EAC9D,KAAK,CAAC,MAAM,EAAE,EAAE,IACnB,SAAS,kBAAkB,OAAO;AAItC,UAAM,UAAU,OAAO,MAAM,CAAC,QAAQ;AACpC,WAAK,QAAQ,OAAO,KAAK;AACzB,YAAM;AAAA,IACR,CAAC;AACD,SAAK,QAAQ,IAAI,OAAO,OAAO;AAC/B,WAAO;AAAA,EACT;AAAA,EAEA,MAAc,cAA6C;AACzD,QAAI,KAAK,SAAU,QAAO,KAAK;AAC/B,UAAM,MAAM,MAAM,qBAAqB;AACvC,SAAK,WAAW,IAAI,IAAI,eAAe,EAAE,MAAM,KAAK,KAAe,CAAC;AACpE,WAAO,KAAK;AAAA,EACd;AACF;AAEA,eAAe,uBAEZ;AACD,MAAI;AAEF,WAAQ,MAAM,OAAO,oCAAoC;AAAA,EAC3D,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/serializer.ts"],"sourcesContent":["import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * Subject naming strategy. Mirrors Confluent's three built-ins:\n *\n * - `\"TopicNameStrategy\"` (default) — `${topic}-value` / `${topic}-key`.\n * The conventional default; one schema per (topic, isKey) tuple.\n * - `\"RecordNameStrategy\"` — `${recordName}`. Same record type can flow\n * on multiple topics. Requires a `recordName` resolver.\n * - `\"TopicRecordNameStrategy\"` — `${topic}-${recordName}`. Multiple record\n * types per topic. Requires a `recordName` resolver.\n *\n * Set `subject` (function form) to override entirely.\n */\nexport type SubjectNameStrategy =\n | \"TopicNameStrategy\"\n | \"RecordNameStrategy\"\n | \"TopicRecordNameStrategy\";\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /**\n * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes\n * the record key for the matching topic. Topics omitted here fall back\n * to the subject's latest (or, with `autoRegister: false`, ALWAYS the\n * subject's latest).\n */\n keySchemas?: Record<string, SchemaSpec>;\n /**\n * Subject naming strategy preset. Default `\"TopicNameStrategy\"`.\n * Setting `subject` (function) overrides this entirely.\n */\n subjectStrategy?: SubjectNameStrategy;\n /**\n * Resolve the schema's record name (used by `RecordNameStrategy` and\n * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to\n * one of those — throws on first serialize if absent.\n *\n * Typical implementation: read `${namespace}.${name}` from the avsc you\n * already supply via `schemas` / `keySchemas`.\n */\n recordName?: (record: OutboxRecord, isKey: boolean) => string;\n /**\n * Custom subject function — overrides BOTH `subjectStrategy` and\n * `recordName`. Receives `(topic, isKey, record)` for full flexibility.\n *\n * Backwards-compatible with the single-argument legacy form\n * `(topic) => string` — extra args are ignored by JavaScript.\n */\n subject?: (\n topic: string,\n isKey?: boolean,\n record?: OutboxRecord,\n ) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n /**\n * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.\n * Default `true` — matches Confluent client behavior.\n *\n * Set to `false` for production clusters where schemas are managed\n * out-of-band (Confluent Cloud, regulated environments). With\n * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`\n * on the computed subject — and the locally-supplied schema bytes are\n * ignored.\n */\n autoRegister?: boolean;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per (topic, isKey) tuple is resolved once\n * and cached.\n *\n * Also exposes `serializeKey(record)` for users who want Avro-encoded message\n * keys — call it manually when building the publish path; the relay does NOT\n * call it automatically (key encoding is application-level by convention).\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly keySchemas: Record<string, SchemaSpec>;\n private readonly subjectStrategy: SubjectNameStrategy;\n private readonly recordName:\n | ((record: OutboxRecord, isKey: boolean) => string)\n | null;\n private readonly subjectFn:\n | ((topic: string, isKey?: boolean, record?: OutboxRecord) => string)\n | null;\n private readonly host: string | null;\n private readonly autoRegister: boolean;\n // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.schemas = opts.schemas ?? {};\n this.keySchemas = opts.keySchemas ?? {};\n this.subjectStrategy = opts.subjectStrategy ?? \"TopicNameStrategy\";\n this.recordName = opts.recordName ?? null;\n this.subjectFn = opts.subject ?? null;\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n this.autoRegister = opts.autoRegister ?? true;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, false);\n const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);\n return registry.encode(id, record.payload);\n }\n\n /**\n * Avro-encode the record's KEY using the registered key schema. Returns\n * `null` when the record has no key (kafkajs/confluent treat null keys\n * as the producer-side \"no key\" signal).\n *\n * Not part of the core `Serializer` interface — callers wire it into\n * their publish path manually when they want Avro keys instead of raw\n * UTF-8 strings.\n */\n async serializeKey(record: OutboxRecord): Promise<Buffer | null> {\n if (record.key === null || record.key === undefined) return null;\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, true);\n const id = await this.schemaId(\n registry,\n subject,\n this.keySchemas[record.topic],\n true,\n record.topic,\n );\n return registry.encode(id, record.key);\n }\n\n /**\n * Resolve the subject for this (record, isKey) tuple. Order of\n * precedence: explicit `subject` function → `subjectStrategy` preset.\n */\n private resolveSubject(record: OutboxRecord, isKey: boolean): string {\n if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);\n switch (this.subjectStrategy) {\n case \"TopicNameStrategy\":\n return `${record.topic}-${isKey ? \"key\" : \"value\"}`;\n case \"RecordNameStrategy\":\n return this.recordNameFor(record, isKey);\n case \"TopicRecordNameStrategy\":\n return `${record.topic}-${this.recordNameFor(record, isKey)}`;\n }\n }\n\n private recordNameFor(record: OutboxRecord, isKey: boolean): string {\n if (!this.recordName) {\n throw new Error(\n `SchemaRegistrySerializer: subjectStrategy \"${this.subjectStrategy}\" requires a \\`recordName\\` resolver.`,\n );\n }\n return this.recordName(record, isKey);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n subject: string,\n spec: SchemaSpec | undefined,\n isKey: boolean,\n topic: string,\n ): Promise<number> {\n const cacheKey = `${topic}:${isKey ? \"key\" : \"value\"}`;\n const cached = this.idCache.get(cacheKey);\n if (cached) return cached;\n\n // autoRegister=false → ALWAYS resolve by latest; the local spec\n // (if any) is ignored. Matches Confluent's auto.register.schemas=false.\n const lookup =\n spec && this.autoRegister\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(cacheKey);\n throw err;\n });\n this.idCache.set(cacheKey, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n this.registry = new mod.SchemaRegistry({ host: this.host as string });\n return this.registry;\n }\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (cfg: { host: string }) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventferry/schema-registry",
3
- "version": "3.2.3",
3
+ "version": "3.3.0",
4
4
  "description": "Confluent Schema Registry serializer for @eventferry (Avro/Protobuf/JSON Schema)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",