@eventferry/schema-registry 3.2.3 → 3.4.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,34 @@
1
1
  # @eventferry/schema-registry
2
2
 
3
+ ## 3.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0f328d2: Typed authentication for the Schema Registry HTTP API. New `auth?: SchemaRegistryAuth` option on `SchemaRegistrySerializer` with two shapes:
8
+
9
+ - `{ type: "basic", username, password }` — HTTP Basic, forwarded straight to the underlying client's `auth` config (the conventional Confluent Cloud + commercial-registry shape).
10
+ - `{ type: "bearer", token: string | () => string | Promise<string> }` — adds an `Authorization: Bearer <token>` header via a small middleware on the upstream client. Callable tokens are resolved on **every** request, so rotation logic lives in the caller's provider (cache inside your callable if rotation cost matters).
11
+
12
+ Ignored when an already-constructed `registry` client is injected — configure auth there yourself. mTLS to the registry stays out of scope: it's handled by a custom `https.Agent` on a self-constructed client (registry TLS is independent of broker TLS, and we don't want to fold an unrelated knob into this surface).
13
+
14
+ The middleware factory is exported as `bearerAuthMiddleware` for testing only — not part of the supported public API and may move in a future version.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [715523f]
19
+ - Updated dependencies [fb0549d]
20
+ - @eventferry/core@3.4.0
21
+
22
+ ## 3.3.0
23
+
24
+ ### Minor Changes
25
+
26
+ - d983d90: Schema Registry serializer gains three production-grade controls without breaking the existing API:
27
+
28
+ - **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).
29
+ - **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.
30
+ - **`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.
31
+
3
32
  ## 3.2.3
4
33
 
5
34
  ### Patch Changes
package/README.md CHANGED
@@ -32,6 +32,95 @@ 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
+ ## Authentication
36
+
37
+ The serializer accepts the two HTTP auth shapes Confluent Schema Registry installations use:
38
+
39
+ ```ts
40
+ // HTTP Basic — Confluent Cloud + most commercial registries.
41
+ new SchemaRegistrySerializer({
42
+ host,
43
+ auth: { type: "basic", username: "<api-key>", password: "<api-secret>" },
44
+ });
45
+
46
+ // Bearer token — OIDC, custom SR proxies, etc.
47
+ new SchemaRegistrySerializer({
48
+ host,
49
+ auth: { type: "bearer", token: "eyJhbGc..." },
50
+ });
51
+
52
+ // Rotating bearer token (refresh per request — cache inside your provider).
53
+ new SchemaRegistrySerializer({
54
+ host,
55
+ auth: {
56
+ type: "bearer",
57
+ token: async () => await getCachedAccessToken(),
58
+ },
59
+ });
60
+ ```
61
+
62
+ Bearer tokens are injected via a small middleware on the underlying client — the provider is invoked on **every** request, so you control rotation. `auth` is ignored when you pass an already-constructed `registry` client (configure auth there yourself).
63
+
64
+ For **mTLS** to the registry, supply a custom `https.Agent` on a self-constructed client and pass it via the `registry` option. The serializer does not surface a separate `tls` block — registry TLS is independent of broker TLS and `https.Agent` is the standard Node entry point.
65
+
66
+ ## Subject naming strategies
67
+
68
+ Pick one of Confluent's three built-in strategies (default `TopicNameStrategy`):
69
+
70
+ ```ts
71
+ new SchemaRegistrySerializer({
72
+ host,
73
+ subjectStrategy: "RecordNameStrategy", // subject = recordName
74
+ // or: "TopicRecordNameStrategy" // subject = `${topic}-${recordName}`
75
+ recordName: (record) => `com.example.${record.aggregateType}.Created`,
76
+ });
77
+ ```
78
+
79
+ `recordName` is required for the `RecordName` and `TopicRecordName` strategies — typical implementation reads `${namespace}.${name}` from your avsc.
80
+
81
+ Need full control? Skip the preset and pass an explicit `subject` function:
82
+
83
+ ```ts
84
+ new SchemaRegistrySerializer({
85
+ host,
86
+ subject: (topic, isKey, record) => `acme.${topic}.${isKey ? "key" : "value"}`,
87
+ });
88
+ ```
89
+
90
+ ## Avro key serialization
91
+
92
+ Configure per-topic key schemas and call `serializeKey` from your publish path:
93
+
94
+ ```ts
95
+ const serializer = new SchemaRegistrySerializer({
96
+ host,
97
+ schemas: { "orders.created": { type: "AVRO", schema: valueAvsc } },
98
+ keySchemas: { "orders.created": { type: "AVRO", schema: keyAvsc } },
99
+ });
100
+
101
+ // Inside your custom publish glue:
102
+ const encodedValue = await serializer.serialize(record);
103
+ const encodedKey = await serializer.serializeKey(record); // null when record.key is null
104
+ ```
105
+
106
+ 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.
107
+
108
+ Key and value subjects cache their schema ids independently — registering one doesn't affect the other.
109
+
110
+ ## Auto-register toggle
111
+
112
+ For production clusters where schemas are managed out-of-band (Confluent Cloud, regulated environments), turn auto-registration off:
113
+
114
+ ```ts
115
+ new SchemaRegistrySerializer({
116
+ host,
117
+ schemas: { /* ignored when autoRegister is false */ },
118
+ autoRegister: false,
119
+ });
120
+ ```
121
+
122
+ 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`.
123
+
35
124
  📖 **Full documentation:** [github.com/SametGoktepe/eventferry](https://github.com/SametGoktepe/eventferry#readme)
36
125
 
37
126
  ## License
package/dist/index.cjs CHANGED
@@ -30,7 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- SchemaRegistrySerializer: () => SchemaRegistrySerializer
33
+ SchemaRegistrySerializer: () => SchemaRegistrySerializer,
34
+ bearerAuthMiddleware: () => bearerAuthMiddleware
34
35
  });
