@segmentstream-cloud/ontology 0.10.0 → 0.12.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.
Files changed (73) hide show
  1. package/README.md +6 -0
  2. package/dist/action.d.ts +19 -0
  3. package/dist/action.js +1 -0
  4. package/dist/analyze.d.ts +3 -1
  5. package/dist/analyze.js +81 -1
  6. package/dist/bin/migrate.js +3 -2
  7. package/dist/diagnostics.d.ts +3 -3
  8. package/dist/diagnostics.js +1 -0
  9. package/dist/index.d.ts +5 -3
  10. package/dist/index.js +4 -1
  11. package/dist/manifest.d.ts +48 -0
  12. package/dist/manifest.js +9 -0
  13. package/dist/migrate/apply.d.ts +8 -1
  14. package/dist/migrate/apply.js +54 -4
  15. package/dist/migrate/index.d.ts +1 -1
  16. package/dist/migrate/index.js +1 -1
  17. package/dist/migrate/model.d.ts +8 -0
  18. package/dist/migrate/plan.d.ts +6 -0
  19. package/dist/migrate/plan.js +10 -2
  20. package/dist/migrate/storage.d.ts +12 -0
  21. package/dist/migrate/storage.js +82 -0
  22. package/dist/names.d.ts +9 -1
  23. package/dist/names.js +11 -0
  24. package/dist/nitro.d.ts +13 -0
  25. package/dist/nitro.js +18 -0
  26. package/dist/replication/baseline.d.ts +19 -0
  27. package/dist/replication/baseline.js +97 -0
  28. package/dist/replication/bigquery.d.ts +23 -0
  29. package/dist/replication/bigquery.js +63 -0
  30. package/dist/replication/model.d.ts +38 -0
  31. package/dist/replication/model.js +89 -0
  32. package/dist/replication/schema.d.ts +7 -0
  33. package/dist/replication/schema.js +123 -0
  34. package/dist/replication/store.d.ts +35 -0
  35. package/dist/replication/store.js +88 -0
  36. package/dist/replication/transaction.d.ts +4 -0
  37. package/dist/replication/transaction.js +11 -0
  38. package/dist/runtime/generated-actions.d.ts +3 -0
  39. package/dist/runtime/generated-actions.js +3 -2
  40. package/dist/runtime/oauth.d.ts +9 -0
  41. package/dist/runtime/oauth.js +165 -0
  42. package/dist/runtime/pipeline.js +2 -1
  43. package/dist/runtime/principals.d.ts +5 -0
  44. package/dist/runtime/principals.js +21 -0
  45. package/dist/runtime/service-binding.js +10 -0
  46. package/dist/runtime/sql-runtime.js +2 -1
  47. package/dist/service/api.js +4 -10
  48. package/dist/service/authentication.d.ts +27 -1
  49. package/dist/service/authentication.js +48 -26
  50. package/dist/service/config.d.ts +5 -0
  51. package/dist/service/descriptions.d.ts +8 -0
  52. package/dist/service/descriptions.js +15 -0
  53. package/dist/service/errors.d.ts +5 -1
  54. package/dist/service/errors.js +6 -2
  55. package/dist/service/handlers.d.ts +3 -5
  56. package/dist/service/handlers.js +21 -204
  57. package/dist/service/mcp/server.d.ts +26 -0
  58. package/dist/service/mcp/server.js +370 -0
  59. package/dist/service/mcp/tools.d.ts +47 -0
  60. package/dist/service/mcp/tools.js +218 -0
  61. package/dist/service/oauth/protocol.d.ts +52 -0
  62. package/dist/service/oauth/protocol.js +110 -0
  63. package/dist/service/oauth/server.d.ts +27 -0
  64. package/dist/service/oauth/server.js +576 -0
  65. package/dist/service/operations.d.ts +115 -0
  66. package/dist/service/operations.js +228 -0
  67. package/dist/service/replication.d.ts +5 -0
  68. package/dist/service/replication.js +25 -0
  69. package/dist/service/runtime-contract.d.ts +90 -0
  70. package/dist/service/schemas.d.ts +8 -0
  71. package/dist/service/service.d.ts +3 -3
  72. package/dist/service/service.js +13 -6
  73. package/package.json +6 -1
@@ -151,6 +151,18 @@ export declare const USER_ROLES_TABLE = "_user_roles";
151
151
  * secret only as a salted PBKDF2 hash.
152
152
  */
153
153
  export declare const API_KEYS_TABLE = "_api_keys";
154
+ /** OAuth clients that registered themselves to connect agents to the MCP server. */
155
+ export declare const OAUTH_CLIENTS_TABLE = "_oauth_clients";
156
+ /** Authorization requests waiting for a member's consent, for a few minutes. */
157
+ export declare const OAUTH_REQUESTS_TABLE = "_oauth_requests";
158
+ /** What a member allowed a client to do: revoking a grant stops its tokens. */
159
+ export declare const OAUTH_GRANTS_TABLE = "_oauth_grants";
160
+ /** Authorization codes, access tokens and refresh tokens, stored only as SHA-256 hashes. */
161
+ export declare const OAUTH_TOKENS_TABLE = "_oauth_tokens";
162
+ /** The OAuth tables, in the order they are created. */
163
+ export declare const OAUTH_TABLES: ReadonlyArray<string>;
164
+ /** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
165
+ export declare const OAUTH_TABLES_DDL: ReadonlyArray<string>;
154
166
  /**
155
167
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
156
168
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -433,6 +433,87 @@ export const USER_ROLES_TABLE = "_user_roles";
433
433
  * secret only as a salted PBKDF2 hash.
434
434
  */
