@segmentstream-cloud/ontology 0.18.0 → 0.18.1

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.
@@ -1,6 +1,7 @@
1
1
  import { isEntity } from "./entity.js";
2
2
  import { toCamelCase } from "./names.js";
3
3
  import { propertyDefinition } from "./property.js";
4
+ import { isIncludeList } from "./query.js";
4
5
  import { isRecord, nameToWire } from "./authoring-names.js";
5
6
  function targetDefinition(target) {
6
7
  const resolved = typeof target === "function" ? target() : target;
@@ -113,13 +114,15 @@ export function findToWire(options, definition) {
113
114
  : {
114
115
  orderBy: options.orderBy.map((order) => ({ ...order, field: nameToWire(order.field) })),
115
116
  }),
116
- ...(options.include === undefined
117
- ? {}
118
- : {
119
- include: Object.fromEntries(Object.entries(options.include).map(([name, value]) => [nameToWire(name), value])),
120
- }),
117
+ ...(options.include === undefined ? {} : { include: includeToWire(options.include) }),
121
118
  };
122
119
  }
120
+ /** The relationships a find includes, by wire name, in the form the handler gave them. */
121
+ function includeToWire(include) {
122
+ return isIncludeList(include)
123
+ ? include.map(nameToWire)
124
+ : Object.fromEntries(Object.entries(include).map(([name, value]) => [nameToWire(name), value]));
125
+ }
123
126
  /** Current action edits use the same property names and values as authored entity records. */