35
36
  module.exports = __toCommonJS(index_exports);
36
37
 
@@ -39,8 +40,14 @@ var DEFAULT_CONTENT_TYPE = "application/vnd.confluent.avro";
39
40
  var SchemaRegistrySerializer = class {
40
41
  contentType;
41
42
  schemas;
42
- subject;
43
+ keySchemas;
44
+ subjectStrategy;
45
+ recordName;
46
+ subjectFn;
43
47
  host;
48
+ auth;
49
+ autoRegister;
50
+ // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.
44
51
  idCache = /* @__PURE__ */ new Map();
45
52
  registry;
46
53
  constructor(opts) {
@@ -51,35 +58,107 @@ var SchemaRegistrySerializer = class {
51
58
  }
52
59
  this.registry = opts.registry ?? null;
53
60
  this.host = opts.host ?? null;
61
+ this.auth = opts.auth ?? null;
54
62
  this.schemas = opts.schemas ?? {};
55
- this.subject = opts.subject ?? ((topic) => `${topic}-value`);
63
+ this.keySchemas = opts.keySchemas ?? {};
64
+ this.subjectStrategy = opts.subjectStrategy ?? "TopicNameStrategy";
65
+ this.recordName = opts.recordName ?? null;
66
+ this.subjectFn = opts.subject ?? null;
56
67
  this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;
68
+ this.autoRegister = opts.autoRegister ?? true;
57
69
  }
58
70
  async serialize(record) {
59
71
  const registry = await this.getRegistry();
60
- const id = await this.schemaId(registry, record.topic);
72
+ const subject = this.resolveSubject(record, false);
73
+ const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);
61
74
  return registry.encode(id, record.payload);
62
75
  }
63
- schemaId(registry, topic) {
64
- const cached = this.idCache.get(topic);
76
+ /**
77
+ * Avro-encode the record's KEY using the registered key schema. Returns
78
+ * `null` when the record has no key (kafkajs/confluent treat null keys
79
+ * as the producer-side "no key" signal).
80
+ *
81
+ * Not part of the core `Serializer` interface — callers wire it into
82
+ * their publish path manually when they want Avro keys instead of raw
83
+ * UTF-8 strings.
84
+ */
85
+ async serializeKey(record) {
86
+ if (record.key === null || record.key === void 0) return null;
87
+ const registry = await this.getRegistry();
88
+ const subject = this.resolveSubject(record, true);
89
+ const id = await this.schemaId(
90
+ registry,
91
+ subject,
92
+ this.keySchemas[record.topic],
93
+ true,
94
+ record.topic
95
+ );
96
+ return registry.encode(id, record.key);
97
+ }
98
+ /**
99
+ * Resolve the subject for this (record, isKey) tuple. Order of
100
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
101
+ */
102
+ resolveSubject(record, isKey) {
103
+ if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);
104
+ switch (this.subjectStrategy) {
105
+ case "TopicNameStrategy":
106
+ return `${record.topic}-${isKey ? "key" : "value"}`;
107
+ case "RecordNameStrategy":
108
+ return this.recordNameFor(record, isKey);
109
+ case "TopicRecordNameStrategy":
110
+ return `${record.topic}-${this.recordNameFor(record, isKey)}`;
111
+ }
112
+ }
113
+ recordNameFor(record, isKey) {
114
+ if (!this.recordName) {
115
+ throw new Error(
116
+ `SchemaRegistrySerializer: subjectStrategy "${this.subjectStrategy}" requires a \`recordName\` resolver.`
117
+ );
118
+ }
119
+ return this.recordName(record, isKey);
120
+ }
121
+ schemaId(registry, subject, spec, isKey, topic) {
122
+ const cacheKey = `${topic}:${isKey ? "key" : "value"}`;
123
+ const cached = this.idCache.get(cacheKey);
65
124
  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);