435
435
  export const API_KEYS_TABLE = "_api_keys";
436
+ /** OAuth clients that registered themselves to connect agents to the MCP server. */
437
+ export const OAUTH_CLIENTS_TABLE = "_oauth_clients";
438
+ /** Authorization requests waiting for a member's consent, for a few minutes. */
439
+ export const OAUTH_REQUESTS_TABLE = "_oauth_requests";
440
+ /** What a member allowed a client to do: revoking a grant stops its tokens. */
441
+ export const OAUTH_GRANTS_TABLE = "_oauth_grants";
442
+ /** Authorization codes, access tokens and refresh tokens, stored only as SHA-256 hashes. */
443
+ export const OAUTH_TOKENS_TABLE = "_oauth_tokens";
444
+ /** The OAuth tables, in the order they are created. */
445
+ export const OAUTH_TABLES = [
446
+ OAUTH_CLIENTS_TABLE,
447
+ OAUTH_REQUESTS_TABLE,
448
+ OAUTH_GRANTS_TABLE,
449
+ OAUTH_TOKENS_TABLE,
450
+ ];
451
+ /** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
452
+ export const OAUTH_TABLES_DDL = [
453
+ [
454
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} (`,
455
+ ` "id" text NOT NULL,`,
456
+ ` "name" text NOT NULL,`,
457
+ ` "redirect_uris" jsonb NOT NULL,`,
458
+ ` "auth_method" text NOT NULL,`,
459
+ ` "secret_hash" text,`,
460
+ ` "client_uri" text,`,
461
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
462
+ ` CONSTRAINT "${OAUTH_CLIENTS_TABLE}_pkey" PRIMARY KEY ("id")`,
463
+ `)`,
464
+ ].join("\n"),
465
+ [
466
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_REQUESTS_TABLE)} (`,
467
+ ` "id" text NOT NULL,`,
468
+ ` "client_id" text NOT NULL,`,
469
+ ` "redirect_uri" text NOT NULL,`,
470
+ ` "state" text,`,
471
+ ` "code_challenge" text NOT NULL,`,
472
+ ` "scope" text NOT NULL,`,
473
+ ` "resource" text NOT NULL,`,
474
+ ` "expires_at" timestamptz NOT NULL,`,
475
+ ` CONSTRAINT "${OAUTH_REQUESTS_TABLE}_pkey" PRIMARY KEY ("id"),`,
476
+ ` CONSTRAINT "${OAUTH_REQUESTS_TABLE}_client_id_fkey" FOREIGN KEY ("client_id") ` +
477
+ `REFERENCES ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} ("id") ON DELETE CASCADE`,
478
+ `)`,
479
+ ].join("\n"),
480
+ [
481
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_GRANTS_TABLE)} (`,
482
+ ` "id" uuid NOT NULL,`,
483
+ ` "client_id" text NOT NULL,`,
484
+ ` "user_id" uuid NOT NULL,`,
485
+ ` "scope" text NOT NULL,`,
486
+ ` "resource" text NOT NULL,`,
487
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
488
+ ` "revoked_at" timestamptz,`,
489
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_pkey" PRIMARY KEY ("id"),`,
490
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_client_id_fkey" FOREIGN KEY ("client_id") ` +
491
+ `REFERENCES ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} ("id") ON DELETE CASCADE,`,
492
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_user_id_fkey" FOREIGN KEY ("user_id") ` +
493
+ `REFERENCES ${quoteIdentifier(USER_TABLE)} ("id") ON DELETE CASCADE`,
494
+ `)`,
495
+ ].join("\n"),
496
+ `CREATE INDEX IF NOT EXISTS "${OAUTH_GRANTS_TABLE}_user_id_idx" ` +
497
+ `ON ${quoteIdentifier(OAUTH_GRANTS_TABLE)} ("user_id")`,
498
+ [
499
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_TOKENS_TABLE)} (`,
500
+ ` "hash" text NOT NULL,`,
501
+ ` "grant_id" uuid NOT NULL,`,
502
+ ` "kind" text NOT NULL,`,
503
+ ` "redirect_uri" text,`,
504
+ ` "code_challenge" text,`,
505
+ ` "expires_at" timestamptz NOT NULL,`,
506
+ ` "used_at" timestamptz,`,
507
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
508
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_pkey" PRIMARY KEY ("hash"),`,
509
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_kind_check" CHECK ("kind" IN ('code', 'access', 'refresh')),`,
510
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_grant_id_fkey" FOREIGN KEY ("grant_id") ` +
511
+ `REFERENCES ${quoteIdentifier(OAUTH_GRANTS_TABLE)} ("id") ON DELETE CASCADE`,
512
+ `)`,
513
+ ].join("\n"),
514
+ `CREATE INDEX IF NOT EXISTS "${OAUTH_TOKENS_TABLE}_grant_id_idx" ` +
515
+ `ON ${quoteIdentifier(OAUTH_TOKENS_TABLE)} ("grant_id")`,
516
+ ];
436
517
  /**
437
518
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
438
519
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -464,6 +545,7 @@ export const RUNTIME_TABLES_DDL = [
464
545
  `)`,
465
546
  ].join("\n"),
466
547
  `CREATE INDEX IF NOT EXISTS "${API_KEYS_TABLE}_user_id_idx" ON ${quoteIdentifier(API_KEYS_TABLE)} ("user_id")`,
548
+ ...OAUTH_TABLES_DDL,
467
549
  ];
468
550
  export function columnDefinition(column) {
469
551
  const parts = [quoteIdentifier(column.name), column.type];
package/dist/names.d.ts CHANGED
@@ -21,7 +21,15 @@ export declare const HISTORY_SUFFIX = "_history";
21
21
  /** The combinators of the query language, which take the place of field names in a `where`. */
