@segmentstream-cloud/ontology 0.11.0 → 0.13.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 (71) hide show
  1. package/README.md +51 -15
  2. package/dist/aggregate-routes.d.ts +95 -0
  3. package/dist/aggregate-routes.js +332 -0
  4. package/dist/analyze.js +83 -16
  5. package/dist/entity.d.ts +31 -15
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/manifest.d.ts +57 -23
  9. package/dist/manifest.js +77 -30
  10. package/dist/measure.d.ts +11 -2
  11. package/dist/measure.js +14 -1
  12. package/dist/migrate/apply.d.ts +1 -1
  13. package/dist/migrate/apply.js +36 -3
  14. package/dist/migrate/plan.d.ts +1 -1
  15. package/dist/migrate/plan.js +38 -13
  16. package/dist/migrate/storage.d.ts +3 -2
  17. package/dist/migrate/storage.js +15 -5
  18. package/dist/names.d.ts +4 -2
  19. package/dist/names.js +4 -1
  20. package/dist/query.d.ts +5 -2
  21. package/dist/query.js +6 -2
  22. package/dist/relationship.d.ts +36 -5
  23. package/dist/relationship.js +5 -3
  24. package/dist/render.d.ts +47 -3
  25. package/dist/render.js +117 -11
  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 +99 -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/pipeline.js +2 -1
  39. package/dist/runtime/principals.js +6 -0
  40. package/dist/runtime/service-binding.js +8 -0
  41. package/dist/runtime/sql-runtime.js +2 -1
  42. package/dist/service/aggregate-platform.d.ts +60 -0
  43. package/dist/service/aggregate-platform.js +189 -0
  44. package/dist/service/aggregate.d.ts +42 -0
  45. package/dist/service/aggregate.js +145 -0
  46. package/dist/service/api.d.ts +5 -3
  47. package/dist/service/api.js +52 -6
  48. package/dist/service/catalog.d.ts +10 -1
  49. package/dist/service/catalog.js +12 -3
  50. package/dist/service/config.d.ts +13 -1
  51. package/dist/service/config.js +11 -1
  52. package/dist/service/errors.d.ts +32 -0
  53. package/dist/service/errors.js +102 -0
  54. package/dist/service/handlers.d.ts +3 -1
  55. package/dist/service/handlers.js +67 -29
  56. package/dist/service/mcp/server.d.ts +5 -0
  57. package/dist/service/mcp/server.js +61 -9
  58. package/dist/service/mcp/tools.d.ts +13 -2
  59. package/dist/service/mcp/tools.js +165 -6
  60. package/dist/service/operations.d.ts +2 -1
  61. package/dist/service/operations.js +16 -6
  62. package/dist/service/plan.js +7 -1
  63. package/dist/service/replication.d.ts +5 -0
  64. package/dist/service/replication.js +25 -0
  65. package/dist/service/runtime-contract.d.ts +1 -0
  66. package/dist/service/schemas.d.ts +99 -1
  67. package/dist/service/schemas.js +150 -2
  68. package/dist/service/service.js +10 -2
  69. package/dist/user.d.ts +1 -1
  70. package/dist/warehouse-backed.js +222 -121
  71. package/package.json +1 -1
package/dist/manifest.js CHANGED
@@ -10,9 +10,16 @@ import { DEFAULT_SIGN_IN_POLICY, isSignInPolicy, } from "./sign-in.js";
10
10
  * 2 replaced the actions' `allow` with `permission`, and added the roles' `permissions` and the
11
11
  * entities' `permissions`; those fields are optional when decoding, so version 1 manifests still read.
12
12
  * `signIn` came later within version 2 and is optional too, as are the actions' `connections` and the
13
- * top-level `connections`, which are written only when an action calls a connection.
13
+ * top-level `connections`, which are written only when an action calls a connection. Version 3 added
14
+ * warehouse sources and measures. Version 4 writes snake_case names on the wire. Version 5 lets
15
+ * relationships target warehouse-backed entities and warehouse-backed properties declare `unique`:
16
+ * a relationship with a warehouse-backed end carries `on` and omits `optional` and `onDelete`.
17
+ * Version 6 adds the `countDistinct` measure operator and lets stored entities declare `measures`,
18
+ * whose columns name the stored properties' replication view columns.
14
19
  */
15
- export const MANIFEST_VERSION = 4;
20
+ export const MANIFEST_VERSION = 6;
21
+ /** The first version whose documents use snake_case names on the wire. */
22
+ const WIRE_MANIFEST_VERSION = 4;
16
23
  /** The phrase used for a direction the author did not phrase. */