125
+ const lookup = spec && this.autoRegister ? registry.register({ type: spec.type, schema: spec.schema }, { subject }).then((r) => r.id) : registry.getLatestSchemaId(subject);
69
126
  const guarded = lookup.catch((err) => {
70
- this.idCache.delete(topic);
127
+ this.idCache.delete(cacheKey);
71
128
  throw err;
72
129
  });
73
- this.idCache.set(topic, guarded);
130
+ this.idCache.set(cacheKey, guarded);
74
131
  return guarded;
75
132
  }
76
133
  async getRegistry() {
77
134
  if (this.registry) return this.registry;
78
135
  const mod = await importSchemaRegistry();
79
- this.registry = new mod.SchemaRegistry({ host: this.host });
136
+ const cfg = {
137
+ host: this.host
138
+ };
139
+ if (this.auth) {
140
+ if (this.auth.type === "basic") {
141
+ cfg.auth = {
142
+ username: this.auth.username,
143
+ password: this.auth.password
144
+ };
145
+ } else {
146
+ cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];
147
+ }
148
+ }
149
+ this.registry = new mod.SchemaRegistry(cfg);
80
150
  return this.registry;
81
151
  }
82
152
  };
153
+ function bearerAuthMiddleware(token) {
154
+ return () => ({
155
+ async prepareRequest(next) {
156
+ const request = await next();
157
+ const value = typeof token === "function" ? await token() : token;
158
+ return request.enhance({ headers: { Authorization: `Bearer ${value}` } });
159
+ }
160
+ });
161
+ }
83
162
  async function importSchemaRegistry() {
84
163
  try {
85
164
  return await import("@kafkajs/confluent-schema-registry");
@@ -91,6 +170,7 @@ async function importSchemaRegistry() {
91
170
  }
92
171
  // Annotate the CommonJS export names for ESM import in node:
93
172
  0 && (module.exports = {
94
- SchemaRegistrySerializer
173
+ SchemaRegistrySerializer,
174
+ bearerAuthMiddleware
95
175
  });
96
176
  //# sourceMappingURL=index.cjs.map
@@ -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 * Authentication for the Schema Registry HTTP API.\n *\n * - `\"basic\"` — HTTP Basic Auth. The shape Confluent Cloud and most\n * commercial registries use; passed straight through to the underlying\n * client's `auth` config.\n * - `\"bearer\"` — `Authorization: Bearer <token>` header. The `token`\n * field accepts either a static string OR a callable that resolves a\n * fresh token on every request (cache inside your callable to amortise\n * cost; we don't memoize for you, so OAuth refresh-loop logic lives\n * on your side).\n *\n * mTLS for the registry connection itself is handled by Node's `tls`\n * stack — supply a custom `https.Agent` via the upstream client's\n * `agent` option (use the `registry` injection here and configure it\n * yourself), separate from the broker TLS the publisher uses.\n */\nexport type SchemaRegistryAuth =\n | { type: \"basic\"; username: string; password: string }\n | {\n type: \"bearer\";\n token: string | (() => string | Promise<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 /**\n * Optional authentication for the Schema Registry HTTP API. See\n * {@link SchemaRegistryAuth} for the two supported shapes. Ignored\n * when `registry` is provided (configure auth on the injected client\n * yourself in that case).\n */\n auth?: SchemaRegistryAuth;\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 auth: SchemaRegistryAuth | 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.auth = opts.auth ?? 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 const cfg: SchemaRegistryConstructorConfig = {\n host: this.host as string,\n };\n if (this.auth) {\n if (this.auth.type === \"basic\") {\n // Confluent SR client accepts `auth: { username, password }`\n // and the mappersmith basic-auth middleware builds the header.\n cfg.auth = {\n username: this.auth.username,\n password: this.auth.password,\n };\n } else {\n // Bearer: SR doesn't ship a built-in middleware. Inject our own\n // so every API call carries `Authorization: Bearer <token>`.\n cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];\n }\n }\n this.registry = new mod.SchemaRegistry(cfg);\n return this.registry;\n }\n}\n\n/**\n * Mappersmith middleware shape — the structural subset the Confluent\n * SR client passes through. We don't depend on mappersmith types\n * directly so the package compiles without the optional peer installed.\n */\ninterface MmRequest {\n enhance(args: { headers?: Record<string, string> }): MmRequest;\n}\n\n/**\n * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.\n *\n * Exported for testing only — not part of the public API surface. The\n * middleware resolves the token on EVERY request, so callable token\n * providers can rotate without re-constructing the serializer. Cache\n * inside your provider if rotation cost matters.\n */\nexport function bearerAuthMiddleware(\n token: string | (() => string | Promise<string>),\n) {\n return () => ({\n async prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest> {\n const request = await next();\n const value = typeof token === \"function\" ? await token() : token;\n return request.enhance({ headers: { Authorization: `Bearer ${value}` } });\n },\n });\n}\n\n/**\n * Structural shape the SR client's constructor accepts. Mirrors\n * `SchemaRegistryAPIClientArgs` from the upstream package without\n * importing the type directly (optional peer).\n */\ninterface SchemaRegistryConstructorConfig {\n host: string;\n auth?: { username: string; password: string };\n middlewares?: Array<() => unknown>;\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (\n cfg: SchemaRegistryConstructorConfig,\n ) => 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,44 @@ 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";
22
+ /**
23
+ * Authentication for the Schema Registry HTTP API.
24
+ *
25
+ * - `"basic"` — HTTP Basic Auth. The shape Confluent Cloud and most
26
+ * commercial registries use; passed straight through to the underlying
27
+ * client's `auth` config.
28
+ * - `"bearer"` — `Authorization: Bearer <token>` header. The `token`
29
+ * field accepts either a static string OR a callable that resolves a
30
+ * fresh token on every request (cache inside your callable to amortise
31
+ * cost; we don't memoize for you, so OAuth refresh-loop logic lives
32
+ * on your side).
33
+ *
34
+ * mTLS for the registry connection itself is handled by Node's `tls`
35
+ * stack — supply a custom `https.Agent` via the upstream client's
36
+ * `agent` option (use the `registry` injection here and configure it
37
+ * yourself), separate from the broker TLS the publisher uses.
38
+ */
39
+ type SchemaRegistryAuth = {
40
+ type: "basic";
41
+ username: string;
42
+ password: string;
43
+ } | {
44
+ type: "bearer";
45
+ token: string | (() => string | Promise<string>);
46
+ };
9
47
  /**
10
48
  * The subset of a Confluent Schema Registry client this serializer uses. The
11
49
  * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.
@@ -27,29 +65,121 @@ interface SchemaRegistrySerializerOptions {
27
65
  registry?: SchemaRegistryClient;
28
66
  /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */
29
67
  host?: string;
30
- /** Per-topic schema to register. Topics omitted here use the subject's latest. */
68
+ /**
69
+ * Optional authentication for the Schema Registry HTTP API. See
70
+ * {@link SchemaRegistryAuth} for the two supported shapes. Ignored
71
+ * when `registry` is provided (configure auth on the injected client
72
+ * yourself in that case).
73
+ */
74
+ auth?: SchemaRegistryAuth;
75
+ /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */
31
76
  schemas?: Record<string, SchemaSpec>;
32
- /** Subject naming. Default TopicNameStrategy: `${topic}-value`. */
33
- subject?: (topic: string) => string;
77
+ /**
78
+ * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes
79
+ * the record key for the matching topic. Topics omitted here fall back
80
+ * to the subject's latest (or, with `autoRegister: false`, ALWAYS the
81
+ * subject's latest).
82
+ */
83
+ keySchemas?: Record<string, SchemaSpec>;
84
+ /**
85
+ * Subject naming strategy preset. Default `"TopicNameStrategy"`.
86
+ * Setting `subject` (function) overrides this entirely.
87
+ */
88
+ subjectStrategy?: SubjectNameStrategy;
89
+ /**
90
+ * Resolve the schema's record name (used by `RecordNameStrategy` and
91
+ * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to
92
+ * one of those — throws on first serialize if absent.
93
+ *
94
+ * Typical implementation: read `${namespace}.${name}` from the avsc you
95
+ * already supply via `schemas` / `keySchemas`.
96
+ */
97
+ recordName?: (record: OutboxRecord, isKey: boolean) => string;
98
+ /**
99
+ * Custom subject function — overrides BOTH `subjectStrategy` and
100
+ * `recordName`. Receives `(topic, isKey, record)` for full flexibility.
101
+ *
102
+ * Backwards-compatible with the single-argument legacy form
103
+ * `(topic) => string` — extra args are ignored by JavaScript.
104
+ */
105
+ subject?: (topic: string, isKey?: boolean, record?: OutboxRecord) => string;
34
106
  /** content-type header value. Default "application/vnd.confluent.avro". */
35
107
  contentType?: string;
108
+ /**
109
+ * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.
110
+ * Default `true` — matches Confluent client behavior.
111
+ *
112
+ * Set to `false` for production clusters where schemas are managed
113
+ * out-of-band (Confluent Cloud, regulated environments). With
114
+ * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`
115
+ * on the computed subject — and the locally-supplied schema bytes are
116
+ * ignored.
117
+ */
118
+ autoRegister?: boolean;
36
119
  }
37
120
  /**
38
121
  * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry
39
122
  * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s
40
- * `serializer` option. The schema id per topic is resolved once and cached.
123
+ * `serializer` option. The schema id per (topic, isKey) tuple is resolved once
124
+ * and cached.
125
+ *
126
+ * Also exposes `serializeKey(record)` for users who want Avro-encoded message
127
+ * keys — call it manually when building the publish path; the relay does NOT
128
+ * call it automatically (key encoding is application-level by convention).
41
129
  */
42
130
  declare class SchemaRegistrySerializer implements Serializer {
43
131
  readonly contentType: string;
44
132
  private readonly schemas;
45
- private readonly subject;
133
+ private readonly keySchemas;
134
+ private readonly subjectStrategy;
135
+ private readonly recordName;
136
+ private readonly subjectFn;
46
137
  private readonly host;
138
+ private readonly auth;
139
+ private readonly autoRegister;
47
140
  private readonly idCache;
48
141
  private registry;
49
142
  constructor(opts: SchemaRegistrySerializerOptions);
50
143
  serialize(record: OutboxRecord): Promise<Buffer>;
144
+ /**
145
+ * Avro-encode the record's KEY using the registered key schema. Returns
146
+ * `null` when the record has no key (kafkajs/confluent treat null keys
147
+ * as the producer-side "no key" signal).
148
+ *
149
+ * Not part of the core `Serializer` interface — callers wire it into
150
+ * their publish path manually when they want Avro keys instead of raw
151
+ * UTF-8 strings.
152
+ */
153
+ serializeKey(record: OutboxRecord): Promise<Buffer | null>;
154
+ /**
155
+ * Resolve the subject for this (record, isKey) tuple. Order of
156
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
157
+ */
158
+ private resolveSubject;
159
+ private recordNameFor;
51
160
  private schemaId;
52
161
  private getRegistry;
53
162
  }
163
+ /**
164
+ * Mappersmith middleware shape — the structural subset the Confluent
165
+ * SR client passes through. We don't depend on mappersmith types
166
+ * directly so the package compiles without the optional peer installed.
167
+ */
168
+ interface MmRequest {
169
+ enhance(args: {
170
+ headers?: Record<string, string>;
171
+ }): MmRequest;
172
+ }
173
+ /**
174
+ * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.
175
+ *
176
+ * Exported for testing only — not part of the public API surface. The
177
+ * middleware resolves the token on EVERY request, so callable token
178
+ * providers can rotate without re-constructing the serializer. Cache
179
+ * inside your provider if rotation cost matters.
180
+ */
181
+ declare function bearerAuthMiddleware(token: string | (() => string | Promise<string>)): () => {
182
+ prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest>;
183
+ };
54
184
 
55
- export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType };
185
+ export { type SchemaRegistryAuth, type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy, bearerAuthMiddleware };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,44 @@ 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";
22
+ /**
23
+ * Authentication for the Schema Registry HTTP API.
24
+ *
25
+ * - `"basic"` — HTTP Basic Auth. The shape Confluent Cloud and most
26
+ * commercial registries use; passed straight through to the underlying
27
+ * client's `auth` config.
28
+ * - `"bearer"` — `Authorization: Bearer <token>` header. The `token`
29
+ * field accepts either a static string OR a callable that resolves a
30
+ * fresh token on every request (cache inside your callable to amortise
31
+ * cost; we don't memoize for you, so OAuth refresh-loop logic lives
32
+ * on your side).
33
+ *
34
+ * mTLS for the registry connection itself is handled by Node's `tls`
35
+ * stack — supply a custom `https.Agent` via the upstream client's
36
+ * `agent` option (use the `registry` injection here and configure it
37
+ * yourself), separate from the broker TLS the publisher uses.
38
+ */
39
+ type SchemaRegistryAuth = {
40
+ type: "basic";
41
+ username: string;
42
+ password: string;
43
+ } | {
44
+ type: "bearer";
45
+ token: string | (() => string | Promise<string>);
46
+ };
9
47
  /**
10
48
  * The subset of a Confluent Schema Registry client this serializer uses. The
11
49
  * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.
@@ -27,29 +65,121 @@ interface SchemaRegistrySerializerOptions {
27
65
  registry?: SchemaRegistryClient;
28
66
  /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */
29
67
  host?: string;
30
- /** Per-topic schema to register. Topics omitted here use the subject's latest. */
68
+ /**
69
+ * Optional authentication for the Schema Registry HTTP API. See
70
+ * {@link SchemaRegistryAuth} for the two supported shapes. Ignored
71
+ * when `registry` is provided (configure auth on the injected client
72
+ * yourself in that case).
73
+ */
74
+ auth?: SchemaRegistryAuth;
75
+ /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */
31
76
  schemas?: Record<string, SchemaSpec>;
32
- /** Subject naming. Default TopicNameStrategy: `${topic}-value`. */
33
- subject?: (topic: string) => string;
77
+ /**
78
+ * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes
79
+ * the record key for the matching topic. Topics omitted here fall back
80
+ * to the subject's latest (or, with `autoRegister: false`, ALWAYS the
81
+ * subject's latest).
82
+ */
83
+ keySchemas?: Record<string, SchemaSpec>;
84
+ /**
85
+ * Subject naming strategy preset. Default `"TopicNameStrategy"`.
86
+ * Setting `subject` (function) overrides this entirely.
87
+ */
88
+ subjectStrategy?: SubjectNameStrategy;
89
+ /**
90
+ * Resolve the schema's record name (used by `RecordNameStrategy` and
91
+ * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to
92
+ * one of those — throws on first serialize if absent.
93
+ *
94
+ * Typical implementation: read `${namespace}.${name}` from the avsc you
95
+ * already supply via `schemas` / `keySchemas`.
96
+ */
97
+ recordName?: (record: OutboxRecord, isKey: boolean) => string;
98
+ /**
99
+ * Custom subject function — overrides BOTH `subjectStrategy` and
100
+ * `recordName`. Receives `(topic, isKey, record)` for full flexibility.
101
+ *
102
+ * Backwards-compatible with the single-argument legacy form
103
+ * `(topic) => string` — extra args are ignored by JavaScript.
104
+ */
105
+ subject?: (topic: string, isKey?: boolean, record?: OutboxRecord) => string;
34
106
  /** content-type header value. Default "application/vnd.confluent.avro". */
