@segmentstream-cloud/ontology 0.12.0 → 0.14.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 (64) hide show
  1. package/README.md +84 -17
  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 +6 -3
  7. package/dist/index.js +5 -2
  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.js +2 -1
  13. package/dist/migrate/plan.d.ts +1 -1
  14. package/dist/migrate/plan.js +59 -22
  15. package/dist/migrate/storage.d.ts +15 -2
  16. package/dist/migrate/storage.js +63 -5
  17. package/dist/names.d.ts +4 -2
  18. package/dist/names.js +5 -1
  19. package/dist/query.d.ts +5 -2
  20. package/dist/query.js +6 -2
  21. package/dist/relationship.d.ts +36 -5
  22. package/dist/relationship.js +5 -3
  23. package/dist/render.d.ts +47 -3
  24. package/dist/render.js +117 -11
  25. package/dist/replication/model.d.ts +1 -1
  26. package/dist/replication/model.js +11 -1
  27. package/dist/runtime/agents.d.ts +9 -0
  28. package/dist/runtime/agents.js +145 -0
  29. package/dist/runtime/service-binding.js +2 -0
  30. package/dist/service/agent/protocol.d.ts +47 -0
  31. package/dist/service/agent/protocol.js +59 -0
  32. package/dist/service/agent/server.d.ts +27 -0
  33. package/dist/service/agent/server.js +235 -0
  34. package/dist/service/aggregate-platform.d.ts +60 -0
  35. package/dist/service/aggregate-platform.js +189 -0
  36. package/dist/service/aggregate.d.ts +42 -0
  37. package/dist/service/aggregate.js +145 -0
  38. package/dist/service/api.d.ts +5 -3
  39. package/dist/service/api.js +52 -6
  40. package/dist/service/authentication.d.ts +16 -5
  41. package/dist/service/authentication.js +51 -6
  42. package/dist/service/catalog.d.ts +10 -1
  43. package/dist/service/catalog.js +12 -3
  44. package/dist/service/config.d.ts +30 -1
  45. package/dist/service/config.js +53 -1
  46. package/dist/service/errors.d.ts +38 -0
  47. package/dist/service/errors.js +118 -0
  48. package/dist/service/handlers.d.ts +2 -1
  49. package/dist/service/handlers.js +66 -28
  50. package/dist/service/mcp/server.d.ts +6 -0
  51. package/dist/service/mcp/server.js +74 -17
  52. package/dist/service/mcp/tools.d.ts +13 -2
  53. package/dist/service/mcp/tools.js +165 -6
  54. package/dist/service/plan.js +7 -1
  55. package/dist/service/runtime-contract.d.ts +68 -0
  56. package/dist/service/runtime-identity.d.ts +59 -0
  57. package/dist/service/runtime-identity.js +102 -0
  58. package/dist/service/schemas.d.ts +99 -1
  59. package/dist/service/schemas.js +150 -2
  60. package/dist/service/service.d.ts +2 -2
  61. package/dist/service/service.js +24 -6
  62. package/dist/user.d.ts +1 -1
  63. package/dist/warehouse-backed.js +222 -121
  64. 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":
@@ -7,7 +7,7 @@ import { planMigration } from "./plan.js";
7
7
  import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
8
8
  import { createReplicationBaseline } from "../replication/baseline.js";
9
9
  import { resolveReplicationSelection } from "../replication/model.js";