124
127
  export function editsFromWire(edits, entities) {
125
128
  return edits.map((edit) => {
package/dist/query.d.ts CHANGED
@@ -16,14 +16,15 @@ export type StorageType = PropertyType | "uuid";
16
16
  * "or": [{ "fitScore": { "gte": 80 } }, { "source": "pilot_request" }]
17
17
  * },
18
18
  * "orderBy": [{ "field": "created_at", "direction": "desc" }],
19
- * "include": { "owner": true },
19
+ * "include": ["owner"],
20
20
  * "limit": 50
21
21
  * }
22
22
  * ```
23
23
  *
24
24
  * {@link Query} checks the structure for any entity; {@link entityQuerySchema} builds the precise
25
25
  * schema of one entity from the manifest, with its fields, their operators and its relationships.
26
- * The runtime validates every query with the precise schema.
26
+ * The runtime validates every query with the precise schema. `include` also takes the object form
27
+ * `{ "owner": true }`, which a list of names decodes into.
27
28
  */
28
29
  export declare const DEFAULT_QUERY_LIMIT = 50;
29
30
  export declare const MAX_QUERY_LIMIT = 200;
@@ -78,6 +79,14 @@ export declare const OrderBy: Schema.Struct<{
78
79
  readonly direction: Schema.optionalKey<Schema.Literals<readonly ["asc", "desc"]>>;
79
80
  }>;
80
81
  export type OrderBy = typeof OrderBy.Type;
82
+ /** The relationships to include, once decoded: `{ owner: true, tags: true }`. */
83
+ export type Included = {
84
+ readonly [relationship: string]: true;
85
+ };
86
+ /** Whether an `include` is the list form, `["owner", "tags"]`. */
87
+ export declare function isIncludeList<L extends string>(include: ReadonlyArray<L> | object): include is ReadonlyArray<L>;
88
+ /** The relationship names an `include` lists, in either form. */
89
+ export declare function includedRelationships(include: Query["include"]): ReadonlyArray<string>;
81
90
  /** A query on any entity. The runtime checks the fields against the entity. */
82
91
  export declare const Query: Schema.Struct<{
83
92
  readonly where: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Codec<Schema.Json, Schema.Json, never, never>>>;
@@ -85,17 +94,18 @@ export declare const Query: Schema.Struct<{
85
94
  readonly field: Schema.String;
86
95
  readonly direction: Schema.optionalKey<Schema.Literals<readonly ["asc", "desc"]>>;
87
96
  }>>>;
88
- readonly include: Schema.optionalKey<Schema.$Record<Schema.String, Schema.Literal<true>>>;
97
+ readonly include: Schema.optionalKey<Schema.Codec<Included, readonly string[] | Included, never, never>>;
89
98
  readonly after: Schema.optionalKey<Schema.String>;
90
99
  readonly limit: Schema.optionalKey<Schema.Int>;
91
100
  }>;
92
- /** A query. `where` follows {@link Where}; `include` adds related records by relationship name. */
101
+ /**
102
+ * A query. `where` follows {@link Where}; `include` adds related records by relationship name, as a
103
+ * list, `["owner", "tags"]`, or as an object, `{ owner: true, tags: true }`.
104
+ */
93
105
  export interface Query {
94
106
  readonly where?: Where;
95
107
  readonly orderBy?: ReadonlyArray<OrderBy>;
96
- readonly include?: {
97
- readonly [relationship: string]: true;
98
- };
108
+ readonly include?: ReadonlyArray<string> | Included;
99
109
  readonly after?: string;
100
110
  readonly limit?: number;
101
111
  }
@@ -160,9 +170,7 @@ export declare function isOrderable(field: QueryField): boolean;
160
170
  export interface EntityQuerySchemas {
161
171
  readonly where: Schema.Codec<Where>;
162
172
  readonly orderBy: Schema.Codec<ReadonlyArray<OrderBy>>;
163
- readonly include: Schema.Codec<{
164
- readonly [relationship: string]: true;
165
- }>;
173
+ readonly include: Schema.Codec<Included, ReadonlyArray<string> | Included>;
166
174
  readonly limit: Schema.Codec<number>;
167
175
  readonly after: Schema.Codec<string>;
168
176
  readonly query: Schema.Codec<Query>;
package/dist/query.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Schema } from "effect";
2
- import { wireStruct } from "./wire.js";
1
+ import { Schema, SchemaTransformation } from "effect";
2
+ import { expectedOneOf, wireStruct } from "./wire.js";
3
3
  import { isValueMatched } from "./manifest.js";
4
4
  import { BlobValue } from "./blob.js";
5
5
  /**
@@ -14,14 +14,15 @@ import { BlobValue } from "./blob.js";
14
14
  * "or": [{ "fitScore": { "gte": 80 } }, { "source": "pilot_request" }]
15
15
  * },
16
16
  * "orderBy": [{ "field": "created_at", "direction": "desc" }],
17
- * "include": { "owner": true },
17
+ * "include": ["owner"],
18
18
  * "limit": 50
19
19
  * }
20
20
  * ```
21
21
  *
22
22
  * {@link Query} checks the structure for any entity; {@link entityQuerySchema} builds the precise
23
23
  * schema of one entity from the manifest, with its fields, their operators and its relationships.
24
- * The runtime validates every query with the precise schema.
24
+ * The runtime validates every query with the precise schema. `include` also takes the object form
25
+ * `{ "owner": true }`, which a list of names decodes into.
25
26
  */
26
27
  export const DEFAULT_QUERY_LIMIT = 50;
27
28
  export const MAX_QUERY_LIMIT = 200;
@@ -56,11 +57,32 @@ const Limit = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: MAX_QUERY
56
57
  const After = Schema.String.annotate({
57
58
  description: "The nextCursor of the previous page. It is only valid for the same where and orderBy.",
58
59
  });
60
+ /** Whether an `include` is the list form, `["owner", "tags"]`. */
61
+ export function isIncludeList(include) {
62
+ return Array.isArray(include);
63
+ }
64
+ /** The relationship names an `include` lists, in either form. */
65
+ export function includedRelationships(include) {
66
+ if (include === undefined) {
67
+ return [];
68
+ }
69
+ return isIncludeList(include) ? [...new Set(include)] : Object.keys(include);
70
+ }
71
+ /** `["owner", "tags"]` decoded as `{ owner: true, tags: true }`; duplicate names are harmless. */
72
+ function includeList(names, included) {
73
+ return Schema.Array(names).pipe(Schema.decodeTo(included, SchemaTransformation.transform({
74
+ decode: (list) => Object.fromEntries(list.map((name) => [name, true])),
75
+ encode: (relationships) => Object.keys(relationships),
76
+ })));
77
+ }
78
+ const AnyIncluded = Schema.Record(Schema.String, Schema.Literal(true));
59
79
  /** A query on any entity. The runtime checks the fields against the entity. */
60
80
  export const Query = Schema.Struct({
61
81
  where: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
62
82
  orderBy: Schema.optionalKey(Schema.Array(OrderBy)),
63
- include: Schema.optionalKey(Schema.Record(Schema.String, Schema.Literal(true))),
83
+ include: Schema.optionalKey(
84
+ // The list member decodes into the object; the union's static type cannot say so.
85
+ Schema.Union([AnyIncluded, includeList(Schema.String, AnyIncluded)])),
64
86
  after: Schema.optionalKey(After),
65
87
  limit: Schema.optionalKey(Limit),
66
88
  }).annotate({ identifier: "Query" });
@@ -260,7 +282,7 @@ function fieldFilterSchema(field, identifier, wire) {
260
282
  operators.between = Schema.optionalKey(Schema.Tuple([value, value]));
261
283
  }
262
284
  const filter = wire
263
- ? wireStruct(operators, { isNull: "is_null", startsWith: "starts_with" })
285
+ ? wireStruct(operators, { isNull: "is_null", startsWith: "starts_with" }, "Unknown operator")
264
286
  : closedStruct(operators, "Unknown operator");
265
287
  const note = wire
266
288
  ? FILTER_DESCRIPTIONS[identifier]?.replaceAll("startsWith", "starts_with")
@@ -330,7 +352,7 @@ function manyFilterSchema(link, target) {
330
352
  some: Schema.optionalKey(target),
331
353
  none: Schema.optionalKey(target),
332
354
  every: Schema.optionalKey(target),
333
- }, "Unknown quantifier; use some, none or every").annotate({
355
+ }, "Unknown quantifier").annotate({
334
356
  description: `Whether some, none or every one of the related ${link.target} records match.`,
335
357
  });