35
107
  contentType?: string;
108
+ /**
109
+ * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.
110
+ * Default `true` — matches Confluent client behavior.
111
+ *
112
+ * Set to `false` for production clusters where schemas are managed
113
+ * out-of-band (Confluent Cloud, regulated environments). With
114
+ * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`
115
+ * on the computed subject — and the locally-supplied schema bytes are
116
+ * ignored.
117
+ */
118
+ autoRegister?: boolean;
36
119
  }
37
120
  /**
38
121
  * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry
39
122
  * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s
40
- * `serializer` option. The schema id per topic is resolved once and cached.
123
+ * `serializer` option. The schema id per (topic, isKey) tuple is resolved once
124
+ * and cached.
125
+ *
126
+ * Also exposes `serializeKey(record)` for users who want Avro-encoded message
127
+ * keys — call it manually when building the publish path; the relay does NOT
128
+ * call it automatically (key encoding is application-level by convention).
41
129
  */
42
130
  declare class SchemaRegistrySerializer implements Serializer {
43
131
  readonly contentType: string;
44
132
  private readonly schemas;
45
- private readonly subject;
133
+ private readonly keySchemas;
134
+ private readonly subjectStrategy;
135
+ private readonly recordName;
136
+ private readonly subjectFn;
46
137
  private readonly host;
138
+ private readonly auth;
139
+ private readonly autoRegister;
47
140
  private readonly idCache;
48
141
  private registry;
49
142
  constructor(opts: SchemaRegistrySerializerOptions);
50
143
  serialize(record: OutboxRecord): Promise<Buffer>;
144
+ /**
145
+ * Avro-encode the record's KEY using the registered key schema. Returns
146
+ * `null` when the record has no key (kafkajs/confluent treat null keys
147
+ * as the producer-side "no key" signal).
148
+ *
149
+ * Not part of the core `Serializer` interface — callers wire it into
150
+ * their publish path manually when they want Avro keys instead of raw
151
+ * UTF-8 strings.
152
+ */
153
+ serializeKey(record: OutboxRecord): Promise<Buffer | null>;
154
+ /**
155
+ * Resolve the subject for this (record, isKey) tuple. Order of
156
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
157
+ */
158
+ private resolveSubject;
159
+ private recordNameFor;
51
160
  private schemaId;
52
161
  private getRegistry;
53
162
  }
