@godxjp/server-sdk 0.2.0 → 0.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/README.md CHANGED
@@ -477,6 +477,11 @@ still actionable.
477
477
 
478
478
  ## `./producer` — the outbox producer kit
479
479
 
480
+ > **0.3.0 (breaking, nhỏ — server-sdk#4):** envelope của `drainOutbox` đổi
481
+ > `subject` từ NATS subject sang **`{aggregate}/{data.id}`** (join key, parity
482
+ > php-sdk); routing NATS vẫn là tham số `subject` NGOÀI envelope.
483
+ > `recordDomainEvent` nay BẮT BUỘC `data.id` (string khác rỗng).
484
+
480
485
  `./sync` is the CONSUMER half of the event discipline; this is the PRODUCER half (godx-jp/id#1963
481
486
  phase 4). A service that publishes its own domain events writes them to a D1 **outbox** row next
482
487
  to the state change they describe, and a relay drains the table in sequence order — so an event
@@ -590,3 +595,30 @@ Short version — the boilerplate's root `README.md` has the full symptom → ca
590
595
  - [ ] Move `hono` to `peerDependencies` review (already `>=4`) and keep `jose` as the only
591
596
  runtime dependency.
592
597
  - [ ] CI: `vitest run` (40) + `tsc --noEmit` + a pack-and-import smoke test on Workers and Node.
598
+
599
+ ## `./logger` — godx-logger for Workers
600
+
601
+ Ship a Worker's logs to [godx-logger](https://logger.godx.jp). Turning it on:
602
+
603
+ ```sh
604
+ npx wrangler secret put GODX_LOGGER_TOKEN # this app's ingest key (Vault; ask the platform operator)
605
+ ```
606
+
607
+ ```ts
608
+ import { godxLogger, type GodxLoggerVariables } from "@godxjp/server-sdk/logger";
609
+
610
+ type AppEnv = { Bindings: Env; Variables: GodxVariables & GodxLoggerVariables };
611
+ app.use(godxLogger()); // reads GODX_LOGGER_TOKEN (+ optional GODX_LOGGER_LEVEL/_APP/_URL, ENVIRONMENT)
612
+
613
+ app.get("/x", (c) => {
614
+ c.var.log.info("doing x", { order: 42 }); // debug | info | warn | error(msg, Error | fields)
615
+ return c.text("ok");
616
+ });
617
+ ```
618
+
619
+ Per request: `c.var.log` carries `method`, `path`, `request_id` (cf-ray); a request line is logged at the end
620
+ (`info`, `warn` for 4xx, `error` for 5xx) and a thrown error with its stack. The batch is shipped with
621
+ `executionCtx.waitUntil` — the response never waits, and a down logger never fails the request. Without a
622
+ token nothing is sent. The ingest key pins the tenant and the `app` label server-side, so every Worker of the
623
+ app lands in one log group `app:<name>`. Outside Hono: `GodxLogger.create({ token, app }).info(…)` then
624
+ `await log.flush()`.
@@ -0,0 +1,60 @@
1
+ import { Env, MiddlewareHandler } from 'hono';
2
+
3
+ /**
4
+ * godx-logger (https://logger.godx.jp) for Workers.
5
+ *
6
+ * Turning it on is one secret: `wrangler secret put GODX_LOGGER_TOKEN` (this app's ingest key,
7
+ * from the platform operator / Vault) and one line: `app.use(godxLogger())`. Every request then
8
+ * gets `c.var.log`, a request line (status, duration) is logged at the end, a thrown error is
9
+ * logged with its stack, and the batch is shipped with `executionCtx.waitUntil` — the response
10
+ * never waits for the logger, and a down logger never breaks the request.
11
+ *
12
+ * The ingest key pins the tenant and the `app` label server-side; every Worker of the app lands in
13
+ * one log group `app:<name>`.
14
+ */
15
+ type LogLevel = "debug" | "info" | "warn" | "error";
16
+ type LogFields = Record<string, unknown>;
17
+ interface GodxLoggerConfig {
18
+ /** Ingest key. Empty or missing = the logger does nothing (local dev, tests). */
19
+ token?: string;
20
+ /** Fallback app label; the key pins the real one server-side. */
21
+ app?: string;
22
+ env?: string;
23
+ /** Stream name inside the app's log group. Default "worker". */
24
+ host?: string;
25
+ /** Minimum level shipped. Default "info". */
26
+ level?: LogLevel;
27
+ endpoint?: string;
28
+ /** Records per request to the logger. Default 200. */
29
+ batchSize?: number;
30
+ /** Test seam; defaults to the global fetch. */
31
+ fetch?: typeof fetch;
32
+ }
33
+ declare class GodxLogger {
34
+ private readonly sink;
35
+ private readonly cfg;
36
+ private readonly bound;
37
+ private constructor();
38
+ static create(cfg: GodxLoggerConfig): GodxLogger;
39
+ /** A logger that adds `fields` to every record; shares the parent's buffer. */
40
+ child(fields: LogFields): GodxLogger;
41
+ debug(message: string, fields?: LogFields): void;
42
+ info(message: string, fields?: LogFields): void;
43
+ warn(message: string, fields?: LogFields): void;
44
+ error(message: string, fields?: LogFields | unknown): void;
45
+ log(level: LogLevel, message: string, fields?: LogFields): void;
46
+ /** Ship everything buffered. Never throws. */
47
+ flush(): Promise<void>;
48
+ }
49
+ type GodxLoggerVariables = {
50
+ log: GodxLogger;
51
+ };
52
+ /**
53
+ * Hono middleware: `c.var.log` for the request, a request line at the end, errors with stacks,
54
+ * shipped via waitUntil. Config comes from the Worker's bindings unless given.
55
+ */
56
+ declare function godxLogger<E extends Env & {
57
+ Variables: GodxLoggerVariables;
58
+ }>(config?: (env: E["Bindings"]) => GodxLoggerConfig): MiddlewareHandler<E>;
59
+
60
+ export { GodxLogger, type GodxLoggerConfig, type GodxLoggerVariables, type LogFields, type LogLevel, godxLogger };
package/dist/logger.js ADDED
@@ -0,0 +1,143 @@
1
+ // src/logger.ts
2
+ var RANK = { debug: 10, info: 20, warn: 30, error: 40 };
3
+ var DEFAULT_ENDPOINT = "https://logger.godx.jp/api/v1/ingest";
4
+ var MAX_MESSAGE = 32768;
5
+ var MAX_STACK = 8192;
6
+ var cut = (s, max) => s.length > max ? `${s.slice(0, max)}\u2026` : s;
7
+ function errorFields(error) {
8
+ if (!(error instanceof Error)) return { error: String(error) };
9
+ return {
10
+ "exception.class": error.name,
11
+ "exception.message": cut(error.message, MAX_MESSAGE),
12
+ "exception.stack": cut(error.stack ?? "", MAX_STACK)
13
+ };
14
+ }
15
+ var Sink = class {
16
+ constructor(cfg) {
17
+ this.cfg = cfg;
18
+ }
19
+ cfg;
20
+ lines = [];
21
+ get enabled() {
22
+ return !!this.cfg.token;
23
+ }
24
+ push(line) {
25
+ this.lines.push(line);
26
+ }
27
+ get size() {
28
+ return this.lines.length;
29
+ }
30
+ async flush() {
31
+ if (!this.enabled || this.lines.length === 0) return;
32
+ const batch = this.lines.splice(0, this.cfg.batchSize ?? 200);
33
+ const url = new URL(`${(this.cfg.endpoint ?? DEFAULT_ENDPOINT).replace(/\/$/, "")}/insert/jsonline`);
34
+ url.search = new URLSearchParams({ _stream_fields: "app,host", _time_field: "_time", _msg_field: "_msg" }).toString();
35
+ try {
36
+ await (this.cfg.fetch ?? fetch)(url, {
37
+ method: "POST",
38
+ headers: { Authorization: `Bearer ${this.cfg.token}`, "Content-Type": "application/stream+json" },
39
+ body: batch.join("\n") + "\n"
40
+ });
41
+ } catch {
42
+ }
43
+ if (this.lines.length > 0) await this.flush();
44
+ }
45
+ };
46
+ var GodxLogger = class _GodxLogger {
47
+ constructor(sink, cfg, bound) {
48
+ this.sink = sink;
49
+ this.cfg = cfg;
50
+ this.bound = bound;
51
+ }
52
+ sink;
53
+ cfg;
54
+ bound;
55
+ static create(cfg) {
56
+ return new _GodxLogger(new Sink(cfg), cfg, {});
57
+ }
58
+ /** A logger that adds `fields` to every record; shares the parent's buffer. */
59
+ child(fields) {
60
+ return new _GodxLogger(this.sink, this.cfg, { ...this.bound, ...fields });
61
+ }
62
+ debug(message, fields) {
63
+ this.log("debug", message, fields);
64
+ }
65
+ info(message, fields) {
66
+ this.log("info", message, fields);
67
+ }
68
+ warn(message, fields) {
69
+ this.log("warn", message, fields);
70
+ }
71
+ error(message, fields) {
72
+ const extra = fields instanceof Error || fields !== void 0 && typeof fields !== "object" ? errorFields(fields) : fields;
73
+ this.log("error", message, extra);
74
+ }
75
+ log(level, message, fields = {}) {
76
+ if (!this.sink.enabled || RANK[level] < RANK[this.cfg.level ?? "info"]) return;
77
+ const { error, ...rest } = fields;
78
+ this.sink.push(
79
+ JSON.stringify({
80
+ _time: (/* @__PURE__ */ new Date()).toISOString(),
81
+ _msg: cut(message, MAX_MESSAGE),
82
+ level,
83
+ app: this.cfg.app ?? "",
84
+ env: this.cfg.env ?? "production",
85
+ host: this.cfg.host ?? "worker",
86
+ ...this.bound,
87
+ ...rest,
88
+ ...error !== void 0 ? errorFields(error) : {}
89
+ })
90
+ );
91
+ }
92
+ /** Ship everything buffered. Never throws. */
93
+ flush() {
94
+ return this.sink.flush();
95
+ }
96
+ };
97
+ var isLevel = (v) => typeof v === "string" && v in RANK;
98
+ function godxLogger(config) {
99
+ return async (c, next) => {
100
+ const b = c.env ?? {};
101
+ const cfg = config ? config(c.env) : {
102
+ token: b.GODX_LOGGER_TOKEN,
103
+ app: b.GODX_LOGGER_APP,
104
+ env: b.ENVIRONMENT,
105
+ level: isLevel(b.GODX_LOGGER_LEVEL) ? b.GODX_LOGGER_LEVEL : void 0,
106
+ endpoint: b.GODX_LOGGER_URL
107
+ };
108
+ const url = new URL(c.req.url);
109
+ const log = GodxLogger.create(cfg).child({
110
+ method: c.req.method,
111
+ path: url.pathname,
112
+ request_id: c.req.header("cf-ray") ?? crypto.randomUUID()
113
+ });
114
+ c.set("log", log);
115
+ const started = Date.now();
116
+ try {
117
+ await next();
118
+ } catch (error) {
119
+ log.error("unhandled error", { error, status: 500, duration_ms: Date.now() - started });
120
+ throw error;
121
+ } finally {
122
+ const status = c.res?.status ?? 500;
123
+ if (!c.error) {
124
+ log.log(status >= 500 ? "error" : status >= 400 ? "warn" : "info", `${c.req.method} ${url.pathname} ${status}`, {
125
+ status,
126
+ duration_ms: Date.now() - started
127
+ });
128
+ } else {
129
+ log.error("request failed", { error: c.error, status, duration_ms: Date.now() - started });
130
+ }
131
+ const shipping = log.flush();
132
+ try {
133
+ c.executionCtx.waitUntil(shipping);
134
+ } catch {
135
+ }
136
+ }
137
+ };
138
+ }
139
+ export {
140
+ GodxLogger,
141
+ godxLogger
142
+ };
143
+ //# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/logger.ts"],"sourcesContent":["import type { Context, Env, MiddlewareHandler } from \"hono\";\n\n/**\n * godx-logger (https://logger.godx.jp) for Workers.\n *\n * Turning it on is one secret: `wrangler secret put GODX_LOGGER_TOKEN` (this app's ingest key,\n * from the platform operator / Vault) and one line: `app.use(godxLogger())`. Every request then\n * gets `c.var.log`, a request line (status, duration) is logged at the end, a thrown error is\n * logged with its stack, and the batch is shipped with `executionCtx.waitUntil` — the response\n * never waits for the logger, and a down logger never breaks the request.\n *\n * The ingest key pins the tenant and the `app` label server-side; every Worker of the app lands in\n * one log group `app:<name>`.\n */\n\nexport type LogLevel = \"debug\" | \"info\" | \"warn\" | \"error\";\nconst RANK: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };\n\nexport type LogFields = Record<string, unknown>;\n\nexport interface GodxLoggerConfig {\n /** Ingest key. Empty or missing = the logger does nothing (local dev, tests). */\n token?: string;\n /** Fallback app label; the key pins the real one server-side. */\n app?: string;\n env?: string;\n /** Stream name inside the app's log group. Default \"worker\". */\n host?: string;\n /** Minimum level shipped. Default \"info\". */\n level?: LogLevel;\n endpoint?: string;\n /** Records per request to the logger. Default 200. */\n batchSize?: number;\n /** Test seam; defaults to the global fetch. */\n fetch?: typeof fetch;\n}\n\nconst DEFAULT_ENDPOINT = \"https://logger.godx.jp/api/v1/ingest\";\nconst MAX_MESSAGE = 32_768;\nconst MAX_STACK = 8_192;\n\nconst cut = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}…` : s);\n\nfunction errorFields(error: unknown): LogFields {\n if (!(error instanceof Error)) return { error: String(error) };\n return {\n \"exception.class\": error.name,\n \"exception.message\": cut(error.message, MAX_MESSAGE),\n \"exception.stack\": cut(error.stack ?? \"\", MAX_STACK),\n };\n}\n\n/** Shared buffer behind a logger and its children. */\nclass Sink {\n private lines: string[] = [];\n\n constructor(private readonly cfg: GodxLoggerConfig) {}\n\n get enabled(): boolean {\n return !!this.cfg.token;\n }\n\n push(line: string): void {\n this.lines.push(line);\n }\n\n get size(): number {\n return this.lines.length;\n }\n\n async flush(): Promise<void> {\n if (!this.enabled || this.lines.length === 0) return;\n const batch = this.lines.splice(0, this.cfg.batchSize ?? 200);\n const url = new URL(`${(this.cfg.endpoint ?? DEFAULT_ENDPOINT).replace(/\\/$/, \"\")}/insert/jsonline`);\n url.search = new URLSearchParams({ _stream_fields: \"app,host\", _time_field: \"_time\", _msg_field: \"_msg\" }).toString();\n try {\n await (this.cfg.fetch ?? fetch)(url, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${this.cfg.token}`, \"Content-Type\": \"application/stream+json\" },\n body: batch.join(\"\\n\") + \"\\n\",\n });\n } catch {\n // A log line is not worth the request.\n }\n if (this.lines.length > 0) await this.flush();\n }\n}\n\nexport class GodxLogger {\n private constructor(\n private readonly sink: Sink,\n private readonly cfg: GodxLoggerConfig,\n private readonly bound: LogFields,\n ) {}\n\n static create(cfg: GodxLoggerConfig): GodxLogger {\n return new GodxLogger(new Sink(cfg), cfg, {});\n }\n\n /** A logger that adds `fields` to every record; shares the parent's buffer. */\n child(fields: LogFields): GodxLogger {\n return new GodxLogger(this.sink, this.cfg, { ...this.bound, ...fields });\n }\n\n debug(message: string, fields?: LogFields): void {\n this.log(\"debug\", message, fields);\n }\n info(message: string, fields?: LogFields): void {\n this.log(\"info\", message, fields);\n }\n warn(message: string, fields?: LogFields): void {\n this.log(\"warn\", message, fields);\n }\n error(message: string, fields?: LogFields | unknown): void {\n const extra = fields instanceof Error || (fields !== undefined && typeof fields !== \"object\")\n ? errorFields(fields)\n : (fields as LogFields | undefined);\n this.log(\"error\", message, extra);\n }\n\n log(level: LogLevel, message: string, fields: LogFields = {}): void {\n if (!this.sink.enabled || RANK[level] < RANK[this.cfg.level ?? \"info\"]) return;\n const { error, ...rest } = fields;\n this.sink.push(\n JSON.stringify({\n _time: new Date().toISOString(),\n _msg: cut(message, MAX_MESSAGE),\n level,\n app: this.cfg.app ?? \"\",\n env: this.cfg.env ?? \"production\",\n host: this.cfg.host ?? \"worker\",\n ...this.bound,\n ...rest,\n ...(error !== undefined ? errorFields(error) : {}),\n }),\n );\n }\n\n /** Ship everything buffered. Never throws. */\n flush(): Promise<void> {\n return this.sink.flush();\n }\n}\n\nexport type GodxLoggerVariables = { log: GodxLogger };\n\ntype LoggerBindings = {\n GODX_LOGGER_TOKEN?: string;\n GODX_LOGGER_APP?: string;\n GODX_LOGGER_LEVEL?: string;\n GODX_LOGGER_URL?: string;\n ENVIRONMENT?: string;\n};\n\nconst isLevel = (v: unknown): v is LogLevel => typeof v === \"string\" && v in RANK;\n\n/**\n * Hono middleware: `c.var.log` for the request, a request line at the end, errors with stacks,\n * shipped via waitUntil. Config comes from the Worker's bindings unless given.\n */\nexport function godxLogger<E extends Env & { Variables: GodxLoggerVariables }>(\n config?: (env: E[\"Bindings\"]) => GodxLoggerConfig,\n): MiddlewareHandler<E> {\n return async (c: Context<E>, next) => {\n const b = (c.env ?? {}) as LoggerBindings;\n const cfg: GodxLoggerConfig = config\n ? config(c.env)\n : {\n token: b.GODX_LOGGER_TOKEN,\n app: b.GODX_LOGGER_APP,\n env: b.ENVIRONMENT,\n level: isLevel(b.GODX_LOGGER_LEVEL) ? b.GODX_LOGGER_LEVEL : undefined,\n endpoint: b.GODX_LOGGER_URL,\n };\n const url = new URL(c.req.url);\n const log = GodxLogger.create(cfg).child({\n method: c.req.method,\n path: url.pathname,\n request_id: c.req.header(\"cf-ray\") ?? crypto.randomUUID(),\n });\n c.set(\"log\", log);\n\n const started = Date.now();\n try {\n await next();\n } catch (error) {\n log.error(\"unhandled error\", { error, status: 500, duration_ms: Date.now() - started });\n throw error;\n } finally {\n const status = c.res?.status ?? 500;\n if (!c.error) {\n log.log(status >= 500 ? \"error\" : status >= 400 ? \"warn\" : \"info\", `${c.req.method} ${url.pathname} ${status}`, {\n status,\n duration_ms: Date.now() - started,\n });\n } else {\n log.error(\"request failed\", { error: c.error, status, duration_ms: Date.now() - started });\n }\n const shipping = log.flush();\n try {\n c.executionCtx.waitUntil(shipping);\n } catch {\n // No execution context (tests, Node): the promise still runs.\n }\n }\n };\n}\n"],"mappings":"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","names":[]}
@@ -50,7 +50,11 @@ interface DomainEnvelope {
50
50
  source: string;
51
51
  /** jp.godx.{service}.{aggregate}.{action} */
52
52
  type: string;
53
- /** godx.svc.{service}.{organizationId}.{aggregate}.{action} */
53
+ /**
54
+ * `{aggregate}/{data.id}` — join key cho consumer, KHÔNG phải subject NATS
55
+ * (routing đi bằng tham số `subject` của publish). Khớp php-sdk (godx-mailer#18,
56
+ * server-sdk#4); trước 0.3.0 field này nhầm là NATS subject.
57
+ */
54
58
  subject: string;
55
59
  time: string;
56
60
  datacontenttype: "application/json";
@@ -63,7 +67,10 @@ interface DomainEventInput {
63
67
  organizationId: string;
64
68
  aggregate: string;
65
69
  action: string;
66
- data: Record<string, unknown>;
70
+ /** PHẢI có `id` — join key của consumer (CE subject = `{aggregate}/{data.id}`, như php-sdk). */
71
+ data: Record<string, unknown> & {
72
+ id: string;
73
+ };
67
74
  /** CloudEvents id — generated (crypto.randomUUID) when not supplied. */
68
75
  id?: string;
69
76
  }
package/dist/producer.js CHANGED
@@ -30,6 +30,11 @@ async function recordDomainEvent(db, input) {
30
30
  const organizationId = requireToken("organizationId", input.organizationId);
31
31
  const aggregate = requireToken("aggregate", input.aggregate);
32
32
  const action = requireToken("action", input.action);
33
+ if (typeof input.data?.id !== "string" || input.data.id === "") {
34
+ throw new ProducerContractError(
35
+ "data.id (string, non-empty) is required \u2014 it is the consumer's join key and the CE subject's second half ({aggregate}/{data.id}), matching php-sdk."
36
+ );
37
+ }
33
38
  const subject = `godx.svc.${service}.${organizationId}.${aggregate}.${action}`;
34
39
  const type = `jp.godx.${service}.${aggregate}.${action}`;
35
40
  const id = input.id ?? crypto.randomUUID();
@@ -53,12 +58,15 @@ async function drainOutbox(db, publish, { batch = 100 } = {}) {
53
58
  // subject tokens are exactly `godx.svc.{service}` — the producer.
54
59
  source: row.subject.split(".").slice(0, 3).join("."),
55
60
  type: row.type,
56
- subject: row.subject,
61
+ // Token thứ 5 của subject NATS là aggregate (mỗi token đã cấm dấu chấm).
62
+ subject: "",
63
+ // điền dưới — cần data đã parse
57
64
  time: row.created_at,
58
65
  datacontenttype: "application/json",
59
66
  sequence: row.sequence,
60
67
  data: JSON.parse(row.data)
61
68
  };
69
+ envelope.subject = `${row.subject.split(".")[4]}/${String(envelope.data.id ?? "")}`;
62
70
  try {
63
71
  await publish({ subject: row.subject, envelope });
64
72
  } catch {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/producer.ts"],"sourcesContent":["/**\n * ./producer — the outbox half of the event discipline (godx-jp/id#1963,\n * phase 4): a service that PUBLISHES its own domain events writes them to a\n * D1 outbox row alongside its state change, and a relay drains the table in\n * sequence order. The envelope mirrors ./sync's CloudEvents 1.0 shape so\n * consumers reuse one parser; there is NO tenantid attribute — the tenant\n * rides in the subject, exactly like the identity feed.\n *\n * The transport is INJECTED (the `publish` callback) because a Cloudflare\n * Worker cannot dial NATS over TCP. Two deployment shapes, stated honestly:\n *\n * - **Workers-resident relay**: a Cron Trigger calling `drainOutbox`.\n * Cloudflare floors cron at 1 minute, so publish lag is ≥1 minute.\n * Do not promise consumers anything faster from this shape.\n * - **Host-side loop**: any long-lived process with a NATS client (nats.js on\n * Node, or the platform's own relay) polling `drainOutbox` gets sub-second\n * lag. Same function, different clock.\n */\n\n/** Structural subset of D1Database, so this module has no CF type dependency. */\ninterface D1Like {\n prepare(sql: string): {\n bind(...values: unknown[]): {\n first<T = unknown>(): Promise<T | null>;\n run(): Promise<unknown>;\n all<T = unknown>(): Promise<{ results: T[] }>;\n };\n };\n}\n\nexport class ProducerContractError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ProducerContractError\";\n }\n}\n\n/**\n * DDL for the outbox table — copy into a migration verbatim (drizzle users:\n * keep the ORM schema in lockstep). The index serves `drainOutbox`'s\n * \"unpublished, in sequence order\" read.\n */\nexport const OUTBOX_TABLE_SQL = `CREATE TABLE IF NOT EXISTS outbox (\n id TEXT PRIMARY KEY,\n sequence INTEGER NOT NULL,\n subject TEXT NOT NULL,\n type TEXT NOT NULL,\n organization_id TEXT NOT NULL,\n data TEXT NOT NULL,\n created_at TEXT NOT NULL,\n published_at TEXT NULL\n);\nCREATE INDEX IF NOT EXISTS outbox_unpublished_idx ON outbox (published_at, sequence);`;\n\n/**\n * CloudEvents 1.0 envelope as `drainOutbox` hands it to `publish` — the same\n * shape ./sync's IdentityEnvelope reads, and deliberately WITHOUT a tenantid\n * attribute: the tenant is the subject's `{organizationId}` token.\n */\nexport interface DomainEnvelope {\n specversion: \"1.0\";\n /** UUIDv4 — THE idempotency key for consumers. */\n id: string;\n /** Producer identity: `godx.svc.{service}` (the subject's first three tokens). */\n source: string;\n /** jp.godx.{service}.{aggregate}.{action} */\n type: string;\n /** godx.svc.{service}.{organizationId}.{aggregate}.{action} */\n subject: string;\n time: string;\n datacontenttype: \"application/json\";\n /** Per-producer monotonic ordering fence (outbox.sequence). */\n sequence: number;\n data: Record<string, unknown>;\n}\n\nexport interface DomainEventInput {\n service: string;\n organizationId: string;\n aggregate: string;\n action: string;\n data: Record<string, unknown>;\n /** CloudEvents id — generated (crypto.randomUUID) when not supplied. */\n id?: string;\n}\n\nexport interface RecordedDomainEvent {\n id: string;\n sequence: number;\n subject: string;\n type: string;\n}\n\n/** Every subject/type token: letters, digits, `_`, `-` — never `.`, which separates tokens. */\nconst TOKEN = /^[A-Za-z0-9_-]+$/;\n\nfunction requireToken(name: string, value: string): string {\n if (!TOKEN.test(value)) {\n throw new ProducerContractError(\n `${name} ${JSON.stringify(value)} is not a valid subject token — expected /^[A-Za-z0-9_-]+$/ (\".\" separates tokens and may not appear inside one).`,\n );\n }\n return value;\n}\n\n/**\n * Record one domain event in the outbox, next to the state change it\n * describes. Composes:\n * - subject = `godx.svc.{service}.{organizationId}.{aggregate}.{action}`\n * - type = `jp.godx.{service}.{aggregate}.{action}`\n *\n * Sequence is per-producer monotonic via `MAX(sequence) + 1`. That\n * read-then-insert is racy on paper; on D1 it is safe in practice because\n * D1 serializes writes per database — two invocations cannot interleave\n * between the SELECT and the INSERT. On any other SQLite with genuinely\n * concurrent writers, run this inside the caller's transaction (which is\n * where it belongs anyway: the event must not outlive a rolled-back write).\n */\nexport async function recordDomainEvent(db: D1Like, input: DomainEventInput): Promise<RecordedDomainEvent> {\n const service = requireToken(\"service\", input.service);\n const organizationId = requireToken(\"organizationId\", input.organizationId);\n const aggregate = requireToken(\"aggregate\", input.aggregate);\n const action = requireToken(\"action\", input.action);\n\n const subject = `godx.svc.${service}.${organizationId}.${aggregate}.${action}`;\n const type = `jp.godx.${service}.${aggregate}.${action}`;\n const id = input.id ?? crypto.randomUUID();\n\n const next = await db\n .prepare(\"SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM outbox\")\n .bind()\n .first<{ next: number }>();\n const sequence = Number(next?.next ?? 1);\n\n await db\n .prepare(\n \"INSERT INTO outbox (id, sequence, subject, type, organization_id, data, created_at, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)\",\n )\n .bind(id, sequence, subject, type, organizationId, JSON.stringify(input.data), new Date().toISOString())\n .run();\n\n return { id, sequence, subject, type };\n}\n\n/**\n * Publish unpublished rows in sequence order through the injected transport.\n * `published_at` is marked only AFTER `publish` resolves — a crash between\n * the two re-publishes the row, so consumers dedupe on the CloudEvents `id`\n * (at-least-once, exactly like the identity feed). The drain STOPS at the\n * first failure so no later event overtakes an earlier one; the failed row\n * stays unpublished and the next drain retries it, so inspect `remaining`\n * rather than expecting a throw.\n */\nexport async function drainOutbox(\n db: D1Like,\n publish: (event: { subject: string; envelope: DomainEnvelope }) => Promise<void>,\n { batch = 100 }: { batch?: number } = {},\n): Promise<{ published: number; remaining: number }> {\n interface Row {\n id: string;\n sequence: number;\n subject: string;\n type: string;\n data: string;\n created_at: string;\n }\n const { results } = await db\n .prepare(\n \"SELECT id, sequence, subject, type, data, created_at FROM outbox WHERE published_at IS NULL ORDER BY sequence LIMIT ?\",\n )\n .bind(batch)\n .all<Row>();\n\n let published = 0;\n for (const row of results) {\n const envelope: DomainEnvelope = {\n specversion: \"1.0\",\n id: row.id,\n // Tokens cannot contain \".\" (validated on record), so the first three\n // subject tokens are exactly `godx.svc.{service}` — the producer.\n source: row.subject.split(\".\").slice(0, 3).join(\".\"),\n type: row.type,\n subject: row.subject,\n time: row.created_at,\n datacontenttype: \"application/json\",\n sequence: row.sequence,\n data: JSON.parse(row.data) as Record<string, unknown>,\n };\n try {\n await publish({ subject: row.subject, envelope });\n } catch {\n break; // order preserved: nothing after the failed row may go out first\n }\n await db.prepare(\"UPDATE outbox SET published_at = ? WHERE id = ?\").bind(new Date().toISOString(), row.id).run();\n published++;\n }\n\n const count = await db\n .prepare(\"SELECT COUNT(*) AS n FROM outbox WHERE published_at IS NULL\")\n .bind()\n .first<{ n: number }>();\n return { published, remaining: Number(count?.n ?? 0) };\n}\n"],"mappings":";AA8BO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC/C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAOO,IAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoDhC,IAAM,QAAQ;AAEd,SAAS,aAAa,MAAc,OAAuB;AACzD,MAAI,CAAC,MAAM,KAAK,KAAK,GAAG;AACtB,UAAM,IAAI;AAAA,MACR,GAAG,IAAI,IAAI,KAAK,UAAU,KAAK,CAAC;AAAA,IAClC;AAAA,EACF;AACA,SAAO;AACT;AAeA,eAAsB,kBAAkB,IAAY,OAAuD;AACzG,QAAM,UAAU,aAAa,WAAW,MAAM,OAAO;AACrD,QAAM,iBAAiB,aAAa,kBAAkB,MAAM,cAAc;AAC1E,QAAM,YAAY,aAAa,aAAa,MAAM,SAAS;AAC3D,QAAM,SAAS,aAAa,UAAU,MAAM,MAAM;AAElD,QAAM,UAAU,YAAY,OAAO,IAAI,cAAc,IAAI,SAAS,IAAI,MAAM;AAC5E,QAAM,OAAO,WAAW,OAAO,IAAI,SAAS,IAAI,MAAM;AACtD,QAAM,KAAK,MAAM,MAAM,OAAO,WAAW;AAEzC,QAAM,OAAO,MAAM,GAChB,QAAQ,2DAA2D,EACnE,KAAK,EACL,MAAwB;AAC3B,QAAM,WAAW,OAAO,MAAM,QAAQ,CAAC;AAEvC,QAAM,GACH;AAAA,IACC;AAAA,EACF,EACC,KAAK,IAAI,UAAU,SAAS,MAAM,gBAAgB,KAAK,UAAU,MAAM,IAAI,IAAG,oBAAI,KAAK,GAAE,YAAY,CAAC,EACtG,IAAI;AAEP,SAAO,EAAE,IAAI,UAAU,SAAS,KAAK;AACvC;AAWA,eAAsB,YACpB,IACA,SACA,EAAE,QAAQ,IAAI,IAAwB,CAAC,GACY;AASnD,QAAM,EAAE,QAAQ,IAAI,MAAM,GACvB;AAAA,IACC;AAAA,EACF,EACC,KAAK,KAAK,EACV,IAAS;AAEZ,MAAI,YAAY;AAChB,aAAW,OAAO,SAAS;AACzB,UAAM,WAA2B;AAAA,MAC/B,aAAa;AAAA,MACb,IAAI,IAAI;AAAA;AAAA;AAAA,MAGR,QAAQ,IAAI,QAAQ,MAAM,GAAG,EAAE,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG;AAAA,MACnD,MAAM,IAAI;AAAA,MACV,SAAS,IAAI;AAAA,MACb,MAAM,IAAI;AAAA,MACV,iBAAiB;AAAA,MACjB,UAAU,IAAI;AAAA,MACd,MAAM,KAAK,MAAM,IAAI,IAAI;AAAA,IAC3B;AACA,QAAI;AACF,YAAM,QAAQ,EAAE,SAAS,IAAI,SAAS,SAAS,CAAC;AAAA,IAClD,QAAQ;AACN;AAAA,IACF;AACA,UAAM,GAAG,QAAQ,iDAAiD,EAAE,MAAK,oBAAI,KAAK,GAAE,YAAY,GAAG,IAAI,EAAE,EAAE,IAAI;AAC/G;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM,GACjB,QAAQ,6DAA6D,EACrE,KAAK,EACL,MAAqB;AACxB,SAAO,EAAE,WAAW,WAAW,OAAO,OAAO,KAAK,CAAC,EAAE;AACvD;","names":[]}
1
+ {"version":3,"sources":["../src/producer.ts"],"sourcesContent":["/**\n * ./producer — the outbox half of the event discipline (godx-jp/id#1963,\n * phase 4): a service that PUBLISHES its own domain events writes them to a\n * D1 outbox row alongside its state change, and a relay drains the table in\n * sequence order. The envelope mirrors ./sync's CloudEvents 1.0 shape so\n * consumers reuse one parser; there is NO tenantid attribute — the tenant\n * rides in the subject, exactly like the identity feed.\n *\n * The transport is INJECTED (the `publish` callback) because a Cloudflare\n * Worker cannot dial NATS over TCP. Two deployment shapes, stated honestly:\n *\n * - **Workers-resident relay**: a Cron Trigger calling `drainOutbox`.\n * Cloudflare floors cron at 1 minute, so publish lag is ≥1 minute.\n * Do not promise consumers anything faster from this shape.\n * - **Host-side loop**: any long-lived process with a NATS client (nats.js on\n * Node, or the platform's own relay) polling `drainOutbox` gets sub-second\n * lag. Same function, different clock.\n */\n\n/** Structural subset of D1Database, so this module has no CF type dependency. */\ninterface D1Like {\n prepare(sql: string): {\n bind(...values: unknown[]): {\n first<T = unknown>(): Promise<T | null>;\n run(): Promise<unknown>;\n all<T = unknown>(): Promise<{ results: T[] }>;\n };\n };\n}\n\nexport class ProducerContractError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ProducerContractError\";\n }\n}\n\n/**\n * DDL for the outbox table — copy into a migration verbatim (drizzle users:\n * keep the ORM schema in lockstep). The index serves `drainOutbox`'s\n * \"unpublished, in sequence order\" read.\n */\nexport const OUTBOX_TABLE_SQL = `CREATE TABLE IF NOT EXISTS outbox (\n id TEXT PRIMARY KEY,\n sequence INTEGER NOT NULL,\n subject TEXT NOT NULL,\n type TEXT NOT NULL,\n organization_id TEXT NOT NULL,\n data TEXT NOT NULL,\n created_at TEXT NOT NULL,\n published_at TEXT NULL\n);\nCREATE INDEX IF NOT EXISTS outbox_unpublished_idx ON outbox (published_at, sequence);`;\n\n/**\n * CloudEvents 1.0 envelope as `drainOutbox` hands it to `publish` — the same\n * shape ./sync's IdentityEnvelope reads, and deliberately WITHOUT a tenantid\n * attribute: the tenant is the subject's `{organizationId}` token.\n */\nexport interface DomainEnvelope {\n specversion: \"1.0\";\n /** UUIDv4 — THE idempotency key for consumers. */\n id: string;\n /** Producer identity: `godx.svc.{service}` (the subject's first three tokens). */\n source: string;\n /** jp.godx.{service}.{aggregate}.{action} */\n type: string;\n /**\n * `{aggregate}/{data.id}` — join key cho consumer, KHÔNG phải subject NATS\n * (routing đi bằng tham số `subject` của publish). Khớp php-sdk (godx-mailer#18,\n * server-sdk#4); trước 0.3.0 field này nhầm là NATS subject.\n */\n subject: string;\n time: string;\n datacontenttype: \"application/json\";\n /** Per-producer monotonic ordering fence (outbox.sequence). */\n sequence: number;\n data: Record<string, unknown>;\n}\n\nexport interface DomainEventInput {\n service: string;\n organizationId: string;\n aggregate: string;\n action: string;\n /** PHẢI có `id` — join key của consumer (CE subject = `{aggregate}/{data.id}`, như php-sdk). */\n data: Record<string, unknown> & { id: string };\n /** CloudEvents id — generated (crypto.randomUUID) when not supplied. */\n id?: string;\n}\n\nexport interface RecordedDomainEvent {\n id: string;\n sequence: number;\n subject: string;\n type: string;\n}\n\n/** Every subject/type token: letters, digits, `_`, `-` — never `.`, which separates tokens. */\nconst TOKEN = /^[A-Za-z0-9_-]+$/;\n\nfunction requireToken(name: string, value: string): string {\n if (!TOKEN.test(value)) {\n throw new ProducerContractError(\n `${name} ${JSON.stringify(value)} is not a valid subject token — expected /^[A-Za-z0-9_-]+$/ (\".\" separates tokens and may not appear inside one).`,\n );\n }\n return value;\n}\n\n/**\n * Record one domain event in the outbox, next to the state change it\n * describes. Composes:\n * - subject = `godx.svc.{service}.{organizationId}.{aggregate}.{action}`\n * - type = `jp.godx.{service}.{aggregate}.{action}`\n *\n * Sequence is per-producer monotonic via `MAX(sequence) + 1`. That\n * read-then-insert is racy on paper; on D1 it is safe in practice because\n * D1 serializes writes per database — two invocations cannot interleave\n * between the SELECT and the INSERT. On any other SQLite with genuinely\n * concurrent writers, run this inside the caller's transaction (which is\n * where it belongs anyway: the event must not outlive a rolled-back write).\n */\nexport async function recordDomainEvent(db: D1Like, input: DomainEventInput): Promise<RecordedDomainEvent> {\n const service = requireToken(\"service\", input.service);\n const organizationId = requireToken(\"organizationId\", input.organizationId);\n const aggregate = requireToken(\"aggregate\", input.aggregate);\n const action = requireToken(\"action\", input.action);\n if (typeof input.data?.id !== \"string\" || input.data.id === \"\") {\n throw new ProducerContractError(\n \"data.id (string, non-empty) is required — it is the consumer's join key and the CE subject's second half ({aggregate}/{data.id}), matching php-sdk.\",\n );\n }\n\n const subject = `godx.svc.${service}.${organizationId}.${aggregate}.${action}`;\n const type = `jp.godx.${service}.${aggregate}.${action}`;\n const id = input.id ?? crypto.randomUUID();\n\n const next = await db\n .prepare(\"SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM outbox\")\n .bind()\n .first<{ next: number }>();\n const sequence = Number(next?.next ?? 1);\n\n await db\n .prepare(\n \"INSERT INTO outbox (id, sequence, subject, type, organization_id, data, created_at, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)\",\n )\n .bind(id, sequence, subject, type, organizationId, JSON.stringify(input.data), new Date().toISOString())\n .run();\n\n return { id, sequence, subject, type };\n}\n\n/**\n * Publish unpublished rows in sequence order through the injected transport.\n * `published_at` is marked only AFTER `publish` resolves — a crash between\n * the two re-publishes the row, so consumers dedupe on the CloudEvents `id`\n * (at-least-once, exactly like the identity feed). The drain STOPS at the\n * first failure so no later event overtakes an earlier one; the failed row\n * stays unpublished and the next drain retries it, so inspect `remaining`\n * rather than expecting a throw.\n */\nexport async function drainOutbox(\n db: D1Like,\n publish: (event: { subject: string; envelope: DomainEnvelope }) => Promise<void>,\n { batch = 100 }: { batch?: number } = {},\n): Promise<{ published: number; remaining: number }> {\n interface Row {\n id: string;\n sequence: number;\n subject: string;\n type: string;\n data: string;\n created_at: string;\n }\n const { results } = await db\n .prepare(\n \"SELECT id, sequence, subject, type, data, created_at FROM outbox WHERE published_at IS NULL ORDER BY sequence LIMIT ?\",\n )\n .bind(batch)\n .all<Row>();\n\n let published = 0;\n for (const row of results) {\n const envelope: DomainEnvelope = {\n specversion: \"1.0\",\n id: row.id,\n // Tokens cannot contain \".\" (validated on record), so the first three\n // subject tokens are exactly `godx.svc.{service}` — the producer.\n source: row.subject.split(\".\").slice(0, 3).join(\".\"),\n type: row.type,\n // Token thứ 5 của subject NATS là aggregate (mỗi token đã cấm dấu chấm).\n subject: \"\", // điền dưới — cần data đã parse\n time: row.created_at,\n datacontenttype: \"application/json\",\n sequence: row.sequence,\n data: JSON.parse(row.data) as Record<string, unknown>,\n };\n envelope.subject = `${row.subject.split(\".\")[4]}/${String(envelope.data.id ?? \"\")}`;\n try {\n await publish({ subject: row.subject, envelope });\n } catch {\n break; // order preserved: nothing after the failed row may go out first\n }\n await db.prepare(\"UPDATE outbox SET published_at = ? WHERE id = ?\").bind(new Date().toISOString(), row.id).run();\n published++;\n }\n\n const count = await db\n .prepare(\"SELECT COUNT(*) AS n FROM outbox WHERE published_at IS NULL\")\n .bind()\n .first<{ n: number }>();\n return { published, remaining: Number(count?.n ?? 0) };\n}\n"],"mappings":";AA8BO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EAC/C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAOO,IAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAyDhC,IAAM,QAAQ;AAEd,SAAS,aAAa,MAAc,OAAuB;AACzD,MAAI,CAAC,MAAM,KAAK,KAAK,GAAG;AACtB,UAAM,IAAI;AAAA,MACR,GAAG,IAAI,IAAI,KAAK,UAAU,KAAK,CAAC;AAAA,IAClC;AAAA,EACF;AACA,SAAO;AACT;AAeA,eAAsB,kBAAkB,IAAY,OAAuD;AACzG,QAAM,UAAU,aAAa,WAAW,MAAM,OAAO;AACrD,QAAM,iBAAiB,aAAa,kBAAkB,MAAM,cAAc;AAC1E,QAAM,YAAY,aAAa,aAAa,MAAM,SAAS;AAC3D,QAAM,SAAS,aAAa,UAAU,MAAM,MAAM;AAClD,MAAI,OAAO,MAAM,MAAM,OAAO,YAAY,MAAM,KAAK,OAAO,IAAI;AAC9D,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,QAAM,UAAU,YAAY,OAAO,IAAI,cAAc,IAAI,SAAS,IAAI,MAAM;AAC5E,QAAM,OAAO,WAAW,OAAO,IAAI,SAAS,IAAI,MAAM;AACtD,QAAM,KAAK,MAAM,MAAM,OAAO,WAAW;AAEzC,QAAM,OAAO,MAAM,GAChB,QAAQ,2DAA2D,EACnE,KAAK,EACL,MAAwB;AAC3B,QAAM,WAAW,OAAO,MAAM,QAAQ,CAAC;AAEvC,QAAM,GACH;AAAA,IACC;AAAA,EACF,EACC,KAAK,IAAI,UAAU,SAAS,MAAM,gBAAgB,KAAK,UAAU,MAAM,IAAI,IAAG,oBAAI,KAAK,GAAE,YAAY,CAAC,EACtG,IAAI;AAEP,SAAO,EAAE,IAAI,UAAU,SAAS,KAAK;AACvC;AAWA,eAAsB,YACpB,IACA,SACA,EAAE,QAAQ,IAAI,IAAwB,CAAC,GACY;AASnD,QAAM,EAAE,QAAQ,IAAI,MAAM,GACvB;AAAA,IACC;AAAA,EACF,EACC,KAAK,KAAK,EACV,IAAS;AAEZ,MAAI,YAAY;AAChB,aAAW,OAAO,SAAS;AACzB,UAAM,WAA2B;AAAA,MAC/B,aAAa;AAAA,MACb,IAAI,IAAI;AAAA;AAAA;AAAA,MAGR,QAAQ,IAAI,QAAQ,MAAM,GAAG,EAAE,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG;AAAA,MACnD,MAAM,IAAI;AAAA;AAAA,MAEV,SAAS;AAAA;AAAA,MACT,MAAM,IAAI;AAAA,MACV,iBAAiB;AAAA,MACjB,UAAU,IAAI;AAAA,MACd,MAAM,KAAK,MAAM,IAAI,IAAI;AAAA,IAC3B;AACA,aAAS,UAAU,GAAG,IAAI,QAAQ,MAAM,GAAG,EAAE,CAAC,CAAC,IAAI,OAAO,SAAS,KAAK,MAAM,EAAE,CAAC;AACjF,QAAI;AACF,YAAM,QAAQ,EAAE,SAAS,IAAI,SAAS,SAAS,CAAC;AAAA,IAClD,QAAQ;AACN;AAAA,IACF;AACA,UAAM,GAAG,QAAQ,iDAAiD,EAAE,MAAK,oBAAI,KAAK,GAAE,YAAY,GAAG,IAAI,EAAE,EAAE,IAAI;AAC/G;AAAA,EACF;AAEA,QAAM,QAAQ,MAAM,GACjB,QAAQ,6DAA6D,EACrE,KAAK,EACL,MAAqB;AACxB,SAAO,EAAE,WAAW,WAAW,OAAO,OAAO,KAAK,CAAC,EAAE;AACvD;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/server-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "GoDX server-side SDK (Workers/Node) — holds service credentials; for browser code use @godxjp/web-sdk. Counterpart of godx-jp/php-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -62,6 +62,13 @@
62
62
  "worker": "./dist/producer.js",
63
63
  "browser": "./dist/browser-guard.js",
64
64
  "default": "./dist/producer.js"
65
+ },
66
+ "./logger": {
67
+ "types": "./dist/logger.d.ts",
68
+ "workerd": "./dist/logger.js",
69
+ "worker": "./dist/logger.js",
70
+ "browser": "./dist/browser-guard.js",
71
+ "default": "./dist/logger.js"
65
72
  }
66
73
  },
67
74
  "scripts": {