336
358
  }
@@ -351,18 +373,13 @@ export function entityQuerySchemas(manifest, entityName, wire = false) {
351
373
  const entity = requireEntity(manifest, entityName);
352
374
  const pascal = pascalCase(entityName);
353
375
  const fields = queryFields(entity).filter(isOrderable);
354
- const include = {};
355
- for (const link of queryLinks(manifest, entityName)) {
356
- include[link.name] = Schema.optionalKey(Schema.Literal(true));
357
- }
376
+ const relationships = queryLinks(manifest, entityName).map((link) => link.name);
358
377
  const where = whereSchema(manifest, entity, true, new Map(), wire);
359
378
  const orderBy = Schema.Array(closedStruct({
360
379
  field: Schema.Literals(fields.map((field) => field.name)),
361
380
  direction: Schema.optionalKey(SortDirection),
362
- }, "Unknown key; use field and direction")).annotate({ description: "Sort keys in order; the id breaks ties. Ascending by default." });
363
- const included = closedStruct(include, "Unknown relationship").annotate({
364
- description: "Related records to add to each record, by relationship name.",
365
- });
381
+ }, "Unknown key")).annotate({ description: "Sort keys in order; the id breaks ties. Ascending by default." });
382
+ const included = includeSchema(relationships);
366
383
  const queryMembers = {
367
384
  where: Schema.optionalKey(where),
368
385
  orderBy: Schema.optionalKey(orderBy),
@@ -372,7 +389,7 @@ export function entityQuerySchemas(manifest, entityName, wire = false) {
372
389
  };
373
390
  const query = (wire
374
391
  ? wireStruct(queryMembers, { orderBy: "order_by" })
375
- : closedStruct(queryMembers, "Unknown key; use where, orderBy, include, after or limit")).annotate({ identifier: `${pascal}Query`, description: `A query on ${entity.plural}.` });
392
+ : closedStruct(queryMembers, "Unknown key")).annotate({ identifier: `${pascal}Query`, description: `A query on ${entity.plural}.` });
376
393
  // The structs are assembled from the manifest, so their static types are too wide to name; they