22
22
  export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
23
23
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
24
- export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me"];
24
+ export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
25
+ /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
26
+ export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
27
+ /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
28
+ export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me"];
29
+ /** The longest tool name that Claude and OpenAI accept. */
30
+ export declare const MCP_TOOL_NAME_MAX_LENGTH = 64;
31
+ /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
32
+ export declare function mcpToolName(entity: string, operation: string): string;
25
33
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
26
34
  export declare const RESERVED_RELATIONSHIP_NAMES: readonly ["history"];
27
35
  export declare function isSystemProperty(name: string): boolean;
package/dist/names.js CHANGED
@@ -57,7 +57,18 @@ export const RESERVED_ENTITY_NAMES = [
57
57
  "docs",
58
58
  "mcp",
59
59
  "me",
60
+ "oauth",
60
61
  ];
62
+ /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
63
+ export const MCP_READ_TOOLS = ["find", "get", "history"];
64
+ /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
65
+ export const MCP_GLOBAL_TOOLS = ["describe", "me"];
66
+ /** The longest tool name that Claude and OpenAI accept. */
67
+ export const MCP_TOOL_NAME_MAX_LENGTH = 64;
68
+ /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
69
+ export function mcpToolName(entity, operation) {
70
+ return `${entity}_${operation}`;
71
+ }
61
72
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
62
73
  export const RESERVED_RELATIONSHIP_NAMES = ["history"];
63
74
  export function isSystemProperty(name) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The paths the ontology service answers, for the workspace's `ontology/nitro.config.ts`. They come