10
- import { API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
10
+ import { AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
11
11
  const CATALOG = quoteIdentifier(CATALOG_TABLE);
12
12
  /** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
13
13
  const MIGRATION_LOCK = "segmentstream.ontology.migration";
@@ -171,6 +171,7 @@ export const RUNTIME_TABLES = [
171
171
  USER_ROLES_TABLE,
172
172
  API_KEYS_TABLE,
173
173
  ...OAUTH_TABLES,
174
+ ...AGENT_TABLES,
174
175
  ];
175
176
  /**
176
177
  * The runtime tables the database has, for `planMigration`'s `existingTables`: a database that an
@@ -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,6 +1,7 @@
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
- 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";
4
+ import { AGENT_TABLES, AGENT_TABLES_DDL, 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";
4
5
  const GLOBAL_KINDS = new Set(["table", "type", "index"]);
5
6
  function step(input) {
6
7
  return {
@@ -69,17 +70,29 @@ export function planMigration(input) {
69
70
  draft.runtimeTables.push(step({
70
71
  kind: "createCatalog",
71
72
  classification: "additive",
72
- description: "Create the runtime's role assignment, API key and OAuth tables.",
73
+ description: "Create the runtime's role assignment, API key, OAuth and agent tables.",
73
74
  sql: RUNTIME_TABLES_DDL,
74
75
  }));
75
76
  }
76
- else if (OAUTH_TABLES.some((table) => input.existingTables?.has(table) === false)) {
77
- draft.runtimeTables.push(step({
78
- kind: "createCatalog",
79
- classification: "additive",
80
- description: "Create the runtime's OAuth tables, which connect agents to the MCP server.",
81
- sql: OAUTH_TABLES_DDL,
82
- }));
77
+ else {
78
+ // A database an earlier SDK migrated lacks the tables added since; each set arrives on its own.
79
+ const lacks = (tables) => tables.some((table) => input.existingTables?.has(table) === false);
80
+ if (lacks(OAUTH_TABLES)) {
81
+ draft.runtimeTables.push(step({
82
+ kind: "createCatalog",
83
+ classification: "additive",
84
+ description: "Create the runtime's OAuth tables, which connect agents to the MCP server.",
85
+ sql: OAUTH_TABLES_DDL,
86
+ }));
87
+ }
88
+ if (lacks(AGENT_TABLES)) {
89
+ draft.runtimeTables.push(step({
90
+ kind: "createCatalog",
91
+ classification: "additive",
92
+ description: "Create the runtime's agent tables, which pair channel accounts with members.",
93
+ sql: AGENT_TABLES_DDL,
94
+ }));
95
+ }
83
96
  }
84
97
  if (storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob")) &&
85
98
  !current?.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))) {
@@ -150,29 +163,32 @@ function validateManifest(manifest, refusals) {
150
163
  const invalid = (path, message) => {
151
164
  refusals.push(refusal("invalid-manifest", path, message));
152
165
  };
153
- const names = new Set();
166
+ const entitiesByName = new Map();
154
167
  const ids = new Set();
155
168
  for (const entity of manifest.entities) {
156
169
  if (!SNAKE_CASE_NAME.test(entity.name)) {
157
170
  invalid(entity.name, "entity names are lowercase snake_case.");
158
171
  }
159
- if (names.has(entity.name)) {
172
+ if (entitiesByName.has(entity.name)) {
160
173
  invalid(entity.name, `two entities are named ${entity.name}.`);
161
174
  }
175
+ else {
176
+ entitiesByName.set(entity.name, entity);
177
+ }
162
178
  if (ids.has(entity.id)) {
163
179
  invalid(entity.name, `two entities have the id ${entity.id}.`);
164
180
  }
165
- names.add(entity.name);
166
181
  ids.add(entity.id);
167
182
  }
168
- if (!names.has(USER_TABLE)) {
183
+ if (!entitiesByName.has(USER_TABLE)) {
169
184
  invalid(USER_TABLE, "the built-in user entity is missing; every ontology includes it.");
170
185
  }
171
186
  for (const entity of manifest.entities) {
172
- validateEntity(entity, names, invalid);
187
+ validateEntity({ entity, entitiesByName, invalid });
173
188
  }
174
189
  }
175
- function validateEntity(entity, entityNames, invalid) {
190
+ function validateEntity(input) {
191
+ const { entity, entitiesByName, invalid } = input;
176
192
  for (const property of entity.properties) {
177
193
  const path = `${entity.name}.properties.${property.name}`;
178
194
  const values = (property.options ?? []).map((option) => option.value);
@@ -195,11 +211,21 @@ function validateEntity(entity, entityNames, invalid) {
195
211
  if (!SNAKE_CASE_NAME.test(relationship.name) || isSystemProperty(relationship.name)) {
196
212
  invalid(path, "relationship names are lowercase snake_case and cannot be system properties.");
197
213
  }
198
- if (!entityNames.has(relationship.to)) {
214
+ const target = entitiesByName.get(relationship.to);
215
+ if (target === undefined) {
199
216
  invalid(path, `it points at ${relationship.to}, which is not an entity.`);
200
217
  }
201
- if (relationship.cardinality === "one" && relationship.onDelete === undefined) {
202
- invalid(path, "a rel.one relationship needs onDelete.");
218
+ else if (isValueMatched(relationship) !== (entity.source !== undefined || target.source !== undefined)) {
219
+ // `on` is what keeps a relationship with a warehouse-backed end out of storage.
220
+ invalid(path, isValueMatched(relationship)
221
+ ? "a relationship between stored entities uses a key column and cannot declare on."
222
+ : "a relationship with a warehouse-backed end is matched by value and needs on.");
223
+ }
224
+ // A value-matched relationship has no foreign key, so no delete rule.
225
+ if (relationship.cardinality === "one" &&
226
+ !isValueMatched(relationship) &&
227
+ relationship.onDelete === undefined) {
228
+ invalid(path, "a rel.one relationship between stored entities needs onDelete.");
203
229
  }
204
230
  if (relationship.onDelete === "setNull" && relationship.optional !== true) {
205
231
  invalid(path, 'onDelete "setNull" needs an optional relationship.');
@@ -344,12 +370,17 @@ function matchEntities(currentEntities, targetEntities, refusals) {
344
370
  for (const relationship of target.relationships) {
345
371
  const previous = current.relationships.find(({ name }) => name === relationship.name);
346
372
  if (previous === undefined) {
347
- addedRelationships.push(relationship);
373
+ if (!isValueMatched(relationship)) {
374
+ addedRelationships.push(relationship);
375
+ }
348
376
  continue;
349
377
  }
378
+ // Both lists hold stored entities only, so a warehouse-backed target has no id here.
350
379
  const previousTarget = entityIdByName(currentEntities, previous.to);
351
380
  const nextTarget = entityIdByName(targetEntities, relationship.to);
352
- if (previous.cardinality !== relationship.cardinality || previousTarget !== nextTarget) {
381
+ if (previous.cardinality !== relationship.cardinality ||
382
+ previousTarget !== nextTarget ||
383
+ isValueMatched(previous) !== isValueMatched(relationship)) {
353
384
  changed.add(relationship.name);
354
385
  refusals.push(refusal("relationship-change", `${target.name}.relationships.${relationship.name}`, `was rel.${previous.cardinality} to ${previous.to} and is now ` +
355
386
  `rel.${relationship.cardinality} to ${relationship.to}; a relationship keeps its ` +
@@ -357,9 +388,15 @@ function matchEntities(currentEntities, targetEntities, refusals) {
357
388
  continue;
358
389
  }
359
390
  kept.add(relationship.name);
360
- relationships.push({ current: previous, target: relationship });
391
+ if (!isValueMatched(relationship)) {
392
+ relationships.push({ current: previous, target: relationship });
393
+ }
361
394
  }
362
- const removedRelationships = current.relationships.filter(({ name }) => !kept.has(name) && !changed.has(name));
395
+ // A value-matched relationship is stored as the property its `on` names: adding, keeping or
396
+ // removing one plans no statement and drops nothing.
397
+ const removedRelationships = current.relationships.filter((relationship) => !kept.has(relationship.name) &&
398
+ !changed.has(relationship.name) &&
399
+ !isValueMatched(relationship));
363
400
  pairs.push({
364
401
  current,
365
402
  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. */
@@ -163,6 +164,18 @@ export declare const OAUTH_TOKENS_TABLE = "_oauth_tokens";
163
164
  export declare const OAUTH_TABLES: ReadonlyArray<string>;
164
165
  /** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
165
166
  export declare const OAUTH_TABLES_DDL: ReadonlyArray<string>;
167
+ /** Channel accounts waiting, for ten minutes, for a member to claim them with a short code. */
168
+ export declare const AGENT_PAIRINGS_TABLE = "_agent_pairings";
169
+ /** Channel accounts paired with members: the app's agent acts as the member for their messages. */
170
+ export declare const AGENT_IDENTITIES_TABLE = "_agent_identities";
171
+ /** The agent's tables, in the order they are created. */
172
+ export declare const AGENT_TABLES: ReadonlyArray<string>;
173
+ /**
174
+ * The tables of the app's agent: pairing codes, stored only as SHA-256 hashes, and the identities
175
+ * they create. An account and a member each have at most one live identity per provider. Every
176
+ * statement is idempotent.
177
+ */
178
+ export declare const AGENT_TABLES_DDL: ReadonlyArray<string>;
166
179
  /**
167
180
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
168
181
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -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);
@@ -514,6 +524,53 @@ export const OAUTH_TABLES_DDL = [
514
524
  `CREATE INDEX IF NOT EXISTS "${OAUTH_TOKENS_TABLE}_grant_id_idx" ` +
515
525
  `ON ${quoteIdentifier(OAUTH_TOKENS_TABLE)} ("grant_id")`,
516
526
  ];
527
+ /** Channel accounts waiting, for ten minutes, for a member to claim them with a short code. */
528
+ export const AGENT_PAIRINGS_TABLE = "_agent_pairings";
529
+ /** Channel accounts paired with members: the app's agent acts as the member for their messages. */
530
+ export const AGENT_IDENTITIES_TABLE = "_agent_identities";
531
+ /** The agent's tables, in the order they are created. */
532
+ export const AGENT_TABLES = [AGENT_PAIRINGS_TABLE, AGENT_IDENTITIES_TABLE];
533
+ /**
534
+ * The tables of the app's agent: pairing codes, stored only as SHA-256 hashes, and the identities
535
+ * they create. An account and a member each have at most one live identity per provider. Every
536
+ * statement is idempotent.
537
+ */
538
+ export const AGENT_TABLES_DDL = [
539
+ [
540
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(AGENT_PAIRINGS_TABLE)} (`,
541
+ ` "id" uuid NOT NULL,`,
542
+ ` "code_hash" text NOT NULL,`,
543
+ ` "provider" text NOT NULL,`,
544
+ ` "external_id" text NOT NULL,`,
545
+ ` "display" text,`,
546
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
547
+ ` "expires_at" timestamptz NOT NULL,`,
548
+ ` "consumed_at" timestamptz,`,
549
+ ` CONSTRAINT "${AGENT_PAIRINGS_TABLE}_pkey" PRIMARY KEY ("id"),`,
550
+ ` CONSTRAINT "${AGENT_PAIRINGS_TABLE}_code_hash_key" UNIQUE ("code_hash")`,
551
+ `)`,
552
+ ].join("\n"),
553
+ [
554
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} (`,
555
+ ` "id" uuid NOT NULL,`,
556
+ ` "provider" text NOT NULL,`,
557
+ ` "external_id" text NOT NULL,`,
558
+ ` "user_id" uuid NOT NULL,`,
559
+ ` "display" text,`,
560
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
561
+ ` "revoked_at" timestamptz,`,
562
+ ` CONSTRAINT "${AGENT_IDENTITIES_TABLE}_pkey" PRIMARY KEY ("id"),`,
563
+ ` CONSTRAINT "${AGENT_IDENTITIES_TABLE}_user_id_fkey" FOREIGN KEY ("user_id") ` +
564
+ `REFERENCES ${quoteIdentifier(USER_TABLE)} ("id") ON DELETE CASCADE`,
565
+ `)`,
566
+ ].join("\n"),
567
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${AGENT_IDENTITIES_TABLE}_account_key" ` +
568
+ `ON ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} ("provider", "external_id") ` +
569
+ `WHERE "revoked_at" IS NULL`,
570
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${AGENT_IDENTITIES_TABLE}_member_key" ` +
571
+ `ON ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} ("user_id", "provider") ` +
572
+ `WHERE "revoked_at" IS NULL`,
573
+ ];
517
574
  /**
518
575
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
519
576
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -546,6 +603,7 @@ export const RUNTIME_TABLES_DDL = [
546
603
  ].join("\n"),
547
604
  `CREATE INDEX IF NOT EXISTS "${API_KEYS_TABLE}_user_id_idx" ON ${quoteIdentifier(API_KEYS_TABLE)} ("user_id")`,
548
605
  ...OAUTH_TABLES_DDL,
606
+ ...AGENT_TABLES_DDL,
549
607
  ];
550
608
  export function columnDefinition(column) {
551
609
  const parts = [quoteIdentifier(column.name), column.type];
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", "agent"];
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",
@@ -58,11 +61,12 @@ export const RESERVED_ENTITY_NAMES = [
58
61
  "mcp",
59
62
  "me",
60
63
  "oauth",
64
+ "agent",
61
65
  ];
62
66
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
63
67
  export const MCP_READ_TOOLS = ["find", "get", "history"];
64
68
  /** 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"];
69
+ export const MCP_GLOBAL_TOOLS = ["describe", "me", "aggregate"];
66
70
  /** The longest tool name that Claude and OpenAI accept. */
67
71
  export const MCP_TOOL_NAME_MAX_LENGTH = 64;
68
72
  /** 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>;