@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/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",
@@ -12,19 +12,42 @@ export type OnDelete = (typeof ON_DELETE)[number];
12
12
  export type LazyEntity = Function;
13
13
  /** Where a relationship points: an entity, a thunk returning one, or an entity name. */
14
14
  export type EntityTarget = AnyEntity | LazyEntity | string;
15
+ /**
16
+ * The name of the entity a target points at, as far as its type shows: the given name, or the
17
+ * entity's name. A thunk is a `Function`, whose `name` is any string, so its target stays unknown.
18
+ */
19
+ type TargetName<Target> = Target extends string ? Target : Target extends {
20
+ readonly name: infer Name extends string;
21
+ } ? Name : string;
15
22
  /** Names a relationship in both directions, for example `["is owned by", "owns"]`. */
16
23
  export type Phrase = readonly [forward: string, inverse: string];
17
- /** A many-to-one relationship, declared on the side that holds the key (`owner_id` on `lead`). */
18
- export interface OneRelationship<Optional extends boolean = boolean> {
24
+ /**
25
+ * A many-to-one relationship. Between stored entities it is declared on the side that holds the key
26
+ * (`owner_id` on `lead`). When either end is warehouse-backed it is value-matched: it declares `on`,
27
+ * one pair of an own property and a target property, and has no key column, delete rule or
28
+ * optionality of its own.
29
+ */
30
+ export interface OneRelationship<Optional extends boolean = boolean, Matched extends boolean = boolean, Target extends string = string> {
19
31
  readonly [RelationshipTypeId]: typeof RelationshipTypeId;
20
32
  readonly cardinality: "one";
21
33
  readonly on?: Readonly<Record<string, string>>;
22
34
  readonly target: EntityTarget;
23
35
  readonly optional: Optional;
24
36
  readonly inverse: string;
25
- readonly onDelete: OnDelete;
37
+ /** As declared. A stored → stored relationship without one restricts deletes. */
38
+ readonly onDelete?: OnDelete;
26
39
  readonly phrase?: Phrase;
27
40
  readonly description: string;
41
+ /**
42
+ * Exists only in the type: whether `on` was given, which makes the relationship value-matched.
43
+ * Value-matched relationships add no `<relationship>Id` to records and inputs.
44
+ */
45
+ readonly "~ontology.matched": Matched;
46
+ /**
47
+ * Exists only in the type: the target entity's name when the target was given as an entity or a
48
+ * name, and `string` for a thunk. Typed aggregate clients resolve relationship paths with it.
49
+ */
50
+ readonly "~ontology.target": Target;
28
51
  }
29
52
  /** A plain many-to-many relationship, stored in a hidden join table. It never carries properties. */
30
53
  export interface ManyRelationship {
@@ -38,11 +61,16 @@ export interface ManyRelationship {
38
61
  }
39
62
  export type AnyRelationship = OneRelationship | ManyRelationship;
40
63
  export interface OneOptions {
64
+ /**
65
+ * The property pair a relationship with a warehouse-backed end matches on: `{ ownProperty:
66
+ * targetProperty }`. Required for those relationships and refused between stored entities.
67
+ */
41
68
  readonly on?: Readonly<Record<string, string>>;
42
- /** The record may point at nothing: the key column is nullable. */
69
+ /** The record may point at nothing: the key column is nullable. Stored → stored only. */
43
70
  readonly optional?: boolean;
44
71
  /** The name of the relationship as seen from the target, for example `ownedLeads` on `user`. */
45
72
  readonly inverse: string;
73
+ /** What deleting the target does to this record, `restrict` by default. Stored → stored only. */
46
74
  readonly onDelete?: OnDelete;
47
75
  readonly phrase?: Phrase;
48
76
  readonly description: string;
@@ -57,7 +85,10 @@ export interface ManyOptions {
57
85
  type IsOptional<O> = O extends {
58
86
  readonly optional: true;
59
87
  } ? true : false;
60
- declare function one<const O extends OneOptions>(target: EntityTarget, options: O): OneRelationship<IsOptional<O>>;
88
+ type IsMatched<O> = O extends {
89
+ readonly on: Readonly<Record<string, string>>;
90
+ } ? true : false;
91
+ declare function one<const Target extends EntityTarget, const O extends OneOptions>(target: Target, options: O): OneRelationship<IsOptional<O>, IsMatched<O>, TargetName<Target>>;
61
92
  declare function many(target: EntityTarget, options: ManyOptions): ManyRelationship;
62
93
  /** Relationship constructors. */
63
94
  export declare const rel: {
@@ -1,17 +1,19 @@
1
1
  export const RelationshipTypeId = Symbol.for("@segmentstream-cloud/ontology/Relationship");
2
2
  export const ON_DELETE = ["setNull", "cascade", "restrict"];
3
3
  function one(target, options) {
4
- return {
4
+ // `onDelete` stays absent unless declared, so that check can refuse it where it means nothing.
5
+ const relationship = {
5
6
  [RelationshipTypeId]: RelationshipTypeId,
6
7
  cardinality: "one",
7
8
  target,
8
- optional: (options.optional === true),
9
+ optional: options.optional === true,
9
10
  inverse: options.inverse,
10
- onDelete: options.onDelete ?? "restrict",
11
+ ...(options.onDelete !== undefined ? { onDelete: options.onDelete } : {}),
11
12
  ...(options.on !== undefined ? { on: options.on } : {}),
12
13
  ...(options.phrase !== undefined ? { phrase: options.phrase } : {}),
13
14
  description: options.description,
14
15
  };
16
+ return relationship;
15
17
  }
16
18
  function many(target, options) {
17
19
  return {
package/dist/render.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { ActionManifest, OntologyManifest } from "./manifest.js";
1
+ import type { ActionManifest, EntityManifest, OntologyManifest } from "./manifest.js";
2
2
  export interface RenderOptions {
3
3
  /**
4
- * The permissions of the reader. Only the actions these permissions may run are listed. Without
5
- * it, every action is listed with the permission it requires and the roles that grant it.
4
+ * The permissions of the reader. Only the actions these permissions may run are listed, and only
5
+ * the measures and routes of entities they may read. Without it, every action is listed with the
6
+ * permission it requires and the roles that grant it, and every measure and route.
6
7
  */
7
8
  readonly as?: ReadonlyArray<string>;
8
9
  }
@@ -16,3 +17,46 @@ export declare function actionPermission(entity: string, action: ActionManifest)
16
17
  * the console, the CLI and the service describe the same ontology.
17
18
  */
18
19
  export declare function renderCatalog(manifest: OntologyManifest, options?: RenderOptions): string;
20
+ /**
21
+ * The catalog as `describe` answers a reader: {@link renderCatalog} with the reader's permissions,
22
+ * or everything without them.
23
+ */
24
+ export declare function describeCatalog(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;
25
+ type MeasureManifest = NonNullable<EntityManifest["measures"]>[number];
26
+ /** A measure as aggregates name it: `<entity>.<measure>`. */
27
+ export interface NamedMeasure {
28
+ readonly name: string;
29
+ readonly entity: EntityManifest;
30
+ readonly measure: MeasureManifest;
31
+ }
32
+ /**
33
+ * An entity a measure entity reaches, and the shortest routes there the reader may follow:
34
+ * `product.region`. `ambiguous` when the resolver finds several shortest routes, readable or not,
35
+ * so a request must write the route.
36
+ */
37
+ export interface Reach {
38
+ readonly target: string;
39
+ readonly routes: ReadonlyArray<string>;
40
+ readonly ambiguous: boolean;
41
+ }
42
+ /** The rule every surface repeats (docs/ontology-aggregates-spec.md, "Three ways to ask"). */
43
+ export declare const RECORDS_OR_AGGREGATES = "look at or change records through find and actions; count, sum or compare through aggregate";
44
+ /**
45
+ * Every measure of the entities the reader may read, warehouse-backed and stored alike, in
46
+ * declaration order. Without permissions, every measure.
47
+ */
48
+ export declare function measuresFor(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): ReadonlyArray<NamedMeasure>;
49
+ /**
50
+ * The entities one entity reaches, with the shortest routes to each: exactly the routes aggregate
51
+ * resolution weighs ({@link shortestRoutesFrom}). Only then is the reader applied: a target they may
52
+ * not read is left out, and so is any route through an entity they may not read, which the service
53
+ * would refuse. A target keeps `ambiguous` from before that filter, so a route is never shown as the
54
+ * only one when the resolver would ask for it to be written.
55
+ */
56
+ export declare function reachesOf(manifest: OntologyManifest, from: string, permissions?: ReadonlyArray<string>): ReadonlyArray<Reach>;
57
+ /**
58
+ * The aggregate part of the catalog's prose: every measure the reader may read, model-wide, and
59
+ * per measure entity the entities it reaches. Empty when the reader may read no measure.
60
+ */
61
+ export declare function renderAggregates(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;
62
+ export {};
package/dist/render.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { shortestRoutesFrom } from "./aggregate-routes.js";
1
2
  import { isAllowed, permissionOf, READ_VERB, rolesGranting } from "./permissions.js";
2
3
  import { REFERENCE_ANNOTATION } from "./property.js";
3
4
  /** What an action of the manifest requires; version 1 manifests leave it implicit. */
@@ -24,11 +25,14 @@ function withDescription(head, description) {
24
25
  function formatValue(value) {
25
26
  return typeof value === "string" ? value : JSON.stringify(value);
26
27
  }
27
- function propertyLines(property) {
28
+ /** A warehouse-backed property's `unique` is an assertion the platform does not enforce. */
29
+ function propertyLines(input) {
30
+ const { property, warehouseBacked } = input;
31
+ const unique = warehouseBacked ? "unique, not enforced" : "unique";
28
32
  const traits = [
29
33
  property.type,
30
34
  ...(property.optional ? ["optional"] : []),
31
- ...(property.unique ? ["unique"] : []),
35
+ ...(property.unique ? [unique] : []),
32
36
  ...(property.default !== undefined ? [`default ${formatValue(property.default)}`] : []),
33
37
  ...(property.min !== undefined ? [`min ${property.min}`] : []),
34
38
  ...(property.max !== undefined ? [`max ${property.max}`] : []),
@@ -43,18 +47,40 @@ function propertyLines(property) {
43
47
  ...options.map((option) => `${INDENT}${option}`),
44
48
  ];
45
49
  }
50
+ /**
51
+ * The inverse of a relationship as its target reads it. A value-matched inverse, one with `on`, has
52
+ * no route: a warehouse-backed source is read through aggregates, a stored one by its `on` property.
53
+ */
54
+ function inverseLine(source, relationship) {
55
+ const { on, phrase, inverse } = relationship;
56
+ if (on === undefined) {
57
+ return `${phrase[1]} many ${source.plural} (${inverse})`;
58
+ }
59
+ if (source.source !== undefined) {
60
+ return `${phrase[1]} ${source.plural} (${inverse}), through aggregates`;
61
+ }
62
+ return `${phrase[1]} ${source.plural} (${inverse}), matched on ${on.property}`;
63
+ }
46
64
  function relationshipLines(entity, entities, all) {
47
65
  const declared = entity.relationships.map((relationship) => {
66
+ const { on, phrase, name } = relationship;
48
67
  const target = entities.get(relationship.to);
68
+ const label = target?.label ?? relationship.to;
49
69
  const optional = relationship.optional === true ? ", optional" : "";
50
- const head = relationship.cardinality === "one"
51
- ? `${relationship.phrase[0]} one ${target?.label ?? relationship.to} (${relationship.name})${optional}`
52
- : `${relationship.phrase[0]} many ${target?.plural ?? relationship.to} (${relationship.name})`;
70
+ let head = `${phrase[0]} one ${label} (${name})${optional}`;
71
+ if (relationship.cardinality === "many") {
72
+ head = `${phrase[0]} many ${target?.plural ?? relationship.to} (${name})`;
73
+ }
74
+ else if (on !== undefined) {
75
+ // Value-matched: nothing promises a match, and the key is the declared property pair.
76
+ const key = `${on.property} = ${label}.${on.targetProperty}`;
77
+ head = `${phrase[0]} at most one ${label} (${name}), matched on ${key}`;
78
+ }
53
79
  return withDescription(head, relationship.description);
54
80
  });
55
81
  const inverses = all.flatMap((source) => source.relationships
56
82
  .filter((relationship) => relationship.to === entity.name)
57
- .map((relationship) => `${relationship.phrase[1]} many ${source.plural} (${relationship.inverse})`));
83
+ .map((relationship) => inverseLine(source, relationship)));
58
84
  return [...declared, ...inverses];
59
85
  }
60
86
  function inputType(schema) {
@@ -143,10 +169,8 @@ function renderEntity(entity, manifest, entities, options) {
143
169
  ...(entity.source
144
170
  ? [`${INDENT}Source: ${entity.source.asset}; key: ${entity.source.key.join(", ")}.`]
145
171
  : []),
146
- ...section("Properties", entity.properties.flatMap(propertyLines)),
147
- ...(entity.source
148
- ? section("Measures", (entity.measures ?? []).map((measure) => `${measure.name}: ${measure.description} (${measure.unit}).`))
149
- : []),
172
+ ...section("Properties", entity.properties.flatMap((property) => propertyLines({ property, warehouseBacked: entity.source !== undefined }))),
173
+ ...section("Measures", (entity.measures ?? []).map((measure) => `${measure.name}: ${measure.description} (${measure.unit}).`)),
150
174
  ...section("Relationships", relationshipLines(entity, entities, manifest.entities)),
151
175
  ...section(reader === undefined ? "Actions" : "Actions you may run", actions.flatMap((action) => actionLines(action, reader === undefined
152
176
  ? requirement("Requires", actionPermission(entity.name, action), manifest.roles)
@@ -180,5 +204,87 @@ export function renderCatalog(manifest, options = {}) {
180
204
  options.as === undefined ||
181
205
  isAllowed(entity.permissions?.read ?? `${entity.name}:read`, { permissions: options.as }))
182
206
  .map((entity) => renderEntity(entity, manifest, entities, options));
183
- return `${[...sections, renderRoles(manifest.roles)].join("\n\n")}\n`;
207
+ const aggregates = renderAggregates(manifest, options.as);
208
+ return `${[...sections, renderRoles(manifest.roles), ...(aggregates === "" ? [] : [aggregates])].join("\n\n")}\n`;
209
+ }
210
+ /**
211
+ * The catalog as `describe` answers a reader: {@link renderCatalog} with the reader's permissions,
212
+ * or everything without them.
213
+ */
214
+ export function describeCatalog(manifest, permissions) {
215
+ return renderCatalog(manifest, permissions === undefined ? {} : { as: permissions });
216
+ }
217
+ /** The rule every surface repeats (docs/ontology-aggregates-spec.md, "Three ways to ask"). */
218
+ export const RECORDS_OR_AGGREGATES = "look at or change records through find and actions; count, sum or compare through aggregate";
219
+ /** Whether the reader may read the entity; without a reader, everything is described. */
220
+ function readable(entity, permissions) {
221
+ return permissions === undefined || isAllowed(permissionOf(entity, READ_VERB), { permissions });
222
+ }
223
+ /**
224
+ * Every measure of the entities the reader may read, warehouse-backed and stored alike, in
225
+ * declaration order. Without permissions, every measure.
226
+ */
227
+ export function measuresFor(manifest, permissions) {
228
+ return manifest.entities
229
+ .filter((entity) => readable(entity.name, permissions))
230
+ .flatMap((entity) => (entity.measures ?? []).map((measure) => ({
231
+ name: `${entity.name}.${measure.name}`,
232
+ entity,
233
+ measure,
234
+ })));
235
+ }
236
+ /**
237
+ * The entities one entity reaches, with the shortest routes to each: exactly the routes aggregate
238
+ * resolution weighs ({@link shortestRoutesFrom}). Only then is the reader applied: a target they may
239
+ * not read is left out, and so is any route through an entity they may not read, which the service
240
+ * would refuse. A target keeps `ambiguous` from before that filter, so a route is never shown as the
241
+ * only one when the resolver would ask for it to be written.
242
+ */
243
+ export function reachesOf(manifest, from, permissions) {
244
+ return [...shortestRoutesFrom(manifest, from)].flatMap(([target, routes]) => {
245
+ const followed = routes.filter((route) => route.entities.every((entity) => readable(entity, permissions)));
246
+ return followed.length === 0
247
+ ? []
248
+ : [
249
+ {
250
+ target,
251
+ routes: followed.map((route) => route.relationships.join(".")),
252
+ ambiguous: routes.length > 1,
253
+ },
254
+ ];
255
+ });
256
+ }
257
+ /** `sales reaches product (product), region (product.region or listing.region; write the route).` */
258
+ function reachLine(entity, reaches) {
259
+ const targets = reaches.map(({ target, routes, ambiguous }) => `${target} (${routes.join(" or ")}${ambiguous ? "; write the route" : ""})`);
260
+ return `${entity} reaches ${targets.join(", ")}.`;
261
+ }
262
+ /**
263
+ * The aggregate part of the catalog's prose: every measure the reader may read, model-wide, and
264
+ * per measure entity the entities it reaches. Empty when the reader may read no measure.
265
+ */
266
+ export function renderAggregates(manifest, permissions) {
267
+ const measures = measuresFor(manifest, permissions);
268
+ if (measures.length === 0) {
269
+ return "";
270
+ }
271
+ const owners = [...new Set(measures.map(({ entity }) => entity.name))];
272
+ const reachLines = owners.flatMap((owner) => {
273
+ const reaches = reachesOf(manifest, owner, permissions);
274
+ return reaches.length === 0 ? [] : [reachLine(owner, reaches)];
275
+ });
276
+ const measureLines = measures.map(({ name, measure }) => `${name} (${measure.unit}): ${sentence(measure.description)}`);
277
+ return [
278
+ `Aggregates: ${RECORDS_OR_AGGREGATES} (POST /api/ontology/aggregate, or the aggregate tool).`,
279
+ `${INDENT}Measures, as <entity>.<measure>:`,
280
+ ...measureLines.map((line) => `${INDENT}${INDENT}${line}`),
281
+ ...(reachLines.length === 0
282
+ ? []
283
+ : [
284
+ `${INDENT}Group and filter by <entity>.<property>. Where a target says write the route, ` +
285
+ "name it from the measure's entity: <entity>.<relationship>.<property>. Through " +
286
+ "to-one relationships:",
287
+ ...reachLines.map((line) => `${INDENT}${INDENT}${line}`),
288
+ ]),
289
+ ].join("\n");
184
290
  }
@@ -1,4 +1,4 @@
1
- import type { OntologyManifest } from "../manifest.js";
1
+ import { type OntologyManifest } from "../manifest.js";
2
2
  /** The resolved projection for one immutable generation. `id` is always included. */
3
3
  export interface ReplicationSelection {
4
4
  readonly entityId: string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isValueMatched } from "../manifest.js";
2
3
  import { queryFields } from "../query.js";
3
4
  export function resolveReplicationSelection(manifest, exclusions = []) {
4
5
  const entities = manifest.entities.filter((entity) => !entity.builtIn && !entity.source);
@@ -18,13 +19,22 @@ export function resolveReplicationSelection(manifest, exclusions = []) {
18
19
  if (item.relationships?.some((relationship) => !relationships.has(relationship))) {
19
20
  throw new Error(`Unknown replication relationship on ${entity.name}.`);
20
21
  }
22
+ // A value-matched relationship has no `<name>_id`: its reference is the property `on` names.
23
+ const matched = entity.relationships.find((relationship) => item.relationships?.includes(relationship.name) && isValueMatched(relationship));
24
+ if (matched !== undefined) {
25
+ throw new Error(`${entity.name}.${matched.name} is matched by value; exclude its property ` +
26
+ `${matched.on?.property ?? ""} instead.`);
27
+ }
21
28
  }
22
29
  const included = entities.filter((entity) => !exclusions.some((item) => item.entityId === entity.id && item.disabled));
23
30
  const selection = included.map((entity) => {
24
31
  const excluded = exclusions.find((item) => item.entityId === entity.id);
32
+ const keyed = entity.relationships.filter((relationship) => relationship.cardinality === "one" && !isValueMatched(relationship));
25
33
  const excludedFields = new Set([
26
34
  ...(excluded?.fields ?? []),
27
- ...(excluded?.relationships ?? []).map((relationship) => `${relationship}_id`),
35
+ ...keyed
36
+ .filter((relationship) => excluded?.relationships?.includes(relationship.name))
37
+ .map((relationship) => `${relationship.name}_id`),
28
38
  ]);
29
39
  return {
30
40
  entityId: entity.id,
@@ -0,0 +1,9 @@
1
+ import { SqlClient } from "effect/unstable/sql";
2
+ import type { Ontology } from "../ontology.js";
3
+ import type { AgentStore } from "../service/runtime-contract.js";
4
+ /**
5
+ * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
6
+ * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
7
+ * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
8
+ */
9
+ export declare function sqlAgentStore(ontology: Ontology, sql: SqlClient.SqlClient): AgentStore;
@@ -0,0 +1,145 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql";
3
+ import { AGENT_IDENTITIES_TABLE, AGENT_PAIRINGS_TABLE, USER_TABLE } from "../migrate/storage.js";
4
+ import { resolveMemberById } from "./principals.js";
5
+ import { isRecordId } from "./records.js";
6
+ const PAIRINGS = `"${AGENT_PAIRINGS_TABLE}"`;
7
+ const IDENTITIES = `"${AGENT_IDENTITIES_TABLE}"`;
8
+ const USERS = `"${USER_TABLE}"`;
9
+ /** A timestamp as epoch milliseconds, which every driver returns as a plain number. */
10
+ function epochMs(column) {
11
+ return `(extract(epoch from ${column}) * 1000)::double precision`;
12
+ }
13
+ const PAIRING_COLUMNS = `"id"::text AS "id", "provider", "external_id", "display", ` +
14
+ `${epochMs(`"expires_at"`)} AS "expires_at"`;
15
+ const IDENTITY_COLUMNS = `"id"::text AS "id", "provider", "external_id", "user_id"::text AS "user_id", "display", ` +
16
+ `${epochMs(`"created_at"`)} AS "created_at", ${epochMs(`"revoked_at"`)} AS "revoked_at"`;
17
+ function nullableText(value) {
18
+ return value === null || value === undefined ? null : String(value);
19
+ }
20
+ function pairingOf(row) {
21
+ return {
22
+ id: String(row.id),
23
+ provider: String(row.provider),
24
+ externalId: String(row.external_id),
25
+ display: nullableText(row.display),
26
+ expiresAt: new Date(Number(row.expires_at)),
27
+ };
28
+ }
29
+ function identityOf(row) {
30
+ return {
31
+ id: String(row.id),
32
+ provider: String(row.provider),
33
+ externalId: String(row.external_id),
34
+ userId: String(row.user_id),
35
+ display: nullableText(row.display),
36
+ createdAt: new Date(Number(row.created_at)),
37
+ revokedAt: row.revoked_at === null ? null : new Date(Number(row.revoked_at)),
38
+ };
39
+ }
40
+ /**
41
+ * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
42
+ * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
43
+ * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
44
+ */
45
+ export function sqlAgentStore(ontology, sql) {
46
+ const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
47
+ const memberPrincipal = (userId) => resolveMemberById(ontology, userId).pipe(Effect.provideService(SqlClient.SqlClient, sql));
48
+ return {
49
+ savePairing: (pairing) => sql
50
+ .withTransaction(Effect.gen(function* () {
51
+ yield* rows(`DELETE FROM ${PAIRINGS} WHERE "expires_at" <= now()`);
52
+ const inserted = yield* rows([
53
+ `INSERT INTO ${PAIRINGS}`,
54
+ ` ("id", "code_hash", "provider", "external_id", "display", "expires_at")`,
55
+ `VALUES ($1::uuid, $2, $3, $4, $5, $6::timestamptz)`,
56
+ `ON CONFLICT ("code_hash") DO NOTHING`,
57
+ `RETURNING "id"`,
58
+ ].join("\n"), [
59
+ pairing.id,
60
+ pairing.codeHash,
61
+ pairing.provider,
62
+ pairing.externalId,
63
+ pairing.display,
64
+ pairing.expiresAt.toISOString(),
65
+ ]);
66
+ return inserted.length > 0;
67
+ }))
68
+ .pipe(Effect.orDie),
69
+ findPairing: (codeHash) => rows([
70
+ `SELECT ${PAIRING_COLUMNS}`,
71
+ `FROM ${PAIRINGS}`,
72
+ `WHERE "code_hash" = $1 AND "consumed_at" IS NULL AND "expires_at" > now()`,
73
+ ].join("\n"), [codeHash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : pairingOf(row)))),
74
+ approvePairing: ({ codeHash, userId, identityId }) => sql
75
+ .withTransaction(Effect.gen(function* () {
76
+ const [consumed] = yield* rows([
77
+ `UPDATE ${PAIRINGS} SET "consumed_at" = now()`,
78
+ `WHERE "code_hash" = $1 AND "consumed_at" IS NULL AND "expires_at" > now()`,
79
+ `RETURNING ${PAIRING_COLUMNS}`,
80
+ ].join("\n"), [codeHash]);
81
+ if (consumed === undefined) {
82
+ return undefined;
83
+ }
84
+ const pairing = pairingOf(consumed);
85
+ // Two approvals that replace the same account's or the same member's identity run one
86
+ // after the other, so the partial unique indexes never refuse a valid one.
87
+ yield* rows(`SELECT pg_advisory_xact_lock(hashtext($1)), pg_advisory_xact_lock(hashtext($2))`, [
88
+ `account:${pairing.provider}:${pairing.externalId}`,
89
+ `member:${userId}:${pairing.provider}`,
90
+ ]);
91
+ yield* rows([
92
+ `UPDATE ${IDENTITIES} SET "revoked_at" = now()`,
93
+ `WHERE "revoked_at" IS NULL AND "provider" = $1`,
94
+ ` AND ("external_id" = $2 OR "user_id" = $3::uuid)`,
95
+ ].join("\n"), [pairing.provider, pairing.externalId, userId]);
96
+ const [created] = yield* rows([
97
+ `INSERT INTO ${IDENTITIES} ("id", "provider", "external_id", "user_id", "display")`,
98
+ `VALUES ($1::uuid, $2, $3, $4::uuid, $5)`,
99
+ `RETURNING ${IDENTITY_COLUMNS}`,
100
+ ].join("\n"), [identityId, pairing.provider, pairing.externalId, userId, pairing.display]);
101
+ return created === undefined ? undefined : identityOf(created);
102
+ }))
103
+ .pipe(Effect.orDie),
104
+ findIdentity: ({ provider, externalId }) => rows([
105
+ `SELECT ${IDENTITY_COLUMNS}`,
106
+ `FROM ${IDENTITIES}`,
107
+ `WHERE "provider" = $1 AND "external_id" = $2`,
108
+ `ORDER BY "revoked_at" IS NOT NULL, "revoked_at" DESC`,
109
+ `LIMIT 1`,
110
+ ].join("\n"), [provider, externalId]).pipe(Effect.map(([row]) => (row === undefined ? undefined : identityOf(row)))),
111
+ listIdentities: (userId) => isRecordId(userId)
112
+ ? rows([
113
+ `SELECT ${IDENTITY_COLUMNS}`,
114
+ `FROM ${IDENTITIES}`,
115
+ `WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL`,
116
+ `ORDER BY "created_at", "id"`,
117
+ ].join("\n"), [userId]).pipe(Effect.map((found) => found.map(identityOf)))
118
+ : Effect.succeed([]),
119
+ revokeIdentity: ({ id, userId }) => isRecordId(id) && isRecordId(userId)
120
+ ? rows([
121
+ `UPDATE ${IDENTITIES} SET "revoked_at" = now()`,
122
+ `WHERE "id" = $1::uuid AND "user_id" = $2::uuid AND "revoked_at" IS NULL`,
123
+ `RETURNING "id"`,
124
+ ].join("\n"), [id, userId]).pipe(Effect.map((revoked) => revoked.length > 0))
125
+ : Effect.succeed(false),
126
+ member: (userId) => Effect.gen(function* () {
127
+ if (!isRecordId(userId)) {
128
+ return undefined;
129
+ }
130
+ const principal = yield* memberPrincipal(userId);
131
+ if (principal === undefined) {
132
+ return undefined;
133
+ }
134
+ const [user] = yield* rows(`SELECT "name" FROM ${USERS} WHERE "id" = $1::uuid`, [userId]);
135
+ return { name: String(user?.name ?? ""), principal };
136
+ }),
137
+ actingMember: (userId) => Effect.gen(function* () {
138
+ if (!isRecordId(userId)) {
139
+ return undefined;
140
+ }
141
+ const [paired] = yield* rows(`SELECT 1 FROM ${IDENTITIES} WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL LIMIT 1`, [userId]);
142
+ return paired === undefined ? undefined : yield* memberPrincipal(userId);
143
+ }),
144
+ };
145
+ }
@@ -6,6 +6,7 @@ import { maintainBlobStorage } from "../storage/maintenance.js";
6
6
  import { blobRuntime } from "../storage/sessions.js";
7
7
  import { readCatalog } from "../migrate/apply.js";
8
8
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
9
+ import { sqlAgentStore } from "./agents.js";
9
10
  import { generatedActions } from "./generated-actions.js";
10
11
  import { sqlOAuthStore } from "./oauth.js";
11
12
  import { runAction } from "./pipeline.js";
@@ -69,6 +70,7 @@ export function backendLayer(ontology, blobStorage) {
69
70
  return state?.enabled === true;
70
71
  }).pipe(Effect.orDie),
71
72
  oauth: sqlOAuthStore(ontology, sql),
73
+ agents: sqlAgentStore(ontology, sql),
72
74
  });
73
75
  }));
74
76
  }
@@ -0,0 +1,47 @@
1
+ import { Schema } from "effect";
2
+ import type { Principal } from "../../runtime.js";
3
+ import type { AgentIdentity } from "../runtime-contract.js";
4
+ /** How long a member has to open the connect link, sign in and approve the pairing. */
5
+ export declare const PAIRING_TTL_MS: number;
6
+ /**
7
+ * The letters and digits a pairing code uses: no 0/O, 1/I/L, so that a code read aloud or typed from
8
+ * a phone comes out right. 31 symbols, 8 of them: about 40 bits, against ten minutes of guessing by
9
+ * signed-in members only.
10
+ */
11
+ export declare const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
12
+ export declare const PAIRING_CODE_LENGTH = 8;
13
+ /** A new pairing code, from the operating system's random source without modulo bias. */
14
+ export declare function newPairingCode(): string;
15
+ /**
16
+ * A code as the member's browser sends it, in the canonical form it was hashed in: upper case,
17
+ * without the spaces or dashes someone may type between groups. `undefined` when it cannot be one.
18
+ */
19
+ export declare function canonicalPairingCode(value: string): string | undefined;
20
+ /** A channel's name: `telegram`, `slack`. It is part of a URL path and of every identity. */
21
+ export declare const Provider: Schema.String;
22
+ /** `POST /api/ontology/agent/pairings`: the account an unknown sender writes from. */
23
+ export declare const PairingRequest: Schema.Struct<{
24
+ readonly provider: Schema.String;
25
+ readonly external_id: Schema.String;
26
+ readonly display: Schema.optionalKey<Schema.NullOr<Schema.String>>;
27
+ }>;
28
+ export type PairingRequest = typeof PairingRequest.Type;
29
+ /** An identity on the wire, as the member's connect page and settings show it. */
30
+ export declare function identityForWire(identity: AgentIdentity): {
31
+ id: string;
32
+ provider: string;
33
+ external_id: string;
34
+ display: string | null;
35
+ created_at: string;
36
+ };
37
+ /** The member the agent acts for, as `GET …/identities/{provider}/{external_id}` answers. */
38
+ export declare function memberForWire(input: {
39
+ readonly name: string;
40
+ readonly principal: Principal;
41
+ }): {
42
+ user: {
43
+ id: string;
44
+ name: string;
45
+ };
46
+ roles: readonly string[];
47
+ };