377
394
  // accept exactly the Query shape and decode it unchanged.
378
395
  const schemas = {
@@ -388,6 +405,28 @@ export function entityQuerySchemas(manifest, entityName, wire = false) {
388
405
  cache.set(manifest, byEntity);
389
406
  return schemas;
390
407
  }
408
+ /**
409
+ * The relationships a query includes: a list of names, `["owner", "tags"]`, or the object form
410
+ * `{ owner: true, tags: true }` that the list decodes into. Both reject an unknown relationship and
411
+ * name the ones there are.
412
+ */
413
+ function includeSchema(relationships) {
414
+ const fields = {};
415
+ for (const name of relationships) {
416
+ fields[name] = Schema.optionalKey(Schema.Literal(true));
417
+ }
418
+ const included = closedStruct(fields, "Unknown relationship");
419
+ const name = Schema.String.check(Schema.makeFilter((candidate) => relationships.includes(candidate) ||
420
+ expectedOneOf(`Unknown relationship "${candidate}"`, relationships), { toJsonSchema: () => (relationships.length > 0 ? { enum: relationships } : { not: {} }) }));
421
+ const example = relationships[0];
422
+ const forms = example === undefined
423
+ ? "a list of relationship names; there are none"
424
+ : `a list of relationship names, as in ["${example}"], or an object, as in {"${example}": true}`;
425
+ return Schema.Union([included, includeList(name, included)]).annotate({
426
+ description: `Related records to add to each record: ${forms}.`,
427
+ message: `Expected ${forms}`,
428
+ });
429
+ }
391
430
  /** The query schema of one entity; see {@link entityQuerySchemas}. */
392
431
  export function entityQuerySchema(manifest, entityName) {
393
432
  return entityQuerySchemas(manifest, entityName).query;
@@ -401,10 +440,11 @@ function escapeRegExp(value) {
401
440
  * same with a `patternProperties` entry that matches every undeclared key.
402
441
  */
403
442
  function closedStruct(fields, unknownKey) {
404
- const declared = Object.keys(fields).map(escapeRegExp).join("|");
443
+ const keys = Object.keys(fields);
444
+ const declared = keys.map(escapeRegExp).join("|");
405
445
  const undeclared = Schema.String.check(Schema.isPattern(new RegExp(`^(?!(?:${declared})$)`)));
406
446
  return Schema.StructWithRest(Schema.Struct(fields), [
407
- Schema.Record(undeclared, Schema.Never.annotate({ message: unknownKey })),
447
+ Schema.Record(undeclared, Schema.Never.annotate({ message: expectedOneOf(unknownKey, keys) })),
408
448
  ]);
409
449
  }
410
450
  function recordValueSchema(field) {
@@ -1,6 +1,6 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  import { NotFound } from "../errors.js";
3
- import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, isInstant, } from "../query.js";
3
+ import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, includedRelationships, isInstant, } from "../query.js";
4
4
  import { quoteIdentifier } from "../migrate/storage.js";
5
5
  import { afterCondition, decodeCursor, encodeCursor, orderByClause, sortKeys } from "./cursor.js";
6
6
  import { InvalidQuery } from "./errors.js";
@@ -120,7 +120,7 @@ export function runQuery(context, entity, query, options = {}) {
120
120
  export const MAX_INCLUDED = MAX_QUERY_LIMIT;
121
121
  function includeRelated(context, entity, records, include) {
122
122
  return Effect.gen(function* () {
123
- const names = Object.keys(include ?? {});
123
+ const names = includedRelationships(include);
124
124
  let items = [...records];
125
125
  if (records.length === 0) {
126
126
  return items;
package/dist/runtime.d.ts CHANGED
@@ -60,7 +60,8 @@ export interface FindOptions<R = StoredRecord, L extends string = never> {
60
60
  readonly field: keyof R & string;
61
61
  readonly direction?: "asc" | "desc";
62
62
  }>;
63
- readonly include?: {
63
+ /** Relationships to add to each record: `["owner", "tags"]`, or `{ owner: true, tags: true }`. */
64
+ readonly include?: ReadonlyArray<L> | {
64
65
  readonly [K in L]?: true;
65
66
  };
66
67
  readonly after?: string;
@@ -88,7 +88,7 @@ function relatedHint(planned, traversal) {
88
88
  const find = mcpToolName(traversal.target, "find");
89
89
  switch (traversal.kind) {
90
90
  case "one":
91
- return (`${traversal.name}: include {"${traversal.name}": true}, or ` +
91
+ return (`${traversal.name}: include ["${traversal.name}"], or ` +
92
92
  `${mcpToolName(traversal.target, "get")} with ${traversal.name}_id.`);
93
93
  case "inverseOfOne":
94
94
  return `${traversal.name}: ${find} with where {"${traversal.opposite}": {"id": {"eq": "${id}"}}}.`;
@@ -122,12 +122,17 @@ function readTools(planned, schemas, built) {
122
122
  ...planned.traversals.map((traversal) => relatedHint(planned, traversal)),
123
123
  ...matchedHints(planned),
124
124
  ].map((hint) => `- ${hint}`);
125
+ const includable = planned.traversals.slice(0, 2).map((traversal) => `"${traversal.name}"`);
126
+ const including = includable.length === 0
127
+ ? ""
128
+ : ` Pass include as a list of relationship names, as in [${includable.join(", ")}].`;
125
129
  return [
126
130
  {
127
131
  name: mcpToolName(planned.name, "find"),
128
132
  title: `Find ${definition.plural.toLowerCase()}`,
129
133
  description: `Filter, sort and page through ${definition.plural.toLowerCase()}, at most 200 at a ` +
130
- `time; pass next_cursor as after to read the next page. ${definition.description}\n\n` +
134
+ `time; pass next_cursor as after to read the next page.${including} ` +
135
+ `${definition.description}\n\n` +
131
136
  `${requires([read])} Including a relationship, or filtering through one, requires ` +
132
137
  "reading its entity too." +
133
138
  (related.length > 0 ? `\n\nRelated records:\n${related.join("\n")}` : ""),
@@ -1,7 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  import type { HttpServerRequest } from "effect/unstable/http";
3
3
  import { PermissionDenied } from "../errors.js";
4
- import type { Query, QueryResult } from "../query.js";
4
+ import { type Query, type QueryResult } from "../query.js";
5
5
  import { CurrentPrincipal } from "./authentication.js";
6
6
  import type { AfterCommitHook } from "./config.js";
7
7
  import type { ReplicationKick } from "./replication.js";
@@ -5,6 +5,7 @@ import { principalFromWire } from "../authoring.js";
5
5
  import { PermissionDenied } from "../errors.js";
6
6
  import { toCamelCase } from "../names.js";
7
7
  import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
8
+ import { includedRelationships } from "../query.js";
8
9
  import { CurrentPrincipal } from "./authentication.js";
9
10
  import { AgentChannel } from "../agent-chat.js";
10
11
  import { OIDC_TOKEN_HEADER } from "./connections.js";
@@ -18,7 +19,7 @@ import { InvalidActionInput, RequestId } from "./errors.js";
18
19
  /** The query the runtime receives: every key present only when set, and no empty `include`. */
19
20
  export function toQuery(decoded) {
20
21
  const { include, ...rest } = decoded;
21
- return include !== undefined && Object.keys(include).length > 0 ? { ...rest, include } : rest;
22
+ return includedRelationships(include).length > 0 ? { ...rest, include } : rest;
22
23
  }
23
24
  function page(result) {
24
25
  return { items: result.items, nextCursor: result.nextCursor ?? null };
@@ -55,7 +56,7 @@ function filterReads(where, targets, reads) {
55
56
  function queryReads(entity, query, targets) {
56
57
  const own = targets.get(entity) ?? new Map();
57
58
  const reads = new Set([entity]);
58
- for (const name of Object.keys(query.include ?? {})) {
59
+ for (const name of includedRelationships(query.include)) {
59
60
  const target = own.get(name);
60
61
  if (target !== undefined) {
61
62
  reads.add(target);
@@ -194,7 +194,7 @@ function schemasOf(plan, planned, recordOf) {
194
194
  include: Schema.optionalKey(includeParam(query.include)),
195
195
  after: Schema.optionalKey(query.after),
196
196
  limit: Schema.optionalKey(query.limit),
197
- }, { orderBy: "order_by" }),
197
+ }, { orderBy: "order_by" }, "Unknown parameter"),
198
198
  includeParams: {
199
199
  include: Schema.optionalKey(includeParam(query.include)),
200
200
  },
package/dist/wire.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { Schema } from "effect";
2
+ /**
3
+ * `Unknown operator. Expected one of: eq, ne, in`: what a closed struct reports for a key it does
4
+ * not declare, so that a caller can correct the key without guessing. Long lists are cut short.
5
+ */
6
+ export declare function expectedOneOf(problem: string, accepted: ReadonlyArray<string>): string;
2
7
  /** A closed wire object with explicit key mappings and an unchanged decoded TypeScript shape. */
3
8
  export declare function wireStruct<const F extends Schema.Struct.Fields, const M extends {
4
9
  readonly [K in keyof F]?: string;
5
- }>(fields: F, mapping: M): Schema.encodeKeys<Schema.Struct<F>, M>;
10
+ }>(fields: F, mapping: M, unknownKey?: string): Schema.encodeKeys<Schema.Struct<F>, M>;
package/dist/wire.js CHANGED
@@ -1,17 +1,31 @@
1
1
  import { Schema, SchemaTransformation } from "effect";
2
+ /** An unknown-key message names at most this many accepted keys and counts the rest. */
3
+ const MAX_LISTED_KEYS = 30;
4
+ /**
5
+ * `Unknown operator. Expected one of: eq, ne, in`: what a closed struct reports for a key it does
6
+ * not declare, so that a caller can correct the key without guessing. Long lists are cut short.
7
+ */
8
+ export function expectedOneOf(problem, accepted) {
9
+ if (accepted.length === 0) {
10
+ return `${problem}; there are none`;
11
+ }
12
+ const listed = accepted.slice(0, MAX_LISTED_KEYS).join(", ");
13
+ const rest = accepted.length - MAX_LISTED_KEYS;
14
+ return `${problem}. Expected one of: ${listed}${rest > 0 ? `, and ${rest} more` : ""}`;
15
+ }
2
16
  /** A closed wire object with explicit key mappings and an unchanged decoded TypeScript shape. */
3
- export function wireStruct(fields, mapping) {
17
+ export function wireStruct(fields, mapping, unknownKey = "Unknown key") {
4
18
  const codec = Schema.Struct(fields).pipe(Schema.encodeKeys(mapping));
5
19
  const encodedFields = Object.fromEntries(Object.entries(fields).map(([key, field]) => [
6
20
  mapping[key] ?? key,
7
21
  field,
8
22
  ]));
9
- const names = Object.keys(encodedFields).join("|");
10
- const unknown = Schema.String.check(Schema.isPattern(new RegExp(`^(?!(?:${names})$)`)));
23
+ const encodedKeys = Object.keys(encodedFields);
24
+ const unknown = Schema.String.check(Schema.isPattern(new RegExp(`^(?!(?:${encodedKeys.join("|")})$)`)));
11
25
  const reverse = Object.fromEntries(Object.entries(mapping).map(([key, value]) => [value, key]));
12
26
  const rename = (value, keys) => Object.fromEntries(Object.entries(value).map(([key, child]) => [keys[key] ?? key, child]));
13
27
  const encoded = Schema.StructWithRest(Schema.Struct(encodedFields), [
14
- Schema.Record(unknown, Schema.Never.annotate({ message: "Unknown wire key" })),
28
+ Schema.Record(unknown, Schema.Never.annotate({ message: expectedOneOf(unknownKey, encodedKeys) })),
15
29
  ]);
16
30
  const decoded = Schema.toType(codec);
17
31
  return encoded.pipe(Schema.decodeTo(decoded, SchemaTransformation.transform({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.18.0",
3
+ "version": "0.18.1",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",