3
+ * from the SDK, so a path the service adds arrives with an upgrade and the config stays as it is.
4
+ * This entry imports nothing: Nitro loads it while it builds.
5
+ */
6
+ export declare const ONTOLOGY_SERVICE_PATHS: readonly ["/api/ontology", "/api/ontology/**", "/mcp", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-authorization-server"];
7
+ /** A Nitro route to the service's handler, loaded on its first request. */
8
+ export interface OntologyRoute {
9
+ readonly handler: string;
10
+ readonly lazy: true;
11
+ }
12
+ /** The Nitro routes that send every path of the ontology service to one handler. */
13
+ export declare function ontologyRoutes(handler: string): Record<string, OntologyRoute>;
package/dist/nitro.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The paths the ontology service answers, for the workspace's `ontology/nitro.config.ts`. They come
3
+ * from the SDK, so a path the service adds arrives with an upgrade and the config stays as it is.
4
+ * This entry imports nothing: Nitro loads it while it builds.
5
+ */
6
+ export const ONTOLOGY_SERVICE_PATHS = [
7
+ "/api/ontology",
8
+ "/api/ontology/**",
9
+ "/mcp",
10
+ // The MCP server's OAuth metadata, on the app's origin.
11
+ "/.well-known/oauth-protected-resource",
12
+ "/.well-known/oauth-protected-resource/mcp",
13
+ "/.well-known/oauth-authorization-server",
14
+ ];
15
+ /** The Nitro routes that send every path of the ontology service to one handler. */
16
+ export function ontologyRoutes(handler) {
17
+ return Object.fromEntries(ONTOLOGY_SERVICE_PATHS.map((path) => [path, { handler, lazy: true }]));
18
+ }
@@ -0,0 +1,19 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import type { OntologyManifest } from "../manifest.js";
4
+ import { type ReplicationSelection, type ReplicationExclusion } from "./model.js";
5
+ export interface ReplicationTarget {
6
+ readonly generation: string;
7
+ readonly source_id: string;
8
+ readonly from_revision: string;
9
+ readonly manifest: OntologyManifest;
10
+ readonly selection: ReadonlyArray<ReplicationSelection>;
11
+ }
12
+ /** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
13
+ export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: {
14
+ readonly generation: string;
15
+ readonly manifest: OntologyManifest;
16
+ readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
17
+ readonly selection: ReadonlyArray<ReplicationSelection>;
18
+ readonly newSource?: boolean;
19
+ }) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import { joinColumnNames, joinTableName, quoteIdentifier as qi, quoteLiteral as ql, tableName, } from "../migrate/storage.js";
4
+ import { REPLICATION_RECORD_BYTES, validateReplicationSelection, } from "./model.js";
5
+ import { snapshotExpression } from "./schema.js";
6
+ const MAX_BASELINE_RECORDS = 50_000;
7
+ /** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
8
+ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Effect.gen(function* () {
9
+ yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
10
+ yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
11
+ const [state] = yield* sql.unsafe("SELECT source_id::text, revision::text, enabled FROM _ontology_replication WHERE id FOR UPDATE");
12
+ if (!state) {
13
+ return yield* Effect.die(new Error("Run the ontology SDK migration before enabling replication."));
14
+ }
15
+ const existing = yield* sql.unsafe("SELECT * FROM _ontology_replication_targets WHERE generation = $1::uuid", [input.generation]);
16
+ if (existing[0]) {
17
+ return existing[0];
18
+ }
19
+ if (state.enabled) {
20
+ return yield* Effect.die(new Error("Pause replication before starting a new generation."));
21
+ }
22
+ const manifest = input.manifest;
23
+ yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
24
+ // Drain older SDK transactions that started before the compatibility fence was enabled.
25
+ const tables = manifest.entities
26
+ .filter((e) => e.source === undefined && e.name !== "user")
27
+ .flatMap((e) => [
28
+ tableName(e.name),
29
+ ...e.relationships
30
+ .filter((r) => r.cardinality === "many")
31
+ .map((r) => joinTableName(e.name, r.name)),
32
+ ]);
33
+ if (tables.length > 0) {
34
+ yield* sql.unsafe(`LOCK TABLE ${tables.map(qi).join(", ")} IN SHARE MODE`);
35
+ }
36
+ const source = input.newSource ? randomUUID() : state.source_id;
37
+ const selection = Object.fromEntries(input.selection.map((s) => [s.entityId, s]));
38
+ yield* sql.unsafe(`UPDATE _ontology_replication SET source_id = $1::uuid, selection = $2::jsonb, enabled = true,
39
+ exclusions = $3::jsonb, active_generation = $4::uuid WHERE id`, [
40
+ source,
41
+ JSON.stringify(selection),
42
+ JSON.stringify(input.exclusions ?? []),
43
+ input.generation,
44
+ ]);
45
+ const target = {
46
+ generation: input.generation,
47
+ source_id: source,
48
+ from_revision: state.revision,
49
+ manifest,
50
+ selection: input.selection,
51
+ };
52
+ yield* sql.unsafe(`INSERT INTO _ontology_replication_targets (generation, source_id, from_revision, manifest, selection)
53
+ VALUES ($1::uuid, $2::uuid, $3::bigint, $4::jsonb, $5::jsonb)`, [
54
+ target.generation,
55
+ source,
56
+ state.revision,
57
+ JSON.stringify(manifest),
58
+ JSON.stringify(input.selection),
59
+ ]);
60
+ let total = 0;
61
+ const baseline = (entityId, kind, records) => Effect.gen(function* () {
62
+ // Keep the snapshot in PostgreSQL; large baselines must not fill a serverless process's heap.
63
+ const [size] = yield* sql.unsafe(`SELECT count(*)::int AS count, max(octet_length(payload::text)) AS largest
64
+ FROM (${records} LIMIT ${MAX_BASELINE_RECORDS + 1}) snapshot`);
65
+ total += size.count;
66
+ if (total > MAX_BASELINE_RECORDS) {
67
+ return yield* Effect.die(new Error("The replication baseline exceeds 50,000 records; use a bulk bootstrap."));
68
+ }
69
+ if ((size.largest ?? 0) > REPLICATION_RECORD_BYTES) {
70
+ return yield* Effect.die(new Error("A projected baseline record exceeds the replication size limit."));
71
+ }
72
+ yield* sql.unsafe(`INSERT INTO _ontology_replication_baselines (generation, event)
73
+ SELECT $1::uuid, jsonb_build_object('source_id', $2::text, 'entity_id', $3::text,
74
+ 'record_key', record_key, 'record_kind', $4::text, 'revision', $5::text,
75
+ 'deleted', false, 'schema_hash', $6::text, 'payload', payload::text)
76
+ FROM (${records}) snapshot`, [target.generation, source, entityId, kind, state.revision, manifest.hash]);
77
+ });
78
+ for (const item of input.selection) {
79
+ const entity = manifest.entities.find((e) => e.id === item.entityId);
80
+ yield* baseline(entity.id, "entity", `SELECT r.id::text AS record_key,
81
+ (SELECT jsonb_object_agg(key, value) FROM jsonb_each(${snapshotExpression(entity, "r")})
82
+ WHERE key = 'id' OR ${ql(JSON.stringify(item.fields))}::jsonb ? key) AS payload
83
+ FROM ${qi(tableName(entity.name))} r`);
84
+ for (const relationship of entity.relationships.filter((r) => item.relationships?.includes(r.name))) {
85
+ const columns = joinColumnNames({
86
+ entity: entity.name,
87
+ relationship: relationship.name,
88
+ target: relationship.to,
89
+ });
90
+ yield* baseline(entity.id, `edge:${relationship.name}`, `SELECT ${qi(columns.source)}::text || ':' || ${qi(columns.target)}::text AS record_key,
91
+ jsonb_build_object('source_id', ${qi(columns.source)}::text,
92
+ 'target_id', ${qi(columns.target)}::text) AS payload
93
+ FROM ${qi(joinTableName(entity.name, relationship.name))}`);
94
+ }
95
+ }
96
+ return target;
97
+ }));
@@ -0,0 +1,23 @@
1
+ import type { ReplicationTarget } from "./store.js";
2
+ export declare const REPLICATION_TABLE_SCHEMA: readonly [...{
3
+ name: string;
4
+ type: string;
5
+ mode: string;
6
+ }[], {
7
+ readonly name: "revision";
8
+ readonly type: "NUMERIC";
9
+ readonly mode: "REQUIRED";
10
+ }, {
11
+ readonly name: "deleted";
12
+ readonly type: "BOOLEAN";
13
+ readonly mode: "REQUIRED";
14
+ }, {
15
+ readonly name: "payload";
16
+ readonly type: "JSON";
17
+ readonly mode: "REQUIRED";
18
+ }];
19
+ /** Rank first, then apply tombstones. Every view reads the whole retained event history. */
20
+ export declare function replicationViews(project: string, target: ReplicationTarget): {
21
+ readonly name: string;
22
+ readonly query: string;
23
+ }[];
@@ -0,0 +1,63 @@
1
+ import { queryFields } from "../query.js";
2
+ export const REPLICATION_TABLE_SCHEMA = [
3
+ ...[
4
+ "event_id",
5
+ "source_id",
6
+ "generation",
7
+ "entity_id",
8
+ "record_key",
9
+ "record_kind",
10
+ "schema_hash",
11
+ ].map((name) => ({ name, type: "STRING", mode: "REQUIRED" })),
12
+ { name: "revision", type: "NUMERIC", mode: "REQUIRED" },
13
+ { name: "deleted", type: "BOOLEAN", mode: "REQUIRED" },
14
+ { name: "payload", type: "JSON", mode: "REQUIRED" },
15
+ ];
16
+ const literal = (value) => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
17
+ const identifier = (value) => {
18
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
19
+ throw new Error("Invalid BigQuery replication identifier.");
20
+ }
21
+ return `\`${value}\``;
22
+ };
23
+ /** Rank first, then apply tombstones. Every view reads the whole retained event history. */
24
+ export function replicationViews(project, target) {
25
+ identifier(project);
26
+ const result = [];
27
+ const latest = (entity, kind, columns) => `SELECT ${columns} FROM (
28
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY record_key ORDER BY revision DESC, event_id DESC) AS position
29
+ FROM \`${project}.ontology_internal.events\`
30
+ WHERE generation = ${literal(target.generation)} AND source_id = ${literal(target.source_id)}
31
+ AND entity_id = ${literal(entity)} AND record_kind = ${literal(kind)}
32
+ ) WHERE position = 1 AND NOT deleted`;
33
+ for (const selection of target.selection) {
34
+ const entity = target.manifest.entities.find((e) => e.id === selection.entityId);
35
+ const columns = queryFields(entity)
36
+ .filter((field) => field.name === "id" || selection.fields.includes(field.name))
37
+ .map((field) => {
38
+ const path = literal(`$.${field.name}`);
39
+ const type = {
40
+ integer: "INT64",
41
+ number: "FLOAT64",
42
+ boolean: "BOOL",
43
+ date: "DATE",
44
+ timestamp: "TIMESTAMP",
45
+ }[field.storage];
46
+ const value = field.storage === "json"
47
+ ? `JSON_QUERY(payload, ${path})`
48
+ : `JSON_VALUE(payload, ${path})`;
49
+ return `${type ? `SAFE_CAST(${value} AS ${type})` : value} AS ${identifier(field.name)}`;
50
+ });
51
+ result.push({ name: entity.name, query: latest(entity.id, "entity", columns.join(", ")) });
52
+ for (const relationship of selection.relationships ?? []) {
53
+ result.push({
54
+ name: `${entity.name}_${relationship}`,
55
+ query: latest(entity.id, `edge:${relationship}`, "JSON_VALUE(payload, '$.source_id') AS source_id, JSON_VALUE(payload, '$.target_id') AS target_id"),
56
+ });
57
+ }
58
+ }
59
+ if (new Set(result.map((v) => v.name.toLowerCase())).size !== result.length) {
60
+ throw new Error("Replicated entity and relationship view names collide.");
61
+ }
62
+ return result;
63
+ }
@@ -0,0 +1,38 @@
1
+ import type { OntologyManifest } from "../manifest.js";
2
+ /** The resolved projection for one immutable generation. `id` is always included. */
3
+ export interface ReplicationSelection {
4
+ readonly entityId: string;
5
+ readonly fields: ReadonlyArray<string>;
6
+ readonly relationships?: ReadonlyArray<string>;
7
+ }
8
+ /** Everything is replicated unless explicitly excluded. Internal identity data is never exported. */
9
+ export interface ReplicationExclusion {
10
+ readonly entityId: string;
11
+ readonly disabled?: boolean;
12
+ readonly fields?: ReadonlyArray<string>;
13
+ readonly relationships?: ReadonlyArray<string>;
14
+ }
15
+ export declare function resolveReplicationSelection(manifest: OntologyManifest, exclusions?: ReadonlyArray<ReplicationExclusion>): ReadonlyArray<ReplicationSelection>;
16
+ export interface ReplicationEvent {
17
+ readonly event_id: string;
18
+ readonly source_id: string;
19
+ readonly generation: string;
20
+ readonly entity_id: string;
21
+ readonly record_key: string;
22
+ readonly record_kind: string;
23
+ /** Decimal strings preserve PostgreSQL bigint precision in JavaScript. */
24
+ readonly revision: string;
25
+ readonly deleted: boolean;
26
+ readonly schema_hash: string;
27
+ /** Pub/Sub's BigQuery table-schema mode expects a string for a JSON column. */
28
+ readonly payload: string;
29
+ }
30
+ export declare const REPLICATION_BATCH_SIZE = 100;
31
+ export declare const REPLICATION_BATCH_BYTES = 1000000;
32
+ export declare const REPLICATION_RECORD_BYTES = 200000;
33
+ export declare function validateReplicationSelection(manifest: OntologyManifest, selection: ReadonlyArray<ReplicationSelection>): void;
34
+ export declare function replicationEventId(source: string, history: string, generation: string): string;
35
+ /** Includes base64 and JSON overhead, not just the original payload bytes. */
36
+ export declare function publishMessage(event: ReplicationEvent): {
37
+ data: string;
38
+ };
@@ -0,0 +1,89 @@
1
+ import { createHash } from "node:crypto";
2
+ import { queryFields } from "../query.js";
3
+ export function resolveReplicationSelection(manifest, exclusions = []) {
4
+ const entities = manifest.entities.filter((entity) => !entity.builtIn && !entity.source);
5
+ if (new Set(exclusions.map((item) => item.entityId)).size !== exclusions.length) {
6
+ throw new Error("Replication exclusions must name distinct entities.");
7
+ }
8
+ for (const item of exclusions) {
9
+ const entity = entities.find((candidate) => candidate.id === item.entityId);
10
+ if (!entity) {
11
+ throw new Error(`Unknown stored business entity in replication exclusions: ${item.entityId}.`);
12
+ }
13
+ const fields = new Set(queryFields(entity).map((field) => field.name));
14
+ const relationships = new Set(entity.relationships.map((relationship) => relationship.name));
15
+ if (item.fields?.some((field) => field === "id" || !fields.has(field))) {
16
+ throw new Error(`Unknown or required replication field on ${entity.name}.`);
17
+ }
18
+ if (item.relationships?.some((relationship) => !relationships.has(relationship))) {
19
+ throw new Error(`Unknown replication relationship on ${entity.name}.`);
20
+ }
21
+ }
22
+ const included = entities.filter((entity) => !exclusions.some((item) => item.entityId === entity.id && item.disabled));
23
+ const selection = included.map((entity) => {
24
+ const excluded = exclusions.find((item) => item.entityId === entity.id);
25
+ const excludedFields = new Set([
26
+ ...(excluded?.fields ?? []),
27
+ ...(excluded?.relationships ?? []).map((relationship) => `${relationship}_id`),
28
+ ]);
29
+ return {
30
+ entityId: entity.id,
31
+ fields: queryFields(entity)
32
+ .filter((field) => !excludedFields.has(field.name))
33
+ .map((field) => field.name),
34
+ relationships: entity.relationships
35
+ .filter((relationship) => relationship.cardinality === "many" &&
36
+ !excluded?.relationships?.includes(relationship.name) &&
37
+ included.some((target) => target.name === relationship.to))
38
+ .map((relationship) => relationship.name),
39
+ };
40
+ });
41
+ validateReplicationSelection(manifest, selection);
42
+ return selection;
43
+ }
44
+ export const REPLICATION_BATCH_SIZE = 100;
45
+ export const REPLICATION_BATCH_BYTES = 1_000_000;
46
+ // Leaves room for JSON escaping, the envelope and base64 inside a 1 MB publish request.
47
+ export const REPLICATION_RECORD_BYTES = 200_000;
48
+ export function validateReplicationSelection(manifest, selection) {
49
+ if (new Set(selection.map((s) => s.entityId)).size !== selection.length) {
50
+ throw new Error("Replicated entities must be distinct.");
51
+ }
52
+ const viewNames = new Set();
53
+ const addView = (name) => {
54
+ const key = name.toLowerCase();
55
+ if (viewNames.has(key)) {
56
+ throw new Error("Replicated entity and relationship view names collide.");
57
+ }
58
+ viewNames.add(key);
59
+ };
60
+ for (const item of selection) {
61
+ const entity = manifest.entities.find((e) => e.id === item.entityId);
62
+ if (!entity || entity.name === "user" || entity.source !== undefined) {
63
+ throw new Error(`Only stored business entities can be replicated: ${item.entityId}.`);
64
+ }
65
+ addView(entity.name);
66
+ const fields = new Set(queryFields(entity).map((f) => f.name));
67
+ if (item.fields.some((field) => !fields.has(field))) {
68
+ throw new Error(`Unknown replication field on ${entity.name}.`);
69
+ }
70
+ for (const name of item.relationships ?? []) {
71
+ const relationship = entity.relationships.find((r) => r.name === name);
72
+ const target = manifest.entities.find((e) => e.name === relationship?.to);
73
+ if (relationship?.cardinality !== "many" ||
74
+ !target ||
75
+ target.name === "user" ||
76
+ !selection.some((s) => s.entityId === target.id)) {
77
+ throw new Error(`Replication of ${entity.name}.${name} requires both business entities.`);
78
+ }
79
+ addView(`${entity.name}_${name}`);
80
+ }
81
+ }
82
+ }
83
+ export function replicationEventId(source, history, generation) {
84
+ return createHash("sha256").update(`${source}:${history}:${generation}`).digest("hex");
85
+ }
86
+ /** Includes base64 and JSON overhead, not just the original payload bytes. */
87
+ export function publishMessage(event) {
88
+ return { data: Buffer.from(JSON.stringify(event)).toString("base64") };
89
+ }
@@ -0,0 +1,7 @@
1
+ import type { EntityManifest, OntologyManifest } from "../manifest.js";
2
+ export declare const REPLICATION_SCHEMA_VERSION = 3;
3
+ /** Installed only by the SDK migration runner, never at service startup. */
4
+ export declare const REPLICATION_DDL: string[];
5
+ export declare function snapshotExpression(entity: EntityManifest, alias: string): string;
6
+ /** History is the durable source. Triggers also reject old writers while capture is enabled. */
7
+ export declare function replicationEntityDdl(manifest: OntologyManifest): ReadonlyArray<string>;
@@ -0,0 +1,123 @@
1
+ import { createHash } from "node:crypto";
2
+ import { queryFields } from "../query.js";
3
+ import { columnName, historyTableName, quoteIdentifier as qi, quoteLiteral as ql, relationshipColumnName, SYSTEM_COLUMNS, tableName, joinTableName, } from "../migrate/storage.js";
4
+ import { REPLICATION_RECORD_BYTES } from "./model.js";
5
+ export const REPLICATION_SCHEMA_VERSION = 3;
6
+ /** Installed only by the SDK migration runner, never at service startup. */
7
+ export const REPLICATION_DDL = [
8
+ `CREATE TABLE IF NOT EXISTS _ontology_replication (
9
+ id boolean PRIMARY KEY DEFAULT true CHECK (id), version integer NOT NULL,
10
+ source_id uuid NOT NULL DEFAULT gen_random_uuid(), revision bigint NOT NULL DEFAULT 0,
11
+ selection jsonb NOT NULL DEFAULT '{}'::jsonb, enabled boolean NOT NULL DEFAULT false
12
+ )`,
13
+ `INSERT INTO _ontology_replication (id, version) VALUES (true, ${REPLICATION_SCHEMA_VERSION}) ON CONFLICT DO NOTHING`,
14
+ `ALTER TABLE _ontology_replication ADD COLUMN IF NOT EXISTS exclusions jsonb NOT NULL DEFAULT '[]'::jsonb`,
15
+ `ALTER TABLE _ontology_replication ADD COLUMN IF NOT EXISTS active_generation uuid`,
16
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_targets (
17
+ generation uuid PRIMARY KEY, source_id uuid NOT NULL, from_revision bigint NOT NULL,
18
+ manifest jsonb NOT NULL, selection jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
19
+ )`,
20
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_baselines (
21
+ generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
22
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event jsonb NOT NULL
23
+ )`,
24
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_receipts (
25
+ generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
26
+ event_id uuid NOT NULL, accepted_at timestamptz NOT NULL DEFAULT now(), verified_at timestamptz,
27
+ PRIMARY KEY (generation, event_id)
28
+ )`,
29
+ `CREATE OR REPLACE FUNCTION _ontology_replication_guard() RETURNS trigger LANGUAGE plpgsql AS $$
30
+ BEGIN
31
+ IF (SELECT enabled FROM _ontology_replication WHERE id) AND
32
+ current_setting('segmentstream.writer', true) IS DISTINCT FROM '1' THEN
33
+ RAISE EXCEPTION 'Replication requires a compatible ontology writer; pause replication before a data migration';
34
+ END IF;
35
+ RETURN NULL;
36
+ END $$`,
37
+ ];
38
+ export function snapshotExpression(entity, alias) {
39
+ const values = queryFields(entity).flatMap((field) => {
40
+ const system = SYSTEM_COLUMNS[field.name];
41
+ const column = system ??
42
+ (entity.properties.some((p) => p.name === field.name)
43
+ ? columnName(field.name)
44
+ : relationshipColumnName(field.name.slice(0, -3)));
45
+ // JSON numbers cannot safely carry a PostgreSQL bigint across all transports.
46
+ const cast = field.storage === "integer" ? "::text" : "";
47
+ return [ql(field.name), `${alias}.${qi(column)}${cast}`];
48
+ });
49
+ const chunks = [];
50
+ for (let offset = 0; offset < values.length; offset += 80) {
51
+ chunks.push(`jsonb_build_object(${values.slice(offset, offset + 80).join(", ")})`);
52
+ }
53
+ return `(${chunks.join(" || ")})`;
54
+ }
55
+ /** History is the durable source. Triggers also reject old writers while capture is enabled. */
56
+ export function replicationEntityDdl(manifest) {
57
+ return manifest.entities
58
+ .filter((e) => e.source === undefined && e.name !== "user")
59
+ .flatMap((entity) => {
60
+ const history = qi(historyTableName(entity.name));
61
+ const table = qi(tableName(entity.name));
62
+ // Keep names stable across renames and below PostgreSQL's 63-byte identifier limit.
63
+ const key = createHash("sha256").update(entity.id).digest("hex").slice(0, 40);
64
+ const fn = qi(`_replicate_${key}`);
65
+ const declared = entity.relationships
66
+ .filter((r) => r.cardinality === "many")
67
+ .map((r) => r.name);
68
+ return [
69
+ `ALTER TABLE ${history} ADD COLUMN IF NOT EXISTS replication jsonb`,
70
+ `CREATE INDEX IF NOT EXISTS ${qi(`_replication_${key}_idx`)} ON ${history}
71
+ (((replication->>'revision')::bigint)) WHERE replication IS NOT NULL`,
72
+ `CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$
73
+ DECLARE state _ontology_replication; policy jsonb; snapshot jsonb; relation text; other_id text;
74
+ record_key text; record_kind text := 'entity'; is_deleted boolean := NEW.kind = 'delete';
75
+ BEGIN
76
+ SELECT * INTO state FROM _ontology_replication WHERE id;
77
+ policy := state.selection->${ql(entity.id)};
78
+ IF NOT state.enabled OR policy IS NULL THEN RETURN NEW; END IF;
79
+ record_key := NEW.record_id::text;
80
+ IF NEW.kind IN ('link', 'unlink') THEN
81
+ SELECT key INTO relation FROM jsonb_object_keys(NEW.changes) AS key LIMIT 1;
82
+ IF NOT (${ql(JSON.stringify(declared))}::jsonb ? relation) OR
83
+ NOT (COALESCE(policy->'relationships', '[]'::jsonb) ? relation) THEN RETURN NEW; END IF;
84
+ other_id := COALESCE(NEW.changes->relation->>'after', NEW.changes->relation->>'before');
85
+ record_key := NEW.record_id::text || ':' || other_id;
86
+ record_kind := 'edge:' || relation;
87
+ is_deleted := NEW.kind = 'unlink';
88
+ snapshot := jsonb_build_object('source_id', NEW.record_id::text, 'target_id', other_id);
89
+ ELSIF is_deleted THEN snapshot := '{}'::jsonb;
90
+ ELSE
91
+ SELECT ${snapshotExpression(entity, "record")} INTO snapshot FROM ${table} AS record WHERE id = NEW.record_id;
92
+ SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb) INTO snapshot
93
+ FROM jsonb_each(snapshot) WHERE key = 'id' OR (policy->'fields' ? key);
94
+ IF snapshot IS NULL OR snapshot = '{}'::jsonb THEN RAISE EXCEPTION 'Replication snapshot is missing'; END IF;
95
+ END IF;
96
+ IF octet_length(snapshot::text) > ${REPLICATION_RECORD_BYTES} THEN
97
+ RAISE EXCEPTION 'Projected ontology record exceeds the replication size limit';
98
+ END IF;
99
+ UPDATE _ontology_replication SET revision = revision + 1 WHERE id RETURNING * INTO state;
100
+ NEW.replication := jsonb_build_object('source_id', state.source_id::text,
101
+ 'entity_id', ${ql(entity.id)}, 'record_key', record_key, 'record_kind', record_kind,
102
+ 'revision', state.revision::text, 'deleted', is_deleted,
103
+ 'schema_hash', ${ql(manifest.hash)}, 'payload', snapshot::text);
104
+ RETURN NEW;
105
+ END $$`,
106
+ `DROP TRIGGER IF EXISTS _ontology_capture ON ${history}`,
107
+ `CREATE TRIGGER _ontology_capture BEFORE INSERT ON ${history} FOR EACH ROW EXECUTE FUNCTION ${fn}()`,
108
+ `DROP TRIGGER IF EXISTS _ontology_writer_guard ON ${table}`,
109
+ `CREATE TRIGGER _ontology_writer_guard BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON ${table}
110
+ FOR EACH STATEMENT EXECUTE FUNCTION _ontology_replication_guard()`,
111
+ ...entity.relationships
112
+ .filter((r) => r.cardinality === "many")
113
+ .flatMap((r) => {
114
+ const join = qi(joinTableName(entity.name, r.name));
115
+ return [
116
+ `DROP TRIGGER IF EXISTS _ontology_writer_guard ON ${join}`,
117
+ `CREATE TRIGGER _ontology_writer_guard BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON ${join}
118
+ FOR EACH STATEMENT EXECUTE FUNCTION _ontology_replication_guard()`,
119
+ ];
120
+ }),
121
+ ];
122
+ });
123
+ }