163
+ /**
164
+ * Mappersmith middleware shape — the structural subset the Confluent
165
+ * SR client passes through. We don't depend on mappersmith types
166
+ * directly so the package compiles without the optional peer installed.
167
+ */
168
+ interface MmRequest {
169
+ enhance(args: {
170
+ headers?: Record<string, string>;
171
+ }): MmRequest;
172
+ }
173
+ /**
174
+ * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.
175
+ *
176
+ * Exported for testing only — not part of the public API surface. The
177
+ * middleware resolves the token on EVERY request, so callable token
178
+ * providers can rotate without re-constructing the serializer. Cache
179
+ * inside your provider if rotation cost matters.
180
+ */
181
+ declare function bearerAuthMiddleware(token: string | (() => string | Promise<string>)): () => {
182
+ prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest>;
183
+ };
54
184
 
55
- export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType };
185
+ export { type SchemaRegistryAuth, type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy, bearerAuthMiddleware };
package/dist/index.js CHANGED
@@ -3,8 +3,14 @@ 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
+ auth;
12
+ autoRegister;
13
+ // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.
8
14
  idCache = /* @__PURE__ */ new Map();
9
15
  registry;
10
16
  constructor(opts) {
@@ -15,35 +21,107 @@ var SchemaRegistrySerializer = class {
15
21
  }
16
22
  this.registry = opts.registry ?? null;
17
23
  this.host = opts.host ?? null;
24
+ this.auth = opts.auth ?? null;
18
25
  this.schemas = opts.schemas ?? {};
19
- this.subject = opts.subject ?? ((topic) => `${topic}-value`);
26
+ this.keySchemas = opts.keySchemas ?? {};
27
+ this.subjectStrategy = opts.subjectStrategy ?? "TopicNameStrategy";
28
+ this.recordName = opts.recordName ?? null;
29
+ this.subjectFn = opts.subject ?? null;
20
30
  this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;
31
+ this.autoRegister = opts.autoRegister ?? true;
21
32
  }
