@godxjp/server-sdk 0.1.0 → 0.2.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
@@ -19,7 +19,8 @@ Naming is by **side**, not by language (`web-sdk` / `server-sdk` / `php-sdk`)
19
19
  | `./http` | OIDC discovery (cached per isolate; endpoints are read, never hardcoded), `GodxSsoClient` (authorize URL, code exchange, refresh, revoke, userinfo), the server-to-server service API (`X-Service-Slug`/`X-Service-Secret`). |
20
20
  | `./hono` | Thin adapter: `godxSso()` mounts `/auth/{login,callback,logout}`, `requireAuth`, `requireWritable` (read-only organizations may not write), `dockProxy` (the GoDX Dock's `apiBase` lane — no CORS, no token in the page). Every SSO failure lands back on the app as `/?sso_error=…&sso_error_description=…` with a human-readable description. |
21
21
  | `./sync` | The identity feed, both consumer legs — see below. |
22
- | `./org` | Typed client for the **write-back** surface (`/api/org/v1/*`): me, employees, departments, branches, teams (list / paginate-all / create). `Idempotency-Key` on every write (IETF draft), RFC 6750 `WWW-Authenticate` surfaced on 401/403, 422 field errors and 429 `Retry-After` exposed on `OrgApiError`. Tenancy is the token's — the client never sends an organization id. Since godx-jp/id#1960, a downstream **service-launch token carries the `organization-api` audience whenever org.\* scopes are granted** (i.e. survive RBAC narrowing) — see [`./org`](#org--the-write-back-client) below. |
22
+ | `./org` | Typed client for the **write-back** surface (`/api/org/v1/*`): me, employees, departments, branches, teams (list / paginate-all / create), and **notifications.create** (godx-jp/id#1962). `Idempotency-Key` on every write (IETF draft), RFC 6750 `WWW-Authenticate` surfaced on 401/403, 422 field errors and 429 `Retry-After` exposed on `OrgApiError`. Tenancy is the token's — the client never sends an organization id. Since godx-jp/id#1960, a downstream **service-launch token carries the `organization-api` audience whenever org.\* scopes are granted** (i.e. survive RBAC narrowing) — see [`./org`](#org--the-write-back-client) below. |
23
+ | `./producer` | The **outbox producer kit** (godx-jp/id#1963 phase 4): `OUTBOX_TABLE_SQL` (D1 DDL), `recordDomainEvent` (write the event next to the state change), `drainOutbox` (relay unpublished rows in sequence order through an injected `publish`). CloudEvents 1.0 envelope mirroring `./sync`'s shape — see [`./producer`](#producer--the-outbox-producer-kit) below. |
23
24
 
24
25
  Core modules depend on `fetch` + WebCrypto only; `hono` is the only framework-shaped subpath.
25
26
 
@@ -167,7 +168,22 @@ and the scope→permission mapping.
167
168
  | `PageMeta` / `Page<T>` | interfaces | Laravel-style page envelope (`data` + `meta`). |
168
169
  | `OrgEmployee` / `OrgDepartment` / `OrgBranch` / `OrgTeam` / `OrgMe` | interfaces | Row shapes (open-ended: `[key: string]: unknown`). |
169
170
  | `OrgApiError` | `class extends GodxHttpError { wwwAuthenticate, retryAfterSeconds, validationErrors }` | Every non-OK org/v1 answer, with the standard headers/bodies surfaced as fields. |
170
- | `createOrgApi` | `(config) => { me, employees, departments, branches, teams }` | The typed client; method table below. |
171
+ | `NotificationCategory` | union type | `organization_invitation \| access_request \| service_provisioning \| billing \| security \| operator \| service` — the platform's category vocabulary, verbatim. |
172
+ | `LocalizedText` | `{ ja, en?, vi? }` | `ja` is the required base locale; title ≤200 chars, body ≤2000. |
173
+ | `CreateNotificationInput` | `{ recipients, category, source_ref, action_url, title, body }` | 1..100 recipients; `source_ref` ≤191 (also the per-instance dedup key); `action_url` ≤2000, must start with the service's base URL. |
174
+ | `CreateNotificationResult` | `{ created[], updated[], skipped[] }` | `created`/`updated` carry `{id, user_id}`; `skipped` carries `{user_id, reason}` (e.g. `preference_off`). |
175
+ | `createOrgApi` | `(config) => { me, employees, departments, branches, teams, notifications }` | The typed client; method table below. |
176
+
177
+ ### `./producer`
178
+
179
+ | Export | Signature | One line |
180
+ |---|---|---|
181
+ | `OUTBOX_TABLE_SQL` | `string` | DDL for the outbox table + the `(published_at, sequence)` drain index — copy into a migration verbatim (below). |
182
+ | `ProducerContractError` | `class extends Error` | Every contract refusal (bad subject token). |
183
+ | `DomainEnvelope` | interface | CloudEvents 1.0 envelope as `drainOutbox` hands it to `publish` — `./sync`'s shape, no `tenantid` (the tenant is the subject). |
184
+ | `DomainEventInput` / `RecordedDomainEvent` | interfaces | What `recordDomainEvent` takes / returns (`{id, sequence, subject, type}`). |
185
+ | `recordDomainEvent` | `(db, {service, organizationId, aggregate, action, data, id?}) => Promise<RecordedDomainEvent>` | Insert one outbox row next to the caller's own write; composes subject/type, validates every token. |
186
+ | `drainOutbox` | `(db, publish, {batch = 100}?) => Promise<{published, remaining}>` | Publish unpublished rows in sequence order; marks `published_at` only after `publish` resolves; stops at the first failure. |
171
187
 
172
188
  ## `./sync` — the identity feed
173
189
 
@@ -385,6 +401,28 @@ page 2.
385
401
  | `departments.list(page?)` / `.all()` / `.create(...)` | `GET`/`POST /departments` | `org.departments:read` / `:write` |
386
402
  | `branches.list(page?)` / `.all()` / `.create(...)` | `GET`/`POST /branches` | `org.branches:read` / `:write` |
387
403
  | `teams.list(page?)` / `.all()` / `.create(...)` | `GET`/`POST /teams` | `org.teams:read` / `:write` |
404
+ | `notifications.create(input, {idempotencyKey?}?)` | `POST /notifications` | `org.notifications:write` |
405
+
406
+ `notifications.create` (godx-jp/id#1962) sends in-app notifications to organization members who
407
+ use this service — 1..100 `recipients` per call, every one of whom must belong to the token's
408
+ organization AND hold access to this service (422 otherwise). `action_url` must be absolute and
409
+ start with the service's base URL. `source_ref` doubles as the per-instance dedup key: sending the
410
+ same `source_ref` again lands in `updated` rather than duplicating. The platform answers **201
411
+ when anything was created, 200 otherwise**; the body is always
412
+ `{ data: { created, updated, skipped } }`, with each skip carrying its reason (e.g.
413
+ `preference_off` — the recipient turned the category off).
414
+
415
+ ```ts
416
+ const { data } = await orgApi.notifications.create({
417
+ recipients: ["user-uuid-1", "user-uuid-2"],
418
+ category: "service",
419
+ source_ref: "task:42", // dedup key per service instance
420
+ action_url: "https://boilerplate.example/tasks/42", // must start with the service base URL
421
+ title: { ja: "承認依頼", en: "Approval requested" }, // ja required; en/vi optional
422
+ body: { ja: "タスク42の承認をお願いします。" },
423
+ });
424
+ // data.created / data.updated: {id, user_id}[] · data.skipped: {user_id, reason}[]
425
+ ```
388
426
 
389
427
  ### Scope → permission mapping
390
428
 
@@ -401,6 +439,7 @@ the Console refuses):
401
439
  | `org.departments:write` | `organization.departments.manage`, `settings.manage` |
402
440
  | `org.branches:write` | `organization.branches.manage`, `settings.manage` |
403
441
  | `org.teams:write` | `organization.teams.manage` |
442
+ | `org.notifications:write` | `organization.members.invite` |
404
443
 
405
444
  ### Handling `OrgApiError`
406
445
 
@@ -436,21 +475,88 @@ The error `message` already names the likely cause per status ("missing/expired,
436
475
  minted for the organization-api audience", "lacks the route's org.* scope", …) so a bare log is
437
476
  still actionable.
438
477
 
478
+ ## `./producer` — the outbox producer kit
479
+
480
+ `./sync` is the CONSUMER half of the event discipline; this is the PRODUCER half (godx-jp/id#1963
481
+ phase 4). A service that publishes its own domain events writes them to a D1 **outbox** row next
482
+ to the state change they describe, and a relay drains the table in sequence order — so an event
483
+ can never outlive a rolled-back write, and never overtake an earlier one.
484
+
485
+ ```ts
486
+ import { recordDomainEvent, drainOutbox } from "@godxjp/server-sdk/producer";
487
+
488
+ // In the request handler, next to your own write:
489
+ await recordDomainEvent(env.DB, {
490
+ service: "boilerplate", // every token: /^[A-Za-z0-9_-]+$/ — refused by name otherwise
491
+ organizationId: principal.organizationId,
492
+ aggregate: "task",
493
+ action: "completed",
494
+ data: { task_id: task.id }, // full snapshot, not a diff — same rule as the identity feed
495
+ });
496
+ // → subject godx.svc.boilerplate.{orgId}.task.completed · type jp.godx.boilerplate.task.completed
497
+
498
+ // In the relay (Cron Trigger, or a host-side loop):
499
+ const { published, remaining } = await drainOutbox(env.DB, async ({ subject, envelope }) => {
500
+ await myTransport.publish(subject, JSON.stringify(envelope)); // CloudEvents 1.0, ./sync's shape
501
+ });
502
+ ```
503
+
504
+ The envelope is exactly `./sync`'s CloudEvents shape (`specversion`, `id`, `source`, `type`,
505
+ `subject`, `time`, `datacontenttype`, `sequence`, `data`) with **no `tenantid`** — the tenant is
506
+ the subject's `{organizationId}` token. `id` is the consumer's idempotency key, `sequence` the
507
+ per-producer ordering fence; `published_at` is marked only after `publish` resolves, so delivery
508
+ is at-least-once and consumers dedupe on `id` (which `./sync`'s `Inbox` already does).
509
+
510
+ **The transport is injected because a Worker cannot dial NATS over TCP.** Two honest shapes:
511
+
512
+ | Relay | Publish lag | How |
513
+ |---|---|---|
514
+ | Workers-resident (Cron Trigger) | **≥1 minute** — Cloudflare floors cron at 1 min. Do not promise consumers less from this shape | `"triggers": { "crons": ["* * * * *"] }` + `scheduled()` calling `drainOutbox` |
515
+ | Host-side loop (Node + any NATS client) | sub-second | poll `drainOutbox` in a long-lived process; same function, different clock |
516
+
517
+ **Sequence caveat, stated honestly**: `recordDomainEvent` computes `MAX(sequence) + 1` with a
518
+ read-then-insert, which is racy on paper. On D1 it is safe in practice because D1 serializes
519
+ writes per database — two invocations cannot interleave between the SELECT and the INSERT. On any
520
+ other SQLite with genuinely concurrent writers, run it inside the caller's transaction (where it
521
+ belongs anyway).
522
+
523
+ ### Outbox DDL (copy-paste)
524
+
525
+ `OUTBOX_TABLE_SQL` exports exactly this — paste into a migration verbatim and keep any ORM-side
526
+ schema (e.g. Drizzle) in lockstep:
527
+
528
+ ```sql
529
+ CREATE TABLE IF NOT EXISTS outbox (
530
+ id TEXT PRIMARY KEY,
531
+ sequence INTEGER NOT NULL,
532
+ subject TEXT NOT NULL,
533
+ type TEXT NOT NULL,
534
+ organization_id TEXT NOT NULL,
535
+ data TEXT NOT NULL,
536
+ created_at TEXT NOT NULL,
537
+ published_at TEXT NULL
538
+ );
539
+ CREATE INDEX IF NOT EXISTS outbox_unpublished_idx ON outbox (published_at, sequence);
540
+ ```
541
+
439
542
  ## Tests
440
543
 
441
544
  ```bash
442
545
  npx vitest run # from packages/server-sdk
443
546
  ```
444
547
 
445
- 30 tests, doubling as executable behaviour docs:
548
+ 40 tests, doubling as executable behaviour docs:
446
549
 
447
550
  - `sync.test.ts` (23): envelope validation (specversion/subject/source pinning), webhook
448
551
  sign/verify (tamper, replay, rotation, raw-secret fallback), processor outcomes (duplicate,
449
552
  unknown family, tenant refused, retry-not-recorded), ingest HTTP mapping (single, batch, mixed
450
553
  outcomes, batch-retry, 401/415/400/405), snapshot pagination/contract-stop/410.
451
- - `org.test.ts` (7): Bearer + Accept headers, Idempotency-Key (generated and caller-supplied),
554
+ - `org.test.ts` (9): Bearer + Accept headers, Idempotency-Key (generated and caller-supplied),
452
555
  pagination to the last page, 403 `WWW-Authenticate` surfaced, 401 audience hint, 422 per-field
453
- validation, 429 `Retry-After`.
556
+ validation, 429 `Retry-After`, notifications.create mapping + Idempotency-Key.
557
+ - `producer.test.ts` (8): subject/type composition, token charset refusal naming the offender,
558
+ sequence monotonicity, drain order + envelope shape (no tenantid), stop-on-first-failure,
559
+ published_at only after publish resolves, batch limit, DDL ships the drain index.
454
560
 
455
561
  The php-sdk mirrors the sync suite in `tests/Sync/Http/`.
456
562
 
@@ -483,4 +589,4 @@ Short version — the boilerplate's root `README.md` has the full symptom → ca
483
589
  - [ ] `"private": true` → off; add `files`, `license`, `repository`; version from `0.1.0`.
484
590
  - [ ] Move `hono` to `peerDependencies` review (already `>=4`) and keep `jose` as the only
485
591
  runtime dependency.
486
- - [ ] CI: `vitest run` (30) + `tsc --noEmit` + a pack-and-import smoke test on Workers and Node.
592
+ - [ ] CI: `vitest run` (40) + `tsc --noEmit` + a pack-and-import smoke test on Workers and Node.
package/dist/org.d.ts CHANGED
@@ -76,6 +76,41 @@ interface OrgTeam {
76
76
  interface OrgMe {
77
77
  [key: string]: unknown;
78
78
  }
79
+ /** Notification categories — the platform's `NotificationCenter::CATEGORIES`, verbatim. */
80
+ type NotificationCategory = "organization_invitation" | "access_request" | "service_provisioning" | "billing" | "security" | "operator" | "service";
81
+ /** `ja` is the required base locale; `en`/`vi` optional. Title ≤200 chars, body ≤2000. */
82
+ interface LocalizedText {
83
+ ja: string;
84
+ en?: string;
85
+ vi?: string;
86
+ }
87
+ interface CreateNotificationInput {
88
+ /** Console user ids — 1..100 per call. Every recipient must belong to the org AND hold access to this service. */
89
+ recipients: string[];
90
+ category: NotificationCategory;
91
+ /** Producer-side reference (≤191) — also the platform's per-instance dedup key: same source_ref updates instead of duplicating. */
92
+ source_ref: string;
93
+ /** Absolute URL (≤2000) that must start with the service's base URL — the platform refuses anything else. */
94
+ action_url: string;
95
+ title: LocalizedText;
96
+ body: LocalizedText;
97
+ }
98
+ /** The platform answers 201 when anything was created, 200 otherwise (updated/skipped only). */
99
+ interface CreateNotificationResult {
100
+ created: {
101
+ id: string;
102
+ user_id: string;
103
+ }[];
104
+ updated: {
105
+ id: string;
106
+ user_id: string;
107
+ }[];
108
+ /** e.g. reason "preference_off" — the recipient turned this category off. */
109
+ skipped: {
110
+ user_id: string;
111
+ reason: string;
112
+ }[];
113
+ }
79
114
  declare class OrgApiError extends GodxHttpError {
80
115
  /** RFC 6750 challenge, when the platform sent one (401/403). */
81
116
  readonly wwwAuthenticate: string | null;
@@ -121,6 +156,14 @@ declare function createOrgApi(config: OrgApiConfig): {
121
156
  data: OrgTeam;
122
157
  }>;
123
158
  };
159
+ notifications: {
160
+ /** scope org.notifications:write — send in-app notifications to members who use this service (godx-jp/id#1962). */
161
+ create: (input: CreateNotificationInput, options?: {
162
+ idempotencyKey?: string;
163
+ }) => Promise<{
164
+ data: CreateNotificationResult;
165
+ }>;
166
+ };
124
167
  };
125
168
 
126
- export { type OrgApiConfig, OrgApiError, type OrgBranch, type OrgDepartment, type OrgEmployee, type OrgMe, type OrgTeam, type Page, type PageMeta, createOrgApi };
169
+ export { type CreateNotificationInput, type CreateNotificationResult, type LocalizedText, type NotificationCategory, type OrgApiConfig, OrgApiError, type OrgBranch, type OrgDepartment, type OrgEmployee, type OrgMe, type OrgTeam, type Page, type PageMeta, createOrgApi };
package/dist/org.js CHANGED
@@ -91,6 +91,10 @@ function createOrgApi(config) {
91
91
  list: (page) => get("/teams", page ? { page: String(page) } : void 0),
92
92
  all: () => paginate("/teams"),
93
93
  create: (payload, idempotencyKey) => post("/teams", payload, idempotencyKey)
94
+ },
95
+ notifications: {
96
+ /** scope org.notifications:write — send in-app notifications to members who use this service (godx-jp/id#1962). */
97
+ create: (input, options) => post("/notifications", { ...input }, options?.idempotencyKey)
94
98
  }
95
99
  };
96
100
  }
package/dist/org.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/org.ts"],"sourcesContent":["/**\n * ./org — typed client for the Delegated Organization API (`/api/org/v1/*`),\n * the platform's write-back surface: the ONLY sanctioned way a service changes\n * platform-owned directory data. The identity feed then carries the change to\n * every consumer (the feed itself is one-way; see ./sync).\n *\n * Auth model, exactly as the platform enforces it (routes/api/org.php):\n * - Bearer access token minted FOR the `organization-api` recipient resource\n * (`aud` contains `{issuer}/api/org/v1`) by a `user`-lane principal.\n * - Tenancy is the token's organization, re-derived server-side per request —\n * this client never sends an organization id.\n * - Each route needs its `org.{resource}:{read|write}` scope; a missing scope\n * answers 403 `insufficient_scope` with a WWW-Authenticate header (RFC 6750),\n * which this client surfaces verbatim.\n *\n * NOTE (2026-09): the platform grants that audience on the MINI-APP lane only\n * (`TokenIssuer::miniAppResourceAudiences`). A downstream service-launch token\n * does not carry it yet — godx-jp/id has the proposal to widen that lane.\n *\n * International-standard behaviours built in:\n * - `Idempotency-Key` (IETF draft-ietf-httpapi-idempotency-key-header) is sent\n * on every write — generated when not supplied. The org/v1 surface does not\n * *honor* it yet (the admin surface does); sending it is forward-compatible\n * and costs nothing.\n * - 401/403 surface the `WWW-Authenticate` challenge; 429 surfaces\n * `Retry-After`; validation errors (422) surface the per-field messages.\n * - Page-based pagination is wrapped in an async iterator so callers cannot\n * forget page 2.\n */\nimport { GodxHttpError } from \"./http\";\n\nexport interface OrgApiConfig {\n issuer: string;\n /** Bearer access token whose aud includes {issuer}/api/org/v1. */\n accessToken: string;\n}\n\nexport interface PageMeta {\n current_page: number;\n last_page: number;\n per_page: number;\n total: number;\n}\n\nexport interface Page<T> {\n data: T[];\n meta: PageMeta;\n}\n\nexport interface OrgEmployee {\n id: string;\n employee_code: string;\n full_name: string;\n email?: string | null;\n department_id?: string | null;\n branch_id?: string | null;\n user_id?: string | null;\n [key: string]: unknown;\n}\n\nexport interface OrgDepartment {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgBranch {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgTeam {\n id: string;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgMe {\n [key: string]: unknown;\n}\n\nexport class OrgApiError extends GodxHttpError {\n /** RFC 6750 challenge, when the platform sent one (401/403). */\n readonly wwwAuthenticate: string | null;\n /** Seconds to wait, when the platform answered 429 with Retry-After. */\n readonly retryAfterSeconds: number | null;\n /** Laravel validation messages per field, when the answer was 422. */\n readonly validationErrors: Record<string, string[]> | null;\n\n constructor(status: number, body: string, message: string, headers: Headers) {\n super(status, body, message);\n this.name = \"OrgApiError\";\n this.wwwAuthenticate = headers.get(\"WWW-Authenticate\");\n const retryAfter = headers.get(\"Retry-After\");\n this.retryAfterSeconds = retryAfter !== null && /^\\d+$/.test(retryAfter) ? Number(retryAfter) : null;\n let validation: Record<string, string[]> | null = null;\n try {\n const parsed = JSON.parse(body) as { errors?: Record<string, string[]> };\n if (parsed && typeof parsed === \"object\" && parsed.errors) validation = parsed.errors;\n } catch {\n /* body was not JSON — nothing more to surface */\n }\n this.validationErrors = validation;\n }\n}\n\nasync function unwrap<T>(response: Response, what: string): Promise<T> {\n if (!response.ok) {\n const body = await response.text();\n const challenge = response.headers.get(\"WWW-Authenticate\");\n const hint =\n response.status === 401\n ? `the token is missing, expired, or was not minted for the organization-api audience${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 403\n ? `the token lacks the route's org.* scope${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 422\n ? \"validation refused the payload (see validationErrors)\"\n : response.status === 429\n ? \"rate limited — honor retryAfterSeconds\"\n : \"see the response body\";\n throw new OrgApiError(response.status, body, `${what} answered ${response.status}: ${hint}`, response.headers);\n }\n return (await response.json()) as T;\n}\n\nexport function createOrgApi(config: OrgApiConfig) {\n const base = config.issuer.replace(/\\/$/, \"\") + \"/api/org/v1\";\n\n const get = async <T>(path: string, query?: Record<string, string>): Promise<T> => {\n const url = new URL(base + path);\n if (query) url.search = new URLSearchParams(query).toString();\n const response = await fetch(url, {\n headers: { Authorization: `Bearer ${config.accessToken}`, Accept: \"application/json\" },\n });\n return unwrap<T>(response, `GET ${path}`);\n };\n\n const post = async <T>(path: string, payload: Record<string, unknown>, idempotencyKey?: string): Promise<T> => {\n const response = await fetch(base + path, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${config.accessToken}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"Idempotency-Key\": idempotencyKey ?? crypto.randomUUID(),\n },\n body: JSON.stringify(payload),\n });\n return unwrap<T>(response, `POST ${path}`);\n };\n\n /** Follow page-based pagination to the end, yielding every row once. */\n async function* paginate<T>(path: string): AsyncGenerator<T, void, void> {\n let page = 1;\n for (;;) {\n const result = await get<Page<T>>(path, { page: String(page) });\n for (const row of result.data) yield row;\n if (result.meta.current_page >= result.meta.last_page) return;\n page = result.meta.current_page + 1;\n }\n }\n\n return {\n /** GET /me — scope org:profile. */\n me: () => get<OrgMe>(\"/me\"),\n\n employees: {\n list: (page?: number) => get<Page<OrgEmployee>>(\"/employees\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgEmployee>(\"/employees\"),\n /** scope org.employees:write */\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgEmployee }>(\"/employees\", payload, idempotencyKey),\n /** Who this user reports to — rides org.employees:read. */\n managerOf: (consoleUserId: string) => get<{ data: unknown }>(`/users/${encodeURIComponent(consoleUserId)}/manager`),\n },\n\n departments: {\n list: (page?: number) => get<Page<OrgDepartment>>(\"/departments\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgDepartment>(\"/departments\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgDepartment }>(\"/departments\", payload, idempotencyKey),\n },\n\n branches: {\n list: (page?: number) => get<Page<OrgBranch>>(\"/branches\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgBranch>(\"/branches\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgBranch }>(\"/branches\", payload, idempotencyKey),\n },\n\n teams: {\n list: (page?: number) => get<Page<OrgTeam>>(\"/teams\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgTeam>(\"/teams\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgTeam }>(\"/teams\", payload, idempotencyKey),\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/org.ts"],"sourcesContent":["/**\n * ./org — typed client for the Delegated Organization API (`/api/org/v1/*`),\n * the platform's write-back surface: the ONLY sanctioned way a service changes\n * platform-owned directory data. The identity feed then carries the change to\n * every consumer (the feed itself is one-way; see ./sync).\n *\n * Auth model, exactly as the platform enforces it (routes/api/org.php):\n * - Bearer access token minted FOR the `organization-api` recipient resource\n * (`aud` contains `{issuer}/api/org/v1`) by a `user`-lane principal.\n * - Tenancy is the token's organization, re-derived server-side per request —\n * this client never sends an organization id.\n * - Each route needs its `org.{resource}:{read|write}` scope; a missing scope\n * answers 403 `insufficient_scope` with a WWW-Authenticate header (RFC 6750),\n * which this client surfaces verbatim.\n *\n * NOTE (2026-09): the platform grants that audience on the MINI-APP lane only\n * (`TokenIssuer::miniAppResourceAudiences`). A downstream service-launch token\n * does not carry it yet — godx-jp/id has the proposal to widen that lane.\n *\n * International-standard behaviours built in:\n * - `Idempotency-Key` (IETF draft-ietf-httpapi-idempotency-key-header) is sent\n * on every write — generated when not supplied. The org/v1 surface does not\n * *honor* it yet (the admin surface does); sending it is forward-compatible\n * and costs nothing.\n * - 401/403 surface the `WWW-Authenticate` challenge; 429 surfaces\n * `Retry-After`; validation errors (422) surface the per-field messages.\n * - Page-based pagination is wrapped in an async iterator so callers cannot\n * forget page 2.\n */\nimport { GodxHttpError } from \"./http\";\n\nexport interface OrgApiConfig {\n issuer: string;\n /** Bearer access token whose aud includes {issuer}/api/org/v1. */\n accessToken: string;\n}\n\nexport interface PageMeta {\n current_page: number;\n last_page: number;\n per_page: number;\n total: number;\n}\n\nexport interface Page<T> {\n data: T[];\n meta: PageMeta;\n}\n\nexport interface OrgEmployee {\n id: string;\n employee_code: string;\n full_name: string;\n email?: string | null;\n department_id?: string | null;\n branch_id?: string | null;\n user_id?: string | null;\n [key: string]: unknown;\n}\n\nexport interface OrgDepartment {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgBranch {\n id: string;\n code?: string | null;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgTeam {\n id: string;\n name: string;\n [key: string]: unknown;\n}\n\nexport interface OrgMe {\n [key: string]: unknown;\n}\n\n/** Notification categories — the platform's `NotificationCenter::CATEGORIES`, verbatim. */\nexport type NotificationCategory =\n | \"organization_invitation\"\n | \"access_request\"\n | \"service_provisioning\"\n | \"billing\"\n | \"security\"\n | \"operator\"\n | \"service\";\n\n/** `ja` is the required base locale; `en`/`vi` optional. Title ≤200 chars, body ≤2000. */\nexport interface LocalizedText {\n ja: string;\n en?: string;\n vi?: string;\n}\n\nexport interface CreateNotificationInput {\n /** Console user ids — 1..100 per call. Every recipient must belong to the org AND hold access to this service. */\n recipients: string[];\n category: NotificationCategory;\n /** Producer-side reference (≤191) — also the platform's per-instance dedup key: same source_ref updates instead of duplicating. */\n source_ref: string;\n /** Absolute URL (≤2000) that must start with the service's base URL — the platform refuses anything else. */\n action_url: string;\n title: LocalizedText;\n body: LocalizedText;\n}\n\n/** The platform answers 201 when anything was created, 200 otherwise (updated/skipped only). */\nexport interface CreateNotificationResult {\n created: { id: string; user_id: string }[];\n updated: { id: string; user_id: string }[];\n /** e.g. reason \"preference_off\" — the recipient turned this category off. */\n skipped: { user_id: string; reason: string }[];\n}\n\nexport class OrgApiError extends GodxHttpError {\n /** RFC 6750 challenge, when the platform sent one (401/403). */\n readonly wwwAuthenticate: string | null;\n /** Seconds to wait, when the platform answered 429 with Retry-After. */\n readonly retryAfterSeconds: number | null;\n /** Laravel validation messages per field, when the answer was 422. */\n readonly validationErrors: Record<string, string[]> | null;\n\n constructor(status: number, body: string, message: string, headers: Headers) {\n super(status, body, message);\n this.name = \"OrgApiError\";\n this.wwwAuthenticate = headers.get(\"WWW-Authenticate\");\n const retryAfter = headers.get(\"Retry-After\");\n this.retryAfterSeconds = retryAfter !== null && /^\\d+$/.test(retryAfter) ? Number(retryAfter) : null;\n let validation: Record<string, string[]> | null = null;\n try {\n const parsed = JSON.parse(body) as { errors?: Record<string, string[]> };\n if (parsed && typeof parsed === \"object\" && parsed.errors) validation = parsed.errors;\n } catch {\n /* body was not JSON — nothing more to surface */\n }\n this.validationErrors = validation;\n }\n}\n\nasync function unwrap<T>(response: Response, what: string): Promise<T> {\n if (!response.ok) {\n const body = await response.text();\n const challenge = response.headers.get(\"WWW-Authenticate\");\n const hint =\n response.status === 401\n ? `the token is missing, expired, or was not minted for the organization-api audience${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 403\n ? `the token lacks the route's org.* scope${challenge ? ` — ${challenge}` : \"\"}`\n : response.status === 422\n ? \"validation refused the payload (see validationErrors)\"\n : response.status === 429\n ? \"rate limited — honor retryAfterSeconds\"\n : \"see the response body\";\n throw new OrgApiError(response.status, body, `${what} answered ${response.status}: ${hint}`, response.headers);\n }\n return (await response.json()) as T;\n}\n\nexport function createOrgApi(config: OrgApiConfig) {\n const base = config.issuer.replace(/\\/$/, \"\") + \"/api/org/v1\";\n\n const get = async <T>(path: string, query?: Record<string, string>): Promise<T> => {\n const url = new URL(base + path);\n if (query) url.search = new URLSearchParams(query).toString();\n const response = await fetch(url, {\n headers: { Authorization: `Bearer ${config.accessToken}`, Accept: \"application/json\" },\n });\n return unwrap<T>(response, `GET ${path}`);\n };\n\n const post = async <T>(path: string, payload: Record<string, unknown>, idempotencyKey?: string): Promise<T> => {\n const response = await fetch(base + path, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${config.accessToken}`,\n Accept: \"application/json\",\n \"Content-Type\": \"application/json\",\n \"Idempotency-Key\": idempotencyKey ?? crypto.randomUUID(),\n },\n body: JSON.stringify(payload),\n });\n return unwrap<T>(response, `POST ${path}`);\n };\n\n /** Follow page-based pagination to the end, yielding every row once. */\n async function* paginate<T>(path: string): AsyncGenerator<T, void, void> {\n let page = 1;\n for (;;) {\n const result = await get<Page<T>>(path, { page: String(page) });\n for (const row of result.data) yield row;\n if (result.meta.current_page >= result.meta.last_page) return;\n page = result.meta.current_page + 1;\n }\n }\n\n return {\n /** GET /me — scope org:profile. */\n me: () => get<OrgMe>(\"/me\"),\n\n employees: {\n list: (page?: number) => get<Page<OrgEmployee>>(\"/employees\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgEmployee>(\"/employees\"),\n /** scope org.employees:write */\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgEmployee }>(\"/employees\", payload, idempotencyKey),\n /** Who this user reports to — rides org.employees:read. */\n managerOf: (consoleUserId: string) => get<{ data: unknown }>(`/users/${encodeURIComponent(consoleUserId)}/manager`),\n },\n\n departments: {\n list: (page?: number) => get<Page<OrgDepartment>>(\"/departments\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgDepartment>(\"/departments\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgDepartment }>(\"/departments\", payload, idempotencyKey),\n },\n\n branches: {\n list: (page?: number) => get<Page<OrgBranch>>(\"/branches\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgBranch>(\"/branches\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgBranch }>(\"/branches\", payload, idempotencyKey),\n },\n\n teams: {\n list: (page?: number) => get<Page<OrgTeam>>(\"/teams\", page ? { page: String(page) } : undefined),\n all: () => paginate<OrgTeam>(\"/teams\"),\n create: (payload: Record<string, unknown>, idempotencyKey?: string) =>\n post<{ data: OrgTeam }>(\"/teams\", payload, idempotencyKey),\n },\n\n notifications: {\n /** scope org.notifications:write — send in-app notifications to members who use this service (godx-jp/id#1962). */\n create: (input: CreateNotificationInput, options?: { idempotencyKey?: string }) =>\n post<{ data: CreateNotificationResult }>(\"/notifications\", { ...input }, options?.idempotencyKey),\n },\n };\n}\n"],"mappings":";;;;;AAyHO,IAAM,cAAN,cAA0B,cAAc;AAAA;AAAA,EAEpC;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA,EAET,YAAY,QAAgB,MAAc,SAAiB,SAAkB;AAC3E,UAAM,QAAQ,MAAM,OAAO;AAC3B,SAAK,OAAO;AACZ,SAAK,kBAAkB,QAAQ,IAAI,kBAAkB;AACrD,UAAM,aAAa,QAAQ,IAAI,aAAa;AAC5C,SAAK,oBAAoB,eAAe,QAAQ,QAAQ,KAAK,UAAU,IAAI,OAAO,UAAU,IAAI;AAChG,QAAI,aAA8C;AAClD,QAAI;AACF,YAAM,SAAS,KAAK,MAAM,IAAI;AAC9B,UAAI,UAAU,OAAO,WAAW,YAAY,OAAO,OAAQ,cAAa,OAAO;AAAA,IACjF,QAAQ;AAAA,IAER;AACA,SAAK,mBAAmB;AAAA,EAC1B;AACF;AAEA,eAAe,OAAU,UAAoB,MAA0B;AACrE,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,OAAO,MAAM,SAAS,KAAK;AACjC,UAAM,YAAY,SAAS,QAAQ,IAAI,kBAAkB;AACzD,UAAM,OACJ,SAAS,WAAW,MAChB,qFAAqF,YAAY,WAAM,SAAS,KAAK,EAAE,KACvH,SAAS,WAAW,MAClB,0CAA0C,YAAY,WAAM,SAAS,KAAK,EAAE,KAC5E,SAAS,WAAW,MAClB,0DACA,SAAS,WAAW,MAClB,gDACA;AACZ,UAAM,IAAI,YAAY,SAAS,QAAQ,MAAM,GAAG,IAAI,aAAa,SAAS,MAAM,KAAK,IAAI,IAAI,SAAS,OAAO;AAAA,EAC/G;AACA,SAAQ,MAAM,SAAS,KAAK;AAC9B;AAEO,SAAS,aAAa,QAAsB;AACjD,QAAM,OAAO,OAAO,OAAO,QAAQ,OAAO,EAAE,IAAI;AAEhD,QAAM,MAAM,OAAU,MAAc,UAA+C;AACjF,UAAM,MAAM,IAAI,IAAI,OAAO,IAAI;AAC/B,QAAI,MAAO,KAAI,SAAS,IAAI,gBAAgB,KAAK,EAAE,SAAS;AAC5D,UAAM,WAAW,MAAM,MAAM,KAAK;AAAA,MAChC,SAAS,EAAE,eAAe,UAAU,OAAO,WAAW,IAAI,QAAQ,mBAAmB;AAAA,IACvF,CAAC;AACD,WAAO,OAAU,UAAU,OAAO,IAAI,EAAE;AAAA,EAC1C;AAEA,QAAM,OAAO,OAAU,MAAc,SAAkC,mBAAwC;AAC7G,UAAM,WAAW,MAAM,MAAM,OAAO,MAAM;AAAA,MACxC,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,eAAe,UAAU,OAAO,WAAW;AAAA,QAC3C,QAAQ;AAAA,QACR,gBAAgB;AAAA,QAChB,mBAAmB,kBAAkB,OAAO,WAAW;AAAA,MACzD;AAAA,MACA,MAAM,KAAK,UAAU,OAAO;AAAA,IAC9B,CAAC;AACD,WAAO,OAAU,UAAU,QAAQ,IAAI,EAAE;AAAA,EAC3C;AAGA,kBAAgB,SAAY,MAA6C;AACvE,QAAI,OAAO;AACX,eAAS;AACP,YAAM,SAAS,MAAM,IAAa,MAAM,EAAE,MAAM,OAAO,IAAI,EAAE,CAAC;AAC9D,iBAAW,OAAO,OAAO,KAAM,OAAM;AACrC,UAAI,OAAO,KAAK,gBAAgB,OAAO,KAAK,UAAW;AACvD,aAAO,OAAO,KAAK,eAAe;AAAA,IACpC;AAAA,EACF;AAEA,SAAO;AAAA;AAAA,IAEL,IAAI,MAAM,IAAW,KAAK;AAAA,IAE1B,WAAW;AAAA,MACT,MAAM,CAAC,SAAkB,IAAuB,cAAc,OAAO,EAAE,MAAM,OAAO,IAAI,EAAE,IAAI,MAAS;AAAA,MACvG,KAAK,MAAM,SAAsB,YAAY;AAAA;AAAA,MAE7C,QAAQ,CAAC,SAAkC,mBACzC,KAA4B,cAAc,SAAS,cAAc;AAAA;AAAA,MAEnE,WAAW,CAAC,kBAA0B,IAAuB,UAAU,mBAAmB,aAAa,CAAC,UAAU;AAAA,IACpH;AAAA,IAEA,aAAa;AAAA,MACX,MAAM,CAAC,SAAkB,IAAyB,gBAAgB,OAAO,EAAE,MAAM,OAAO,IAAI,EAAE,IAAI,MAAS;AAAA,MAC3G,KAAK,MAAM,SAAwB,cAAc;AAAA,MACjD,QAAQ,CAAC,SAAkC,mBACzC,KAA8B,gBAAgB,SAAS,cAAc;AAAA,IACzE;AAAA,IAEA,UAAU;AAAA,MACR,MAAM,CAAC,SAAkB,IAAqB,aAAa,OAAO,EAAE,MAAM,OAAO,IAAI,EAAE,IAAI,MAAS;AAAA,MACpG,KAAK,MAAM,SAAoB,WAAW;AAAA,MAC1C,QAAQ,CAAC,SAAkC,mBACzC,KAA0B,aAAa,SAAS,cAAc;AAAA,IAClE;AAAA,IAEA,OAAO;AAAA,MACL,MAAM,CAAC,SAAkB,IAAmB,UAAU,OAAO,EAAE,MAAM,OAAO,IAAI,EAAE,IAAI,MAAS;AAAA,MAC/F,KAAK,MAAM,SAAkB,QAAQ;AAAA,MACrC,QAAQ,CAAC,SAAkC,mBACzC,KAAwB,UAAU,SAAS,cAAc;AAAA,IAC7D;AAAA,IAEA,eAAe;AAAA;AAAA,MAEb,QAAQ,CAAC,OAAgC,YACvC,KAAyC,kBAAkB,EAAE,GAAG,MAAM,GAAG,SAAS,cAAc;AAAA,IACpG;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,109 @@
1
+ /**
2
+ * ./producer — the outbox half of the event discipline (godx-jp/id#1963,
3
+ * phase 4): a service that PUBLISHES its own domain events writes them to a
4
+ * D1 outbox row alongside its state change, and a relay drains the table in
5
+ * sequence order. The envelope mirrors ./sync's CloudEvents 1.0 shape so
6
+ * consumers reuse one parser; there is NO tenantid attribute — the tenant
7
+ * rides in the subject, exactly like the identity feed.
8
+ *
9
+ * The transport is INJECTED (the `publish` callback) because a Cloudflare
10
+ * Worker cannot dial NATS over TCP. Two deployment shapes, stated honestly:
11
+ *
12
+ * - **Workers-resident relay**: a Cron Trigger calling `drainOutbox`.
13
+ * Cloudflare floors cron at 1 minute, so publish lag is ≥1 minute.
14
+ * Do not promise consumers anything faster from this shape.
15
+ * - **Host-side loop**: any long-lived process with a NATS client (nats.js on
16
+ * Node, or the platform's own relay) polling `drainOutbox` gets sub-second
17
+ * lag. Same function, different clock.
18
+ */
19
+ /** Structural subset of D1Database, so this module has no CF type dependency. */
20
+ interface D1Like {
21
+ prepare(sql: string): {
22
+ bind(...values: unknown[]): {
23
+ first<T = unknown>(): Promise<T | null>;
24
+ run(): Promise<unknown>;
25
+ all<T = unknown>(): Promise<{
26
+ results: T[];
27
+ }>;
28
+ };
29
+ };
30
+ }
31
+ declare class ProducerContractError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ /**
35
+ * DDL for the outbox table — copy into a migration verbatim (drizzle users:
36
+ * keep the ORM schema in lockstep). The index serves `drainOutbox`'s
37
+ * "unpublished, in sequence order" read.
38
+ */
39
+ declare 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);";
40
+ /**
41
+ * CloudEvents 1.0 envelope as `drainOutbox` hands it to `publish` — the same
42
+ * shape ./sync's IdentityEnvelope reads, and deliberately WITHOUT a tenantid
43
+ * attribute: the tenant is the subject's `{organizationId}` token.
44
+ */
45
+ interface DomainEnvelope {
46
+ specversion: "1.0";
47
+ /** UUIDv4 — THE idempotency key for consumers. */
48
+ id: string;
49
+ /** Producer identity: `godx.svc.{service}` (the subject's first three tokens). */
50
+ source: string;
51
+ /** jp.godx.{service}.{aggregate}.{action} */
52
+ type: string;
53
+ /** godx.svc.{service}.{organizationId}.{aggregate}.{action} */
54
+ subject: string;
55
+ time: string;
56
+ datacontenttype: "application/json";
57
+ /** Per-producer monotonic ordering fence (outbox.sequence). */
58
+ sequence: number;
59
+ data: Record<string, unknown>;
60
+ }
61
+ interface DomainEventInput {
62
+ service: string;
63
+ organizationId: string;
64
+ aggregate: string;
65
+ action: string;
66
+ data: Record<string, unknown>;
67
+ /** CloudEvents id — generated (crypto.randomUUID) when not supplied. */
68
+ id?: string;
69
+ }
70
+ interface RecordedDomainEvent {
71
+ id: string;
72
+ sequence: number;
73
+ subject: string;
74
+ type: string;
75
+ }
76
+ /**
77
+ * Record one domain event in the outbox, next to the state change it
78
+ * describes. Composes:
79
+ * - subject = `godx.svc.{service}.{organizationId}.{aggregate}.{action}`
80
+ * - type = `jp.godx.{service}.{aggregate}.{action}`
81
+ *
82
+ * Sequence is per-producer monotonic via `MAX(sequence) + 1`. That
83
+ * read-then-insert is racy on paper; on D1 it is safe in practice because
84
+ * D1 serializes writes per database — two invocations cannot interleave
85
+ * between the SELECT and the INSERT. On any other SQLite with genuinely
86
+ * concurrent writers, run this inside the caller's transaction (which is
87
+ * where it belongs anyway: the event must not outlive a rolled-back write).
88
+ */
89
+ declare function recordDomainEvent(db: D1Like, input: DomainEventInput): Promise<RecordedDomainEvent>;
90
+ /**
91
+ * Publish unpublished rows in sequence order through the injected transport.
92
+ * `published_at` is marked only AFTER `publish` resolves — a crash between
93
+ * the two re-publishes the row, so consumers dedupe on the CloudEvents `id`
94
+ * (at-least-once, exactly like the identity feed). The drain STOPS at the
95
+ * first failure so no later event overtakes an earlier one; the failed row
96
+ * stays unpublished and the next drain retries it, so inspect `remaining`
97
+ * rather than expecting a throw.
98
+ */
99
+ declare function drainOutbox(db: D1Like, publish: (event: {
100
+ subject: string;
101
+ envelope: DomainEnvelope;
102
+ }) => Promise<void>, { batch }?: {
103
+ batch?: number;
104
+ }): Promise<{
105
+ published: number;
106
+ remaining: number;
107
+ }>;
108
+
109
+ export { type DomainEnvelope, type DomainEventInput, OUTBOX_TABLE_SQL, ProducerContractError, type RecordedDomainEvent, drainOutbox, recordDomainEvent };
@@ -0,0 +1,79 @@
1
+ // src/producer.ts
2
+ var ProducerContractError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "ProducerContractError";
6
+ }
7
+ };
8
+ var OUTBOX_TABLE_SQL = `CREATE TABLE IF NOT EXISTS outbox (
9
+ id TEXT PRIMARY KEY,
10
+ sequence INTEGER NOT NULL,
11
+ subject TEXT NOT NULL,
12
+ type TEXT NOT NULL,
13
+ organization_id TEXT NOT NULL,
14
+ data TEXT NOT NULL,
15
+ created_at TEXT NOT NULL,
16
+ published_at TEXT NULL
17
+ );
18
+ CREATE INDEX IF NOT EXISTS outbox_unpublished_idx ON outbox (published_at, sequence);`;
19
+ var TOKEN = /^[A-Za-z0-9_-]+$/;
20
+ function requireToken(name, value) {
21
+ if (!TOKEN.test(value)) {
22
+ throw new ProducerContractError(
23
+ `${name} ${JSON.stringify(value)} is not a valid subject token \u2014 expected /^[A-Za-z0-9_-]+$/ ("." separates tokens and may not appear inside one).`
24
+ );
25
+ }
26
+ return value;
27
+ }
28
+ async function recordDomainEvent(db, input) {
29
+ const service = requireToken("service", input.service);
30
+ const organizationId = requireToken("organizationId", input.organizationId);
31
+ const aggregate = requireToken("aggregate", input.aggregate);
32
+ const action = requireToken("action", input.action);
33
+ const subject = `godx.svc.${service}.${organizationId}.${aggregate}.${action}`;
34
+ const type = `jp.godx.${service}.${aggregate}.${action}`;
35
+ const id = input.id ?? crypto.randomUUID();
36
+ const next = await db.prepare("SELECT COALESCE(MAX(sequence), 0) + 1 AS next FROM outbox").bind().first();
37
+ const sequence = Number(next?.next ?? 1);
38
+ await db.prepare(
39
+ "INSERT INTO outbox (id, sequence, subject, type, organization_id, data, created_at, published_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)"
40
+ ).bind(id, sequence, subject, type, organizationId, JSON.stringify(input.data), (/* @__PURE__ */ new Date()).toISOString()).run();
41
+ return { id, sequence, subject, type };
42
+ }
43
+ async function drainOutbox(db, publish, { batch = 100 } = {}) {
44
+ const { results } = await db.prepare(
45
+ "SELECT id, sequence, subject, type, data, created_at FROM outbox WHERE published_at IS NULL ORDER BY sequence LIMIT ?"
46
+ ).bind(batch).all();
47
+ let published = 0;
48
+ for (const row of results) {
49
+ const envelope = {
50
+ specversion: "1.0",
51
+ id: row.id,
52
+ // Tokens cannot contain "." (validated on record), so the first three
53
+ // subject tokens are exactly `godx.svc.{service}` — the producer.
54
+ source: row.subject.split(".").slice(0, 3).join("."),
55
+ type: row.type,
56
+ subject: row.subject,
57
+ time: row.created_at,
58
+ datacontenttype: "application/json",
59
+ sequence: row.sequence,
60
+ data: JSON.parse(row.data)
61
+ };
62
+ try {
63
+ await publish({ subject: row.subject, envelope });
64
+ } catch {
65
+ break;
66
+ }
67
+ await db.prepare("UPDATE outbox SET published_at = ? WHERE id = ?").bind((/* @__PURE__ */ new Date()).toISOString(), row.id).run();
68
+ published++;
69
+ }
70
+ const count = await db.prepare("SELECT COUNT(*) AS n FROM outbox WHERE published_at IS NULL").bind().first();
71
+ return { published, remaining: Number(count?.n ?? 0) };
72
+ }
73
+ export {
74
+ OUTBOX_TABLE_SQL,
75
+ ProducerContractError,
76
+ drainOutbox,
77
+ recordDomainEvent
78
+ };
79
+ //# sourceMappingURL=producer.js.map
@@ -0,0 +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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/server-sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {
@@ -55,6 +55,13 @@
55
55
  "worker": "./dist/org.js",
56
56
  "browser": "./dist/browser-guard.js",
57
57
  "default": "./dist/org.js"
58
+ },
59
+ "./producer": {
60
+ "types": "./dist/producer.d.ts",
61
+ "workerd": "./dist/producer.js",
62
+ "worker": "./dist/producer.js",
63
+ "browser": "./dist/browser-guard.js",
64
+ "default": "./dist/producer.js"
58
65
  }
59
66
  },
60
67
  "scripts": {