17
24
  export const DEFAULT_PHRASE = "has";
18
25
  export const SelectOptionManifest = Schema.Struct({
@@ -45,14 +52,21 @@ export const PropertyManifest = Schema.Struct({
45
52
  * from `to`, `inverse` and the second `phrase`.
46
53
  */
47
54
  export const RelationshipManifest = Schema.Struct({
55
+ /**
56
+ * The property pair of a value-matched relationship, one with a warehouse-backed end. Absent
57
+ * between stored entities, which use a `<name>_id` key instead.
58
+ */
48
59
  on: Schema.optionalKey(Schema.Struct({ property: Schema.String, targetProperty: Schema.String })),
49
60
  name: Schema.String,
50
61
  to: Schema.String,
51
62
  inverse: Schema.String,
52
63
  cardinality: Schema.Literals(["one", "many"]),
53
- /** `rel.one` only. */
64
+ /** `rel.one` between stored entities only. */
54
65
  optional: Schema.optionalKey(Schema.Boolean),
55
- /** `rel.one` only. */
66
+ /**
67
+ * `rel.one` between stored entities only. Before version 5, warehouse → stored relationships
68
+ * carried it too, as `restrict`, though nothing enforced it.
69
+ */
56
70
  onDelete: Schema.optionalKey(Schema.Literals(ON_DELETE)),
57
71
  /** `rel.many` only. */
58
72
  ordered: Schema.optionalKey(Schema.Boolean),
@@ -60,6 +74,14 @@ export const RelationshipManifest = Schema.Struct({
60
74
  phrase: Schema.Tuple([Schema.String, Schema.String]),
61
75
  description: Schema.String,
62
76
  });
77
+ /**
78
+ * Whether a relationship is matched by value: either end is warehouse-backed. A value-matched
79
+ * relationship always declares `on`; a stored → stored relationship never does. It has no
80
+ * `<name>_id` column, foreign key, index or traversal route.
81
+ */
82
+ export function isValueMatched(relationship) {
83
+ return relationship.on !== undefined;
84
+ }
63
85
  export const IndexManifest = Schema.Struct({
64
86
  properties: Schema.Array(Schema.String),
65
87
  unique: Schema.Boolean,
@@ -190,7 +212,7 @@ const EntityManifestWire = Schema.Struct({
190
212
  relationships: Schema.Array(RelationshipManifestWire),
191
213
  }).pipe(Schema.encodeKeys({ builtIn: "built_in", renamedFrom: "renamed_from" }));
192
214
  const RoleManifestWire = RoleManifest.pipe(Schema.encodeKeys({ builtIn: "built_in" }));
193
- /** The JSON contract written by version 4; decoded SDK models remain camelCase. */
215
+ /** The JSON contract written since version 4; decoded SDK models remain camelCase. */
194
216
  export const OntologyManifestWire = Schema.Struct({
195
217
  ...OntologyManifestModel.fields,
196
218
  entities: Schema.Array(EntityManifestWire),
@@ -210,7 +232,7 @@ export function encodeManifest(manifest) {
210
232
  if (original !== undefined) {
211
233
  return original;
212
234
  }
213
- return manifest.version < MANIFEST_VERSION
235
+ return manifest.version < WIRE_MANIFEST_VERSION
214
236
  ? manifest
215
237
  : Schema.encodeSync(OntologyManifestWire)(manifest);
216
238
  }
@@ -292,7 +314,13 @@ function propertyManifest({ name, definition }) {
292
314
  : {}),
293
315
  };
294
316
  }
295
- function relationshipManifest({ name, relationship, target, }) {
317
+ /**
318
+ * `stored` says whether both ends are stored: only then does a `rel.one` have a key column, and so
319
+ * an optionality and a delete rule.
320
+ */
321
+ function relationshipManifest(input) {
322
+ const { resolved, stored } = input;
323
+ const { name, relationship, target } = resolved;
296
324
  const common = {
297
325
  name,
298
326
  to: target ?? "",
@@ -306,8 +334,9 @@ function relationshipManifest({ name, relationship, target, }) {
306
334
  return {
307
335
  ...common,
308
336
  cardinality: "one",
309
- optional: relationship.optional,
310
- onDelete: relationship.onDelete,
337
+ ...(stored
338
+ ? { optional: relationship.optional, onDelete: relationship.onDelete ?? "restrict" }
339
+ : {}),
311
340
  ...(relationship.on === undefined
312
341
  ? {}
313
342
  : {
@@ -318,27 +347,39 @@ function relationshipManifest({ name, relationship, target, }) {
318
347
  }),
319
348
  };
320
349
  }
321
- function entityManifest(resolved) {
350
+ /**
351
+ * The measures of an entity. A warehouse-backed property's column is its `column`; a stored property
352
+ * has none, and its column in the replication view is its wire name.
353
+ */
354
+ function measuresManifest(resolved) {
355
+ const columns = new Map(resolved.properties.map((property) => [
356
+ property.name,
357
+ property.definition.column ?? property.name,
358
+ ]));
359
+ return Object.entries(resolved.entity.definition.measures ?? {}).map(([name, measure]) => ({
360
+ name,
361
+ label: measure.label ?? humanize(name),
362
+ description: measure.description ?? "",
363
+ unit: measure.unit ?? "number",
364
+ format: measure.format ??
365
+ (measure.unit === "currency"
366
+ ? "currency"
367
+ : measure.unit === "percent"
368
+ ? "percent"
369
+ : "decimal"),
370
+ expression: measureExpression(measure, columns),
371
+ }));
372
+ }
373
+ /** `warehouseBacked` names the ontology's warehouse-backed entities, which relationships may target. */
374
+ function entityManifest(input) {
375
+ const { resolved, warehouseBacked } = input;
322
376
  const { definition } = resolved.entity;
377
+ const storedOwner = definition.source === undefined;
378
+ const measures = measuresManifest(resolved);
323
379
  return {
324
- ...(definition.source === undefined
325
- ? {}
326
- : {
327
- source: definition.source,
328
- measures: Object.entries(definition.measures ?? {}).map(([name, measure]) => ({
329
- name,
330
- label: measure.label ?? humanize(name),
331
- description: measure.description ?? "",
332
- unit: measure.unit ?? "number",
333
- format: measure.format ??
334
- (measure.unit === "currency"
335
- ? "currency"
336
- : measure.unit === "percent"
337
- ? "percent"
338
- : "decimal"),
339
- expression: measureExpression(measure, new Map(resolved.properties.map((p) => [p.name, p.definition.column]))),
340
- })),
341
- }),
380
+ // A warehouse-backed entity always lists its measures; a stored entity only when it has some.
381
+ ...(definition.source === undefined ? {} : { source: definition.source, measures }),
382
+ ...(definition.source === undefined && measures.length > 0 ? { measures } : {}),
342
383
  id: definition.id,
343
384
  name: resolved.name,
344
385
  label: definition.label,
@@ -349,7 +390,10 @@ function entityManifest(resolved) {
349
390
  ...(definition.renamedFrom !== undefined ? { renamedFrom: definition.renamedFrom } : {}),
350
391
  ...(definition.guide !== undefined ? { guide: definition.guide } : {}),
351
392
  properties: resolved.properties.map(propertyManifest),
352
- relationships: resolved.relationships.map(relationshipManifest),
393
+ relationships: resolved.relationships.map((relationship) => relationshipManifest({
394
+ resolved: relationship,
395
+ stored: storedOwner && !warehouseBacked.has(relationship.target ?? ""),
396
+ })),
353
397
  indexes: resolved.indexes.map((index) => ({
354
398
  properties: [...index.properties],
355
399
  unique: index.unique,
@@ -368,9 +412,12 @@ function entityManifest(resolved) {
368
412
  /** Builds the manifest of a resolved ontology that passed `check`. */
369
413
  export function buildManifest(input) {
370
414
  const connections = input.connections ?? [];
415
+ const warehouseBacked = new Set(input.entities
416
+ .filter((candidate) => candidate.entity.definition.source !== undefined)
417
+ .map((candidate) => candidate.name));
371
418
  const body = {
372
419
  version: MANIFEST_VERSION,
373
- entities: input.entities.map(entityManifest),
420
+ entities: input.entities.map((candidate) => entityManifest({ resolved: candidate, warehouseBacked })),
374
421
  roles: input.roles.map((candidate) => ({
375
422
  name: candidate.name,
376
423
  description: candidate.description,
package/dist/measure.d.ts CHANGED
@@ -7,7 +7,7 @@ export type MeasureExpression = {
7
7
  readonly op: "literal";
8
8
  readonly value: string;
9
9
  } | {
10
- readonly op: "sum" | "add" | "subtract" | "multiply" | "divide";
10
+ readonly op: "sum" | "countDistinct" | "add" | "subtract" | "multiply" | "divide";
11
11
  readonly args: ReadonlyArray<MeasureExpression>;
12
12
  };
13
13
  export interface MeasureOptions {
@@ -22,6 +22,11 @@ export type Measures = Readonly<Record<string, Measure>>;
22
22
  export declare const m: {
23
23
  readonly count: (options?: MeasureOptions) => Measure;
24
24
  readonly sum: (property: string, options?: MeasureOptions) => Measure;
25
+ /**
26
+ * The number of distinct non-null values of a text, email, url, select, integer, date or timestamp
27
+ * property. Distinct counts do not add up across groups.
28
+ */
29
+ readonly countDistinct: (property: string, options?: MeasureOptions) => Measure;
25
30
  readonly ref: (name: string, options?: MeasureOptions) => Measure;
26
31
  readonly literal: (value: number | string, options?: MeasureOptions) => Measure;
27
32
  readonly add: (left: MeasureExpression, right: MeasureExpression, options?: MeasureOptions) => Measure;
@@ -29,5 +34,9 @@ export declare const m: {
29
34
  readonly multiply: (left: MeasureExpression, right: MeasureExpression, options?: MeasureOptions) => Measure;
30
35
  readonly divide: (left: MeasureExpression, right: MeasureExpression, options?: MeasureOptions) => Measure;
31
36
  };
32
- /** Strip authoring options and resolve logical properties to physical columns for the manifest. */
37
+ /**
38
+ * Strip authoring options and resolve logical properties to physical columns for the manifest: a
39
+ * warehouse-backed property's `column`, or a stored property's replication view column, which is its
40
+ * wire name.
41
+ */
33
42
  export declare function measureExpression(value: MeasureExpression, columns: ReadonlyMap<string, string>): MeasureExpression;
package/dist/measure.js CHANGED
@@ -9,6 +9,15 @@ export const m = {
9
9
  args: [{ op: "column", name: property }],
10
10
  ...options,
11
11
  }),
12
+ /**
13
+ * The number of distinct non-null values of a text, email, url, select, integer, date or timestamp
14
+ * property. Distinct counts do not add up across groups.
15
+ */
16
+ countDistinct: (property, options) => ({
17
+ op: "countDistinct",
18
+ args: [{ op: "column", name: property }],
19
+ ...options,
20
+ }),
12
21
  ref: (name, options) => ({ op: "measure", name, ...options }),
13
22
  literal: (value, options) => ({
14
23
  op: "literal",
@@ -20,7 +29,11 @@ export const m = {
20
29
  multiply: binary("multiply"),
21
30
  divide: binary("divide"),
22
31
  };
23
- /** Strip authoring options and resolve logical properties to physical columns for the manifest. */
32
+ /**
33
+ * Strip authoring options and resolve logical properties to physical columns for the manifest: a
34
+ * warehouse-backed property's `column`, or a stored property's replication view column, which is its
35
+ * wire name.
36
+ */
24
37
  export function measureExpression(value, columns) {
25
38
  switch (value.op) {
26
39
  case "count":
@@ -46,7 +46,7 @@ export declare const readCatalog: (input: CatalogInput) => Effect.Effect<Ontolog
46
46
  * then runs in one transaction with `statement_timeout` and `lock_timeout` set, which also verifies
47
47
  * that the catalog still records the manifest the plan starts from and finally records the target. A
48
48
  * failure rolls the transaction back and leaves the applied version in place. A no-op plan writes
49
- * nothing.
49
+ * nothing when the runtime schema is also current.
50
50
  */
51
51
  export declare const applyMigration: (plan: MigrationPlan, options: ApplyMigrationOptions) => Effect.Effect<MigrationResult, ApplyMigrationError>;
52
52
  /**
@@ -1,8 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Duration, Effect, Schema } from "effect";
2
3
  import { canonicalJson, decodeManifestDocument, encodeManifest, manifestHash, } from "../manifest.js";
3
4
  import { CatalogInvalid, CatalogMismatch, MigrationFailed, MigrationRefused } from "./errors.js";
4
5
  import { isNoopMigration } from "./model.js";
5
6
  import { planMigration } from "./plan.js";
7
+ import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
8
+ import { createReplicationBaseline } from "../replication/baseline.js";
9
+ import { resolveReplicationSelection } from "../replication/model.js";
6
10
  import { API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
7
11
  const CATALOG = quoteIdentifier(CATALOG_TABLE);
8
12
  /** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
@@ -79,7 +83,7 @@ const runStep = (sql, step, stepIndex) => Effect.forEach(step.sql, (statement) =
79
83
  * then runs in one transaction with `statement_timeout` and `lock_timeout` set, which also verifies
80
84
  * that the catalog still records the manifest the plan starts from and finally records the target. A
81
85
  * failure rolls the transaction back and leaves the applied version in place. A no-op plan writes
82
- * nothing.
86
+ * nothing when the runtime schema is also current.
83
87
  */
84
88
  export const applyMigration = (plan, options) => Effect.gen(function* () {
85
89
  const { sql } = options;
@@ -89,8 +93,12 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
89
93
  refusals: plan.refusals,
90
94
  });
91
95
  }
92
- if (isNoopMigration(plan)) {
93
- return { hash: plan.target.hash, appliedSteps: 0, changed: false };
96
+ const [presence] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
97
+ if (presence?.present) {
98
+ const [state] = yield* sql.unsafe("SELECT version, enabled FROM _ontology_replication WHERE id");
99
+ if (isNoopMigration(plan) && state?.version === REPLICATION_SCHEMA_VERSION) {
100
+ return { hash: plan.target.hash, appliedSteps: 0, changed: false };
101
+ }
94
102
  }
95
103
  const statementTimeout = Duration.toMillis(options.statementTimeout ?? DEFAULT_STATEMENT_TIMEOUT);
96
104
  const lockTimeout = Duration.toMillis(options.lockTimeout ?? DEFAULT_LOCK_TIMEOUT);
@@ -109,11 +117,26 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
109
117
  MIGRATION_LOCK,
110
118
  ]);
111
119
  yield* verifyCatalog(sql, plan, { forUpdate: true });
120
+ let refresh;
121
+ if (presence?.present) {
122
+ const [state] = yield* sql.unsafe("SELECT * FROM _ontology_replication WHERE id FOR UPDATE");
123
+ if (state?.enabled) {
124
+ refresh = state.exclusions ?? [];
125
+ // Writers wait on this row. Capture, schema changes and the new baseline commit together.
126
+ yield* sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
127
+ }
128
+ }
112
129
  for (const [stepIndex, step] of plan.steps.entries()) {
113
130
  if (step.transactional) {
114
131
  yield* runStep(sql, step, stepIndex);
115
132
  }
116
133
  }
134
+ for (const statement of [...REPLICATION_DDL, ...replicationEntityDdl(plan.target)]) {
135
+ yield* sql.unsafe(statement).unprepared;
136
+ }
137
+ yield* sql.unsafe("UPDATE _ontology_replication SET version = $1 WHERE id", [
138
+ REPLICATION_SCHEMA_VERSION,
139
+ ]);
117
140
  yield* sql.unsafe(`
118
141
  INSERT INTO ${CATALOG} ("id", "version", "hash", "manifest")
119
142
  VALUES (true, $1, $2, $3::jsonb)
@@ -123,6 +146,16 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
123
146
  "manifest" = excluded."manifest",
124
147
  "applied_at" = now()
125
148
  `, [plan.target.version, plan.target.hash, canonicalJson(encodeManifest(plan.target))]);
149
+ if (refresh !== undefined) {
150
+ const selection = yield* Effect.sync(() => resolveReplicationSelection(plan.target, refresh));
151
+ yield* createReplicationBaseline(sql, {
152
+ generation: randomUUID(),
153
+ manifest: plan.target,
154
+ exclusions: refresh,
155
+ selection,
156
+ newSource: true,
157
+ });
158
+ }
126
159
  }));
127
160
  return { hash: plan.target.hash, appliedSteps: plan.steps.length, changed: true };
128
161
  }).pipe(Effect.withSpan("ontology.migrate.apply", {
@@ -1,4 +1,4 @@
1
- import type { OntologyManifest } from "../manifest.js";
1
+ import { type OntologyManifest } from "../manifest.js";
2
2
  import type { MigrationPlan } from "./model.js";
3
3
  export interface MigrationOptions {
4
4
  /**
@@ -1,3 +1,4 @@
1
+ import { isValueMatched, } from "../manifest.js";
1
2
  import { BLOB_TABLES_DDL } from "../storage/tables.js";
2
3
  import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
3
4
  import { CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
@@ -150,29 +151,32 @@ function validateManifest(manifest, refusals) {
150
151
  const invalid = (path, message) => {
151
152
  refusals.push(refusal("invalid-manifest", path, message));
152
153
  };
153
- const names = new Set();
154
+ const entitiesByName = new Map();
154
155
  const ids = new Set();
155
156
  for (const entity of manifest.entities) {
156
157
  if (!SNAKE_CASE_NAME.test(entity.name)) {
157
158
  invalid(entity.name, "entity names are lowercase snake_case.");
158
159
  }
159
- if (names.has(entity.name)) {
160
+ if (entitiesByName.has(entity.name)) {
160
161
  invalid(entity.name, `two entities are named ${entity.name}.`);
161
162
  }
163
+ else {
164
+ entitiesByName.set(entity.name, entity);
165
+ }
162
166
  if (ids.has(entity.id)) {
163
167
  invalid(entity.name, `two entities have the id ${entity.id}.`);
164
168
  }
165
- names.add(entity.name);
166
169
  ids.add(entity.id);
167
170
  }
168
- if (!names.has(USER_TABLE)) {
171
+ if (!entitiesByName.has(USER_TABLE)) {
169
172
  invalid(USER_TABLE, "the built-in user entity is missing; every ontology includes it.");
170
173
  }
171
174
  for (const entity of manifest.entities) {
172
- validateEntity(entity, names, invalid);
175
+ validateEntity({ entity, entitiesByName, invalid });
173
176
  }
174
177
  }
175
- function validateEntity(entity, entityNames, invalid) {
178
+ function validateEntity(input) {
179
+ const { entity, entitiesByName, invalid } = input;
176
180
  for (const property of entity.properties) {
177
181
  const path = `${entity.name}.properties.${property.name}`;
178
182
  const values = (property.options ?? []).map((option) => option.value);
@@ -195,11 +199,21 @@ function validateEntity(entity, entityNames, invalid) {
195
199
  if (!SNAKE_CASE_NAME.test(relationship.name) || isSystemProperty(relationship.name)) {
196
200
  invalid(path, "relationship names are lowercase snake_case and cannot be system properties.");
197
201
  }
198
- if (!entityNames.has(relationship.to)) {
202
+ const target = entitiesByName.get(relationship.to);
203
+ if (target === undefined) {
199
204
  invalid(path, `it points at ${relationship.to}, which is not an entity.`);
200
205
  }
201
- if (relationship.cardinality === "one" && relationship.onDelete === undefined) {
202
- invalid(path, "a rel.one relationship needs onDelete.");
206
+ else if (isValueMatched(relationship) !== (entity.source !== undefined || target.source !== undefined)) {
207
+ // `on` is what keeps a relationship with a warehouse-backed end out of storage.
208
+ invalid(path, isValueMatched(relationship)
209
+ ? "a relationship between stored entities uses a key column and cannot declare on."
210
+ : "a relationship with a warehouse-backed end is matched by value and needs on.");
211
+ }
212
+ // A value-matched relationship has no foreign key, so no delete rule.
213
+ if (relationship.cardinality === "one" &&
214
+ !isValueMatched(relationship) &&
215
+ relationship.onDelete === undefined) {
216
+ invalid(path, "a rel.one relationship between stored entities needs onDelete.");
203
217
  }
204
218
  if (relationship.onDelete === "setNull" && relationship.optional !== true) {
205
219
  invalid(path, 'onDelete "setNull" needs an optional relationship.');
@@ -344,12 +358,17 @@ function matchEntities(currentEntities, targetEntities, refusals) {
344
358
  for (const relationship of target.relationships) {
345
359
  const previous = current.relationships.find(({ name }) => name === relationship.name);
346
360
  if (previous === undefined) {
347
- addedRelationships.push(relationship);
361
+ if (!isValueMatched(relationship)) {
362
+ addedRelationships.push(relationship);
363
+ }
348
364
  continue;
349
365
  }
366
+ // Both lists hold stored entities only, so a warehouse-backed target has no id here.
350
367
  const previousTarget = entityIdByName(currentEntities, previous.to);
351
368
  const nextTarget = entityIdByName(targetEntities, relationship.to);
352
- if (previous.cardinality !== relationship.cardinality || previousTarget !== nextTarget) {
369
+ if (previous.cardinality !== relationship.cardinality ||
370
+ previousTarget !== nextTarget ||
371
+ isValueMatched(previous) !== isValueMatched(relationship)) {
353
372
  changed.add(relationship.name);
354
373
  refusals.push(refusal("relationship-change", `${target.name}.relationships.${relationship.name}`, `was rel.${previous.cardinality} to ${previous.to} and is now ` +
355
374
  `rel.${relationship.cardinality} to ${relationship.to}; a relationship keeps its ` +
@@ -357,9 +376,15 @@ function matchEntities(currentEntities, targetEntities, refusals) {
357
376
  continue;
358
377
  }
359
378
  kept.add(relationship.name);
360
- relationships.push({ current: previous, target: relationship });
379
+ if (!isValueMatched(relationship)) {
380
+ relationships.push({ current: previous, target: relationship });
381
+ }
361
382
  }
362
- const removedRelationships = current.relationships.filter(({ name }) => !kept.has(name) && !changed.has(name));
383
+ // A value-matched relationship is stored as the property its `on` names: adding, keeping or
384
+ // removing one plans no statement and drops nothing.
385
+ const removedRelationships = current.relationships.filter((relationship) => !kept.has(relationship.name) &&
386
+ !changed.has(relationship.name) &&
387
+ !isValueMatched(relationship));
363
388
  pairs.push({
364
389
  current,
365
390
  target,
@@ -1,4 +1,4 @@
1
- import type { EntityManifest, PropertyManifest, RelationshipManifest } from "../manifest.js";
1
+ import { type EntityManifest, type PropertyManifest, type RelationshipManifest } from "../manifest.js";
2
2
  /**
3
3
  * The physical mapping of an ontology onto Postgres. The planner creates exactly these objects and the
4
4
  * runtime reads and writes them, so every name here is part of the storage contract.
@@ -121,7 +121,8 @@ export declare function primaryKeyName(table: string): string;
121
121
  export declare function indexName(table: string, columns: ReadonlyArray<string>): string;
122
122
  /**
123
123
  * The column of an index field: a system property (`created_at` → `created_at`), a property, or a
124
- * `rel.one` (`owner` → `owner_id`). Returns undefined for anything else.
124
+ * stored `rel.one` (`owner` → `owner_id`). Returns undefined for anything else, value-matched
125
+ * relationships included: they have no column.
125
126
  */
126
127
  export declare function fieldColumnName(entity: EntityManifest, field: string): string | undefined;
127
128
  /** The SQL default of a property, or undefined when it declares none. */
@@ -1,3 +1,4 @@
1
+ import { isValueMatched, } from "../manifest.js";
1
2
  /**
2
3
  * The physical mapping of an ontology onto Postgres. The planner creates exactly these objects and the
3
4
  * runtime reads and writes them, so every name here is part of the storage contract.
@@ -102,7 +103,8 @@ export function indexName(table, columns) {
102
103
  }
103
104
  /**
104
105
  * The column of an index field: a system property (`created_at` → `created_at`), a property, or a
105
- * `rel.one` (`owner` → `owner_id`). Returns undefined for anything else.
106
+ * stored `rel.one` (`owner` → `owner_id`). Returns undefined for anything else, value-matched
107
+ * relationships included: they have no column.
106
108
  */
107
109
  export function fieldColumnName(entity, field) {
108
110
  // Applied manifests may still name system fields in camelCase.
@@ -121,7 +123,9 @@ export function fieldColumnName(entity, field) {
121
123
  if (entity.properties.some((property) => property.name === field)) {
122
124
  return columnName(field);
123
125
  }
124
- if (entity.relationships.some((relationship) => relationship.name === field && relationship.cardinality === "one")) {
126
+ if (entity.relationships.some((relationship) => relationship.name === field &&
127
+ relationship.cardinality === "one" &&
128
+ !isValueMatched(relationship))) {
125
129
  return relationshipColumnName(field);
126
130
  }
127
131
  return undefined;
@@ -364,8 +368,7 @@ function entityIndexes(entity) {
364
368
  unique: true,
365
369
  origin: `${entity.name}.${property.name} is unique`,
366
370
  }));
367
- const keys = entity.relationships
368
- .filter((relationship) => relationship.cardinality === "one")
371
+ const keys = keyedRelationships(entity)
369
372
  .map((relationship) => ({
370
373
  name: indexName(table, [relationshipColumnName(relationship.name)]),
371
374
  table,
@@ -376,10 +379,17 @@ function entityIndexes(entity) {
376
379
  .filter((index) => !declaredNames.has(index.name));
377
380
  return [...unique, ...keys, ...declared];
378
381
  }
382
+ /**
383
+ * The `rel.one` relationships that have a key column and a foreign key: those between stored
384
+ * entities. A value-matched relationship is stored as the ordinary property its `on` names.
385
+ */
386
+ function keyedRelationships(entity) {
387
+ return entity.relationships.filter((relationship) => relationship.cardinality === "one" && !isValueMatched(relationship));
388
+ }
379
389
  /** The table, history table, enum types and join tables of an entity. */
380
390
  export function entityStorage(entity) {
381
391
  const table = tableName(entity.name);
382
- const ones = entity.relationships.filter((relationship) => relationship.cardinality === "one");
392
+ const ones = keyedRelationships(entity);
383
393
  const manys = entity.relationships.filter((relationship) => relationship.cardinality === "many");
384
394
  const checks = entity.properties.flatMap((property) => {
385
395
  const check = propertyCheck(entity.name, property);
package/dist/names.d.ts CHANGED
@@ -20,12 +20,14 @@ export declare const MEMBER_ROLE = "member";
20
20
  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
+ /** The ontology service's route for aggregates across entities: `/api/ontology/aggregate`. */
24
+ export declare const AGGREGATE_ROUTE = "aggregate";
23
25
  /** 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", "oauth"];
26
+ export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
25
27
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
26
28
  export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
27
29
  /** 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"];
30
+ export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me", "aggregate"];
29
31
  /** The longest tool name that Claude and OpenAI accept. */
30
32
  export declare const MCP_TOOL_NAME_MAX_LENGTH = 64;
31
33
  /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
package/dist/names.js CHANGED
@@ -48,8 +48,11 @@ export const MEMBER_ROLE = "member";
48
48
  export const HISTORY_SUFFIX = "_history";
49
49
  /** The combinators of the query language, which take the place of field names in a `where`. */
50
50
  export const RESERVED_MEMBER_NAMES = ["and", "or", "not"];
51
+ /** The ontology service's route for aggregates across entities: `/api/ontology/aggregate`. */
52
+ export const AGGREGATE_ROUTE = "aggregate";
51
53
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
52
54
  export const RESERVED_ENTITY_NAMES = [
55
+ AGGREGATE_ROUTE,
53
56
  "graph",
54
57
  "status",
55
58
  "describe",
@@ -62,7 +65,7 @@ export const RESERVED_ENTITY_NAMES = [
62
65
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
63
66
  export const MCP_READ_TOOLS = ["find", "get", "history"];
64
67
  /** 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"];
68
+ export const MCP_GLOBAL_TOOLS = ["describe", "me", "aggregate"];
66
69
  /** The longest tool name that Claude and OpenAI accept. */
67
70
  export const MCP_TOOL_NAME_MAX_LENGTH = 64;
68
71
  /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
package/dist/query.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import type { JsonSchemaObject } from "./analyze.js";
3
- import type { EntityManifest, OntologyManifest } from "./manifest.js";
3
+ import { type EntityManifest, type OntologyManifest } from "./manifest.js";
4
4
  import type { PropertyType } from "./property.js";
5
5
  /** How a record field is stored: a property type, or a UUID for ids and `<relationship>_id` keys. */
6
6
  export type StorageType = PropertyType | "uuid";
@@ -139,7 +139,10 @@ export interface QueryLink {
139
139
  readonly declared: "one" | "many";
140
140
  readonly inverse: boolean;
141
141
  }
142
- /** The fields of an entity's records, in record order. */
142
+ /**
143
+ * The fields of an entity's records, in record order. A value-matched relationship has no
144
+ * `<relationship>_id`: its reference is the property named in `on`.
145
+ */
143
146
  export declare function queryFields(entity: EntityManifest): ReadonlyArray<QueryField>;
144
147
  /** The relationships of an entity, declared ones first, then the inverses landing on it. */
145
148
  export declare function queryLinks(manifest: OntologyManifest, entityName: string): ReadonlyArray<QueryLink>;
package/dist/query.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { wireStruct } from "./wire.js";
3
+ import { isValueMatched } from "./manifest.js";
3
4
  import { BlobValue } from "./blob.js";
4
5
  /**
5
6
  * The query language of `find` and of the HTTP API's list and query endpoints.
@@ -79,7 +80,10 @@ const SYSTEM_FIELDS = [
79
80
  { name: "created_by", storage: "uuid", nullable: true },
80
81
  { name: "updated_by", storage: "uuid", nullable: true },
81
82
  ];
82
- /** The fields of an entity's records, in record order. */
83
+ /**
84
+ * The fields of an entity's records, in record order. A value-matched relationship has no
85
+ * `<relationship>_id`: its reference is the property named in `on`.
86
+ */
83
87
  export function queryFields(entity) {
84
88
  const properties = entity.properties.map((property) => ({
85
89
  name: property.name,
@@ -90,7 +94,7 @@ export function queryFields(entity) {
90
94
  : {}),
91
95
  }));
92
96
  const keys = entity.relationships
93
- .filter((relationship) => relationship.cardinality === "one")
97
+ .filter((relationship) => relationship.cardinality === "one" && !isValueMatched(relationship))
94
98
  .map((relationship) => ({
95
99
  name: `${relationship.name}_id`,
96
100
  storage: "uuid",