22
33
  async serialize(record) {
23
34
  const registry = await this.getRegistry();
24
- const id = await this.schemaId(registry, record.topic);
35
+ const subject = this.resolveSubject(record, false);
36
+ const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);
25
37
  return registry.encode(id, record.payload);
26
38
  }
27
- schemaId(registry, topic) {
28
- const cached = this.idCache.get(topic);
39
+ /**
40
+ * Avro-encode the record's KEY using the registered key schema. Returns
41
+ * `null` when the record has no key (kafkajs/confluent treat null keys
42
+ * as the producer-side "no key" signal).
43
+ *
44
+ * Not part of the core `Serializer` interface — callers wire it into
45
+ * their publish path manually when they want Avro keys instead of raw
46
+ * UTF-8 strings.
47
+ */
48
+ async serializeKey(record) {
49
+ if (record.key === null || record.key === void 0) return null;
50
+ const registry = await this.getRegistry();
51
+ const subject = this.resolveSubject(record, true);
52
+ const id = await this.schemaId(
53
+ registry,
54
+ subject,
55
+ this.keySchemas[record.topic],
56
+ true,
57
+ record.topic
58
+ );
59
+ return registry.encode(id, record.key);
60
+ }
61
+ /**
62
+ * Resolve the subject for this (record, isKey) tuple. Order of
63
+ * precedence: explicit `subject` function → `subjectStrategy` preset.
64
+ */
65
+ resolveSubject(record, isKey) {
66
+ if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);
67
+ switch (this.subjectStrategy) {
68
+ case "TopicNameStrategy":
69
+ return `${record.topic}-${isKey ? "key" : "value"}`;
70
+ case "RecordNameStrategy":
71
+ return this.recordNameFor(record, isKey);
72
+ case "TopicRecordNameStrategy":
73
+ return `${record.topic}-${this.recordNameFor(record, isKey)}`;
74
+ }
75
+ }
76
+ recordNameFor(record, isKey) {
77
+ if (!this.recordName) {
78
+ throw new Error(
79
+ `SchemaRegistrySerializer: subjectStrategy "${this.subjectStrategy}" requires a \`recordName\` resolver.`
80
+ );
81
+ }
82
+ return this.recordName(record, isKey);
83
+ }
84
+ schemaId(registry, subject, spec, isKey, topic) {
85
+ const cacheKey = `${topic}:${isKey ? "key" : "value"}`;
86
+ const cached = this.idCache.get(cacheKey);
29
87
  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);
88
+ const lookup = spec && this.autoRegister ? registry.register({ type: spec.type, schema: spec.schema }, { subject }).then((r) => r.id) : registry.getLatestSchemaId(subject);
33
89
  const guarded = lookup.catch((err) => {
34
- this.idCache.delete(topic);
90
+ this.idCache.delete(cacheKey);
35
91
  throw err;
36
92
  });
37
- this.idCache.set(topic, guarded);
93
+ this.idCache.set(cacheKey, guarded);
38
94
  return guarded;
39
95
  }
40
96
  async getRegistry() {
41
97
  if (this.registry) return this.registry;
42
98
  const mod = await importSchemaRegistry();
43
- this.registry = new mod.SchemaRegistry({ host: this.host });
99
+ const cfg = {
100
+ host: this.host
101
+ };
102
+ if (this.auth) {
103
+ if (this.auth.type === "basic") {
104
+ cfg.auth = {
105
+ username: this.auth.username,
106
+ password: this.auth.password
107
+ };
108
+ } else {
109
+ cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];
110
+ }
111
+ }
112
+ this.registry = new mod.SchemaRegistry(cfg);
44
113
  return this.registry;
45
114
  }
46
115
  };
116
+ function bearerAuthMiddleware(token) {
117
+ return () => ({
118
+ async prepareRequest(next) {
119
+ const request = await next();
120
+ const value = typeof token === "function" ? await token() : token;
121
+ return request.enhance({ headers: { Authorization: `Bearer ${value}` } });
122
+ }
123
+ });
124
+ }
47
125
  async function importSchemaRegistry() {
48
126
  try {
49
127
  return await import("@kafkajs/confluent-schema-registry");
@@ -54,6 +132,7 @@ async function importSchemaRegistry() {
54
132
  }
55
133
  }
56
134
  export {
57
- SchemaRegistrySerializer
135
+ SchemaRegistrySerializer,
136
+ bearerAuthMiddleware
58
137
  };
59
138
  //# sourceMappingURL=index.js.map
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 * Authentication for the Schema Registry HTTP API.\n *\n * - `\"basic\"` — HTTP Basic Auth. The shape Confluent Cloud and most\n * commercial registries use; passed straight through to the underlying\n * client's `auth` config.\n * - `\"bearer\"` — `Authorization: Bearer <token>` header. The `token`\n * field accepts either a static string OR a callable that resolves a\n * fresh token on every request (cache inside your callable to amortise\n * cost; we don't memoize for you, so OAuth refresh-loop logic lives\n * on your side).\n *\n * mTLS for the registry connection itself is handled by Node's `tls`\n * stack — supply a custom `https.Agent` via the upstream client's\n * `agent` option (use the `registry` injection here and configure it\n * yourself), separate from the broker TLS the publisher uses.\n */\nexport type SchemaRegistryAuth =\n | { type: \"basic\"; username: string; password: string }\n | {\n type: \"bearer\";\n token: string | (() => string | Promise<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 /**\n * Optional authentication for the Schema Registry HTTP API. See\n * {@link SchemaRegistryAuth} for the two supported shapes. Ignored\n * when `registry` is provided (configure auth on the injected client\n * yourself in that case).\n */\n auth?: SchemaRegistryAuth;\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 auth: SchemaRegistryAuth | 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.auth = opts.auth ?? 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 const cfg: SchemaRegistryConstructorConfig = {\n host: this.host as string,\n };\n if (this.auth) {\n if (this.auth.type === \"basic\") {\n // Confluent SR client accepts `auth: { username, password }`\n // and the mappersmith basic-auth middleware builds the header.\n cfg.auth = {\n username: this.auth.username,\n password: this.auth.password,\n };\n } else {\n // Bearer: SR doesn't ship a built-in middleware. Inject our own\n // so every API call carries `Authorization: Bearer <token>`.\n cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];\n }\n }\n this.registry = new mod.SchemaRegistry(cfg);\n return this.registry;\n }\n}\n\n/**\n * Mappersmith middleware shape — the structural subset the Confluent\n * SR client passes through. We don't depend on mappersmith types\n * directly so the package compiles without the optional peer installed.\n */\ninterface MmRequest {\n enhance(args: { headers?: Record<string, string> }): MmRequest;\n}\n\n/**\n * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.\n *\n * Exported for testing only — not part of the public API surface. The\n * middleware resolves the token on EVERY request, so callable token\n * providers can rotate without re-constructing the serializer. Cache\n * inside your provider if rotation cost matters.\n */\nexport function bearerAuthMiddleware(\n token: string | (() => string | Promise<string>),\n) {\n return () => ({\n async prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest> {\n const request = await next();\n const value = typeof token === \"function\" ? await token() : token;\n return request.enhance({ headers: { Authorization: `Bearer ${value}` } });\n },\n });\n}\n\n/**\n * Structural shape the SR client's constructor accepts. Mirrors\n * `SchemaRegistryAPIClientArgs` from the upstream package without\n * importing the type directly (optional peer).\n */\ninterface SchemaRegistryConstructorConfig {\n host: string;\n auth?: { username: string; password: string };\n middlewares?: Array<() => unknown>;\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (\n cfg: SchemaRegistryConstructorConfig,\n ) => 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.4.0",
4
4
  "description": "Confluent Schema Registry serializer for @eventferry (Avro/Protobuf/JSON Schema)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -17,6 +17,9 @@
17
17
  "dist",
18
18
  "CHANGELOG.md"
19
19
  ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
20
23
  "keywords": [
21
24
  "outbox",
22
25
  "transactional-outbox",
@@ -45,7 +48,7 @@
45
48
  "node": ">=18"
46
49
  },
47
50
  "dependencies": {
48
- "@eventferry/core": "3.3.1"
51
+ "@eventferry/core": "3.4.0"
49
52
  },
50
53
  "peerDependencies": {
51
54
  "@kafkajs/confluent-schema-registry": "^3.0.0"
@@ -59,8 +62,8 @@
59
62
  "@kafkajs/confluent-schema-registry": "^3.0.0",
60
63
  "tsup": "^8.3.5",
61
64
  "typescript": "^5.7.2",
62
- "vitest": "^2.1.8",
63
- "@eventferry/core": "3.3.1"
65
+ "vitest": "^3.2.6",
66
+ "@eventferry/core": "3.4.0"
64
67
  },
65
68
  "scripts": {
66
69
  "build": "tsup",