@segmentstream-cloud/ontology 0.6.0 → 0.8.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 (53) hide show
  1. package/README.md +14 -2
  2. package/dist/analyze.js +58 -17
  3. package/dist/authoring-input.d.ts +3 -0
  4. package/dist/authoring-input.js +107 -0
  5. package/dist/authoring-names.d.ts +4 -0
  6. package/dist/authoring-names.js +13 -0
  7. package/dist/authoring-records.d.ts +16 -0
  8. package/dist/authoring-records.js +144 -0
  9. package/dist/authoring.d.ts +12 -0
  10. package/dist/authoring.js +237 -0
  11. package/dist/connection.js +1 -1
  12. package/dist/entity.d.ts +1 -2
  13. package/dist/entity.js +31 -8
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/manifest.d.ts +468 -10
  17. package/dist/manifest.js +46 -6
  18. package/dist/migrate/apply.d.ts +1 -1
  19. package/dist/migrate/apply.js +5 -4
  20. package/dist/migrate/model.d.ts +112 -2
  21. package/dist/migrate/plan.js +11 -9
  22. package/dist/migrate/storage.d.ts +6 -6
  23. package/dist/migrate/storage.js +25 -15
  24. package/dist/names.d.ts +11 -11
  25. package/dist/names.js +36 -13
  26. package/dist/permissions.d.ts +2 -2
  27. package/dist/permissions.js +8 -8
  28. package/dist/property.js +2 -1
  29. package/dist/query.d.ts +6 -6
  30. package/dist/query.js +41 -30
  31. package/dist/runtime/cursor.js +1 -1
  32. package/dist/runtime/generated-actions.d.ts +4 -4
  33. package/dist/runtime/generated-actions.js +5 -9
  34. package/dist/runtime/history.js +1 -1
  35. package/dist/runtime/model.d.ts +2 -2
  36. package/dist/runtime/model.js +1 -1
  37. package/dist/runtime/principals.js +16 -13
  38. package/dist/runtime/reads.js +1 -1
  39. package/dist/runtime/records.js +1 -1
  40. package/dist/runtime/sql-runtime.js +1 -1
  41. package/dist/service/config.d.ts +1 -1
  42. package/dist/service/errors.d.ts +1 -0
  43. package/dist/service/errors.js +5 -1
  44. package/dist/service/handlers.js +27 -18
  45. package/dist/service/runtime-contract.d.ts +1 -1
  46. package/dist/service/schemas.d.ts +201 -26
  47. package/dist/service/schemas.js +30 -21
  48. package/dist/service/service.js +11 -4
  49. package/dist/user.js +3 -0
  50. package/dist/warehouse-backed.js +3 -3
  51. package/dist/wire.d.ts +5 -0
  52. package/dist/wire.js +21 -0
  53. package/package.json +1 -1
package/dist/query.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Schema } from "effect";
2
+ import { wireStruct } from "./wire.js";
2
3
  /**
3
4
  * The query language of `find` and of the HTTP API's list and query endpoints.
4
5
  *
@@ -10,7 +11,7 @@ import { Schema } from "effect";
10
11
  * "tags": { "some": { "name": { "eq": "priority" } } },
11
12
  * "or": [{ "fitScore": { "gte": 80 } }, { "source": "pilot_request" }]
12
13
  * },
13
- * "orderBy": [{ "field": "createdAt", "direction": "desc" }],
14
+ * "orderBy": [{ "field": "created_at", "direction": "desc" }],
14
15
  * "include": { "owner": true },
15
16
  * "limit": 50
16
17
  * }
@@ -72,10 +73,10 @@ export function queryResultSchema(record) {
72
73
  }
73
74
  const SYSTEM_FIELDS = [
74
75
  { name: "id", storage: "uuid", nullable: false },
75
- { name: "createdAt", storage: "timestamp", nullable: false },
76
- { name: "updatedAt", storage: "timestamp", nullable: false },
77
- { name: "createdBy", storage: "uuid", nullable: true },
78
- { name: "updatedBy", storage: "uuid", nullable: true },
76
+ { name: "created_at", storage: "timestamp", nullable: false },
77
+ { name: "updated_at", storage: "timestamp", nullable: false },
78
+ { name: "created_by", storage: "uuid", nullable: true },
79
+ { name: "updated_by", storage: "uuid", nullable: true },
79
80
  ];
80
81
  /** The fields of an entity's records, in record order. */
81
82
  export function queryFields(entity) {
@@ -90,7 +91,7 @@ export function queryFields(entity) {
90
91
  const keys = entity.relationships
91
92
  .filter((relationship) => relationship.cardinality === "one")
92
93
  .map((relationship) => ({
93
- name: `${relationship.name}Id`,
94
+ name: `${relationship.name}_id`,
94
95
  storage: "uuid",
95
96
  nullable: relationship.optional === true,
96
97
  }));
@@ -224,7 +225,7 @@ function isOperatorObject(value) {
224
225
  return (typeof value === "object" &&
225
226
  value !== null &&
226
227
  !Array.isArray(value) &&
227
- Object.keys(value).every((key) => FILTER_OPERATORS.includes(key)));
228
+ Object.keys(value).every((key) => [...FILTER_OPERATORS, "is_null", "starts_with"].includes(key)));
228
229
  }
229
230
  /** The bare-value form of `eq`. For JSON, an object of operators is a filter, never a value. */
230
231
  function shorthandSchema(field, value) {
@@ -233,7 +234,7 @@ function shorthandSchema(field, value) {
233
234
  }
234
235
  return JsonValue.check(Schema.makeFilter((candidate) => !isOperatorObject(candidate) || "Expected a filter"));
235
236
  }
236
- function fieldFilterSchema(field, identifier) {
237
+ function fieldFilterSchema(field, identifier, wire) {
237
238
  const value = fieldValueSchema(field);
238
239
  const operators = {
239
240
  eq: Schema.optionalKey(value),
@@ -251,24 +252,28 @@ function fieldFilterSchema(field, identifier) {
251
252
  }
252
253
  operators.between = Schema.optionalKey(Schema.Tuple([value, value]));
253
254
  }
254
- const filter = closedStruct(operators, "Unknown operator");
255
- const note = FILTER_DESCRIPTIONS[identifier];
255
+ const filter = wire
256
+ ? wireStruct(operators, { isNull: "is_null", startsWith: "starts_with" })
257
+ : closedStruct(operators, "Unknown operator");
258
+ const note = wire
259
+ ? FILTER_DESCRIPTIONS[identifier]?.replaceAll("startsWith", "starts_with")
260
+ : FILTER_DESCRIPTIONS[identifier];
256
261
  return Schema.Union([filter, shorthandSchema(field, value), Schema.Null]).annotate({
257
262
  identifier,
258
263
  description: "Conditions on the field, all of which must hold. A bare value means eq, null means " +
259
- `isNull: true, and ne also matches empty values.${note === undefined ? "" : ` ${note}`}`,
264
+ `${wire ? "is_null" : "isNull"}: true, and ne also matches empty values.${note === undefined ? "" : ` ${note}`}`,
260
265
  });
261
266
  }
262
- function filterFor(entity, field, schemas) {
267
+ function filterFor(entity, field, schemas, wire) {
263
268
  if (field.storage === "select") {
264
269
  const identifier = `${pascalCase(entity.name)}${pascalCase(field.name)}Filter`;
265
- const existing = schemas.get(identifier) ?? fieldFilterSchema(field, identifier);
270
+ const existing = schemas.get(identifier) ?? fieldFilterSchema(field, identifier, wire);
266
271
  schemas.set(identifier, existing);
267
272
  return existing;
268
273
  }
269
274
  const identifier = FILTER_IDENTIFIERS[field.storage];
270
- const existing = sharedFilters.get(identifier) ?? fieldFilterSchema(field, identifier);
271
- sharedFilters.set(identifier, existing);
275
+ const existing = sharedFilters.get(`${identifier}:${wire}`) ?? fieldFilterSchema(field, identifier, wire);
276
+ sharedFilters.set(`${identifier}:${wire}`, existing);
272
277
  return existing;
273
278
  }
274
279
  /** `pilot_request` → `PilotRequest`, `fitScore` → `FitScore`. */
@@ -282,7 +287,7 @@ function pascalCase(name) {
282
287
  * The `where` of an entity. With `links`, it includes the entity's relationships, whose targets are
283
288
  * filtered by their own fields only: relationship paths are one level deep.
284
289
  */
285
- function whereSchema(manifest, entity, links, schemas) {
290
+ function whereSchema(manifest, entity, links, schemas, wire) {
286
291
  const identifier = `${pascalCase(entity.name)}${links ? "Where" : "FieldsWhere"}`;
287
292
  const existing = schemas.get(identifier);
288
293
  if (existing !== undefined) {
@@ -299,11 +304,11 @@ function whereSchema(manifest, entity, links, schemas) {
299
304
  const fields = {};
300
305
  schemas.set(identifier, self);
301
306
  for (const field of queryFields(entity)) {
302
- fields[field.name] = Schema.optionalKey(filterFor(entity, field, schemas));
307
+ fields[field.name] = Schema.optionalKey(filterFor(entity, field, schemas, wire));
303
308
  }
304
309
  if (links) {
305
310
  for (const link of queryLinks(manifest, entity.name)) {
306
- const target = whereSchema(manifest, requireEntity(manifest, link.target), false, schemas);
311
+ const target = whereSchema(manifest, requireEntity(manifest, link.target), false, schemas, wire);
307
312
  fields[link.name] = Schema.optionalKey(link.cardinality === "one" ? target : manyFilterSchema(link, target));
308
313
  }
309
314
  }
@@ -322,6 +327,7 @@ function manyFilterSchema(link, target) {
322
327
  description: `Whether some, none or every one of the related ${link.target} records match.`,
323
328
  });
324
329
  }
330
+ const wireQuerySchemas = new WeakMap();
325
331
  const querySchemas = new WeakMap();
326
332
  /**
327
333
  * The precise query schemas of one entity: its fields with the operators of their types, its
@@ -329,8 +335,9 @@ const querySchemas = new WeakMap();
329
335
  * include. Every struct is closed: an unknown field, operator or key is an issue whatever the parse
330
336
  * options, never a silently ignored filter.
331
337
  */
332
- export function entityQuerySchemas(manifest, entityName) {
333
- const cached = querySchemas.get(manifest)?.get(entityName);
338
+ export function entityQuerySchemas(manifest, entityName, wire = false) {
339
+ const cache = wire ? wireQuerySchemas : querySchemas;
340
+ const cached = cache.get(manifest)?.get(entityName);
334
341
  if (cached !== undefined) {
335
342
  return cached;
336
343
  }
@@ -341,7 +348,7 @@ export function entityQuerySchemas(manifest, entityName) {
341
348
  for (const link of queryLinks(manifest, entityName)) {
342
349
  include[link.name] = Schema.optionalKey(Schema.Literal(true));
343
350
  }
344
- const where = whereSchema(manifest, entity, true, new Map());
351
+ const where = whereSchema(manifest, entity, true, new Map(), wire);
345
352
  const orderBy = Schema.Array(closedStruct({
346
353
  field: Schema.Literals(fields.map((field) => field.name)),
347
354
  direction: Schema.optionalKey(SortDirection),
@@ -349,13 +356,16 @@ export function entityQuerySchemas(manifest, entityName) {
349
356
  const included = closedStruct(include, "Unknown relationship").annotate({
350
357
  description: "Related records to add to each record, by relationship name.",
351
358
  });
352
- const query = closedStruct({
359
+ const queryMembers = {
353
360
  where: Schema.optionalKey(where),
354
361
  orderBy: Schema.optionalKey(orderBy),
355
362
  include: Schema.optionalKey(included),
356
363
  after: Schema.optionalKey(After),
357
364
  limit: Schema.optionalKey(Limit),
358
- }, "Unknown key; use where, orderBy, include, after or limit").annotate({ identifier: `${pascal}Query`, description: `A query on ${entity.plural}.` });
365
+ };
366
+ const query = (wire
367
+ ? wireStruct(queryMembers, { orderBy: "order_by" })
368
+ : closedStruct(queryMembers, "Unknown key; use where, orderBy, include, after or limit")).annotate({ identifier: `${pascal}Query`, description: `A query on ${entity.plural}.` });
359
369
  // The structs are assembled from the manifest, so their static types are too wide to name; they
360
370
  // accept exactly the Query shape and decode it unchanged.
361
371
  const schemas = {
@@ -366,9 +376,9 @@ export function entityQuerySchemas(manifest, entityName) {
366
376
  after: After,
367
377
  query,
368
378
  };
369
- const byEntity = querySchemas.get(manifest) ?? new Map();
379
+ const byEntity = cache.get(manifest) ?? new Map();
370
380
  byEntity.set(entityName, schemas);
371
- querySchemas.set(manifest, byEntity);
381
+ cache.set(manifest, byEntity);
372
382
  return schemas;
373
383
  }
374
384
  /** The query schema of one entity; see {@link entityQuerySchemas}. */
@@ -411,7 +421,7 @@ function recordValueSchema(field) {
411
421
  }
412
422
  /**
413
423
  * The record of an entity as the runtime returns it: system fields, properties (`null` when empty;
414
- * dates as `YYYY-MM-DD`, timestamps as ISO 8601 in UTC) and a `<relationship>Id` per `rel.one`. It
424
+ * dates as `YYYY-MM-DD`, timestamps as ISO 8601 in UTC) and a `<relationship>_id` per `rel.one`. It
415
425
  * checks the shape of stored values, not the refinements of the definition (`maxLength`, `min`,
416
426
  * patterns), so that tightening a refinement never makes existing records unreadable.
417
427
  */
@@ -430,7 +440,7 @@ function recordFields(entity) {
430
440
  return fields;
431
441
  }
432
442
  /** A page of an entity's records, with the relationships a query may include. */
433
- export function entityQueryResultSchema(manifest, entityName) {
443
+ export function entityQueryResultSchema(manifest, entityName, wire = false) {
434
444
  const entity = requireEntity(manifest, entityName);
435
445
  const fields = recordFields(entity);
436
446
  for (const link of queryLinks(manifest, entityName)) {
@@ -438,7 +448,8 @@ export function entityQueryResultSchema(manifest, entityName) {
438
448
  fields[link.name] = Schema.optionalKey(link.cardinality === "one" ? Schema.NullOr(target) : Schema.Array(target));
439
449
  }
440
450
  const record = Schema.Struct(fields);
441
- return queryResultSchema(record);
451
+ const page = queryResultSchema(record);
452
+ return wire ? page.pipe(Schema.encodeKeys({ nextCursor: "next_cursor" })) : page;
442
453
  }
443
454
  /** A JSON Schema whose root is the schema itself, not a `$ref`, as MCP tool inputs need it. */
444
455
  function jsonSchema(schema) {
@@ -453,9 +464,9 @@ function jsonSchema(schema) {
453
464
  }
454
465
  /** The JSON Schema (draft 2020-12) of an entity's queries, for MCP tools, OpenAPI and the console. */
455
466
  export function queryJsonSchema(manifest, entityName) {
456
- return jsonSchema(entityQuerySchema(manifest, entityName));
467
+ return jsonSchema(entityQuerySchemas(manifest, entityName, true).query);
457
468
  }
458
469
  /** The JSON Schema (draft 2020-12) of a page of an entity's records. */
459
470
  export function queryResultJsonSchema(manifest, entityName) {
460
- return jsonSchema(entityQueryResultSchema(manifest, entityName));
471
+ return jsonSchema(entityQueryResultSchema(manifest, entityName, true));
461
472
  }
@@ -3,7 +3,7 @@ import { fieldValueSchema } from "../query.js";
3
3
  import { bindValue } from "./records.js";
4
4
  import { columnOf } from "./statement.js";
5
5
  /** The default order: newest first. */
6
- const DEFAULT_ORDER = [{ field: "createdAt", direction: "desc" }];
6
+ const DEFAULT_ORDER = [{ field: "created_at", direction: "desc" }];
7
7
  export function sortKeys(entity, orderBy) {
8
8
  const requested = orderBy === undefined || orderBy.length === 0 ? DEFAULT_ORDER : orderBy;
9
9
  const keys = [];
@@ -3,13 +3,13 @@ import type { JsonSchemaObject } from "../analyze.js";
3
3
  import type { Ontology } from "../ontology.js";
4
4
  /** An entity's generated actions by name. */
5
5
  export type GeneratedActions = Readonly<Record<string, AnyAction>>;
6
- /** `upsertByEmail`. */
6
+ /** `upsert_by_email`. */
7
7
  export declare function upsertActionName(property: string): string;
8
8
  /**
9
9
  * The actions generated for an entity: `create`, `update` (without `managed` properties), `delete`,
10
- * `upsertBy<Property>` per unique property, and `link`/`unlink` when it declares a `rel.many`. They
10
+ * `upsert_by_<property>` per unique property, and `link`/`unlink` when it declares a `rel.many`. They
11
11
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
12
- * the permission named after it (`lead:create`), and `upsertBy<Property>` both `create` and `update`,
12
+ * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
13
13
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
14
14
  * and services by the app or the CLI.
15
15
  */
@@ -18,7 +18,7 @@ export declare function generatedActions(ontology: Ontology, entityName: string)
18
18
  export interface GeneratedActionManifest {
19
19
  readonly name: string;
20
20
  readonly description: string;
21
- /** Every permission the action requires: one, or `create` and `update` for `upsertBy<Property>`. */
21
+ /** Every permission the action requires: one, or `create` and `update` for `upsert_by_<property>`. */
22
22
  readonly permissions: ReadonlyArray<string>;
23
23
  readonly input: JsonSchemaObject;
24
24
  }
@@ -5,19 +5,15 @@ import { generatedActionPermissions } from "../permissions.js";
5
5
  import { p, REFERENCE_ANNOTATION } from "../property.js";
6
6
  import { isCalendarDate, isInstant } from "../query.js";
7
7
  import { OntologyWrites } from "../runtime.js";
8
- import { User } from "../user.js";
9
8
  import { entityModel, ontologyModel, propertyValueSchema, } from "./model.js";
10
9
  /** Generated actions, built once per ontology and entity. */
11
10
  const builtActions = new WeakMap();
12
11
  function article(word) {
13
12
  return /^[aeiou]/i.test(word) ? `an ${word}` : `a ${word}`;
14
13
  }
15
- function capitalize(name) {
16
- return name.charAt(0).toUpperCase() + name.slice(1);
17
- }
18
- /** `upsertByEmail`. */
14
+ /** `upsert_by_email`. */
19
15
  export function upsertActionName(property) {
20
- return `${GENERATED_UPSERT_PREFIX}${capitalize(property)}`;
16
+ return `${GENERATED_UPSERT_PREFIX}${property}`;
21
17
  }
22
18
  /** A property's value schema, refusing days that do not exist, which PostgreSQL would reject. */
23
19
  function propertyInput(field) {
@@ -83,9 +79,9 @@ function valuesOf(input, omit) {
83
79
  }
84
80
  /**
85
81
  * The actions generated for an entity: `create`, `update` (without `managed` properties), `delete`,
86
- * `upsertBy<Property>` per unique property, and `link`/`unlink` when it declares a `rel.many`. They
82
+ * `upsert_by_<property>` per unique property, and `link`/`unlink` when it declares a `rel.many`. They
87
83
  * are ordinary actions and run through the same pipeline. Their `permission` is unset: each requires
88
- * the permission named after it (`lead:create`), and `upsertBy<Property>` both `create` and `update`,
84
+ * the permission named after it (`lead:create`), and `upsert_by_<property>` both `create` and `update`,
89
85
  * as `generatedActionPermissions` says. The built-in User has none: members are created by sign-in
90
86
  * and services by the app or the CLI.
91
87
  */
@@ -101,7 +97,7 @@ export function generatedActions(ontology, entityName) {
101
97
  }
102
98
  function buildGeneratedActions(ontology, entityName) {
103
99
  const entity = entityModel(ontologyModel(ontology), entityName);
104
- if (entity.resolved.entity.definition === User.definition) {
100
+ if (entity.resolved.builtIn) {
105
101
  return {};
106
102
  }
107
103
  const label = entity.label.toLowerCase();
@@ -33,7 +33,7 @@ export const HistoryEntry = Schema.Struct({
33
33
  at: Schema.String,
34
34
  changes: Changes,
35
35
  });
36
- /** The fields history records: declared properties and `<relationship>Id` keys. */
36
+ /** The fields history records: declared properties and `<relationship>_id` keys. */
37
37
  function changedFields(entity) {
38
38
  return entity.fields.filter((field) => field.source !== "system");
39
39
  }
@@ -12,7 +12,7 @@ export interface FieldModel extends QueryField {
12
12
  readonly source: "system" | "property" | "relationship";
13
13
  /** The declared property, for properties. */
14
14
  readonly property?: ResolvedProperty;
15
- /** The relationship whose key the field holds, for `<relationship>Id`. */
15
+ /** The relationship whose key the field holds, for `<relationship>_id`. */
16
16
  readonly relationship?: string;
17
17
  }
18
18
  /** A declared `rel.one`: this entity's key column points at the target's id. */
@@ -45,7 +45,7 @@ export interface EntityModel {
45
45
  readonly historyTable: string;
46
46
  readonly resolved: ResolvedEntity;
47
47
  readonly manifest: EntityManifest;
48
- /** System fields, properties and `<relationship>Id` keys, in record order. */
48
+ /** System fields, properties and `<relationship>_id` keys, in record order. */
49
49
  readonly fields: ReadonlyArray<FieldModel>;
50
50
  readonly fieldsByName: ReadonlyMap<string, FieldModel>;
51
51
  readonly links: ReadonlyMap<string, LinkModel>;
@@ -80,7 +80,7 @@ function buildField(entity, field, properties) {
80
80
  property,
81
81
  };
82
82
  }
83
- const relationship = field.name.slice(0, -"Id".length);
83
+ const relationship = field.name.slice(0, -"_id".length);
84
84
  return {
85
85
  ...field,
86
86
  column: relationshipColumnName(relationship),
@@ -1,3 +1,4 @@
1
+ import { propertyDefinition } from "../property.js";
1
2
  import { pbkdf2, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
3
  import { promisify } from "node:util";
3
4
  import { Effect, Schema } from "effect";
@@ -5,7 +6,7 @@ import { SqlClient } from "effect/unstable/sql";
5
6
  import { isSqlError } from "effect/unstable/sql/SqlError";
6
7
  import { entity, } from "../entity.js";
7
8
  import { API_KEYS_TABLE, USER_ROLES_TABLE, USER_TABLE } from "../migrate/storage.js";
8
- import { MEMBER_ROLE } from "../names.js";
9
+ import { MEMBER_ROLE, toSnakeCase } from "../names.js";
9
10
  import { buildOntology } from "../ontology.js";
10
11
  import { permissionsOf } from "../permissions.js";
11
12
  import { EMAIL_PATTERN } from "../property.js";
@@ -110,19 +111,19 @@ function userRow(record) {
110
111
  * the ontology's sign-in policy decides: `invited` turns the person away, `open` creates a member.
111
112
  */
112
113
  function signIn(ontology, sql, claims) {
113
- const runtime = platformRuntime(ontology, sql, "user.signIn");
114
+ const runtime = platformRuntime(ontology, sql, "user.sign_in");
114
115
  const claimedEmail = claims.email?.trim().toLowerCase();
115
116
  const email = claimedEmail !== undefined && EMAIL_PATTERN.test(claimedEmail) ? claimedEmail : undefined;
116
117
  return sql
117
118
  .withTransaction(Effect.gen(function* () {
118
119
  const now = new Date();
119
- const signedIn = { lastSignInAt: now.toISOString() };
120
+ const signedIn = { last_sign_in_at: now.toISOString() };
120
121
  const [linked] = yield* runtime.find({
121
122
  entity: "user",
122
123
  options: {
123
124
  where: {
124
- identityProvider: claims.identityProvider,
125
- identitySubject: claims.identitySubject,
125
+ identity_provider: claims.identityProvider,
126
+ identity_subject: claims.identitySubject,
126
127
  },
127
128
  limit: 1,
128
129
  forUpdate: true,
@@ -130,7 +131,7 @@ function signIn(ontology, sql, claims) {
130
131
  });
131
132
  if (linked !== undefined) {
132
133
  // An inactive user is turned away, which is not a sign-in to record.
133
- if (linked.active !== true || !signInDue(linked.lastSignInAt, now)) {
134
+ if (linked.active !== true || !signInDue(linked.last_sign_in_at, now)) {
134
135
  return userRow(linked);
135
136
  }
136
137
  const returning = yield* runtime.update({
@@ -147,7 +148,7 @@ function signIn(ontology, sql, claims) {
147
148
  options: { where: { email }, limit: 1, forUpdate: true },
148
149
  });
149
150
  if (invited !== undefined) {
150
- if (invited.kind !== "member" || invited.identitySubject !== null) {
151
+ if (invited.kind !== "member" || invited.identity_subject !== null) {
151
152
  return yield* new PrincipalRejected({
152
153
  reason: "identity_conflict",
153
154
  message: "Another user already has this email.",
@@ -157,8 +158,8 @@ function signIn(ontology, sql, claims) {
157
158
  entity: "user",
158
159
  id: invited.id,
159
160
  values: {
160
- identityProvider: claims.identityProvider,
161
- identitySubject: claims.identitySubject,
161
+ identity_provider: claims.identityProvider,
162
+ identity_subject: claims.identitySubject,
162
163
  ...(invited.active === true ? signedIn : {}),
163
164
  },
164
165
  });
@@ -176,8 +177,8 @@ function signIn(ontology, sql, claims) {
176
177
  name: claims.name?.trim() || email || claims.identitySubject,
177
178
  email: email ?? null,
178
179
  kind: "member",
179
- identityProvider: claims.identityProvider,
180
- identitySubject: claims.identitySubject,
180
+ identity_provider: claims.identityProvider,
181
+ identity_subject: claims.identitySubject,
181
182
  ...signedIn,
182
183
  },
183
184
  });
@@ -295,7 +296,7 @@ export function setUserRoles(ontology, input) {
295
296
  export function createServiceUser(ontology, input) {
296
297
  return Effect.gen(function* () {
297
298
  const sql = yield* SqlClient.SqlClient;
298
- const runtime = platformRuntime(ontology, sql, "user.createService");
299
+ const runtime = platformRuntime(ontology, sql, "user.create_service");
299
300
  return yield* sql.withTransaction(Effect.gen(function* () {
300
301
  const user = yield* runtime
301
302
  .create({ entity: "user", values: { name: input.name, kind: "service" } })
@@ -317,7 +318,9 @@ function deployedUser(manifest) {
317
318
  return User;
318
319
  }
319
320
  const names = new Set(deployed.properties.map((property) => property.name));
320
- const kept = Object.entries(User.definition.properties).filter(([name]) => names.has(name));
321
+ const kept = Object.entries(User.definition.properties).filter(([name, property]) => names.has(toSnakeCase(name)) ||
322
+ (propertyDefinition(property)?.renamedFrom !== undefined &&
323
+ names.has(propertyDefinition(property).renamedFrom)));
321
324
  if (kept.length === Object.keys(User.definition.properties).length) {
322
325
  return User;
323
326
  }
@@ -139,7 +139,7 @@ function loadRelated(context, link, records) {
139
139
  const target = entityModel(context.model, link.target);
140
140
  const params = new Params();
141
141
  if (link.via === "key") {
142
- const keyField = `${link.name}Id`;
142
+ const keyField = `${link.name}_id`;
143
143
  const ids = [...new Set(records.map((record) => record[keyField]).filter(isString))];
144
144
  const byId = new Map();
145
145
  if (ids.length > 0) {
@@ -130,7 +130,7 @@ export function prepareWrite(entity, values, mode) {
130
130
  columns.push({ field, value });
131
131
  }
132
132
  else if (link?.via === "key") {
133
- const key = entity.fieldsByName.get(`${name}Id`);
133
+ const key = entity.fieldsByName.get(`${name}_id`);
134
134
  if (value === null && !link.optional) {
135
135
  issues.push({ path: [name], message: `${name} is required` });
136
136
  }
@@ -117,7 +117,7 @@ export function makeOntologyRuntime(ontology, sql, scope) {
117
117
  }
118
118
  const target = entityModel(model, link.target);
119
119
  const validated = yield* validateQuery(target, input.query ?? {});
120
- const keyValue = link.via === "key" ? (source[`${link.name}Id`] ?? null) : null;
120
+ const keyValue = link.via === "key" ? (source[`${link.name}_id`] ?? null) : null;
121
121
  return yield* orDieOnSql(runQuery(context, target, validated, {
122
122
  condition: relatedCondition(link, source.id, keyValue),
123
123
  }));
@@ -48,7 +48,7 @@ export interface ActionCommitted {
48
48
  readonly entity: string;
49
49
  readonly action: string;
50
50
  readonly principal: Principal;
51
- /** The input as the caller sent it. */
51
+ /** The decoded input, using the authored TypeScript schema. */
52
52
  readonly input: unknown;
53
53
  readonly result: unknown;
54
54
  readonly edits: ReadonlyArray<unknown>;
@@ -42,6 +42,7 @@ export interface ErrorResponse {
42
42
  readonly headers?: Readonly<Record<string, string>>;
43
43
  }
44
44
  export declare function toServerResponse(response: ErrorResponse): HttpServerResponse.HttpServerResponse;
45
+ export declare function errorBodyForWire(body: ErrorBody): Readonly<Record<string, unknown>>;
45
46
  /** Issues of a schema failure, as `[{ path, message }]`. */
46
47
  export declare function schemaIssues(issue: SchemaIssue.Issue): Array<ValidationIssue>;
47
48
  export declare function unauthorized(requestId: string, detail: string): ErrorResponse;
@@ -21,11 +21,15 @@ export class InvalidRequestBody extends Schema.TaggedError()("InvalidRequestBody
21
21
  export class SignInCheckLimited extends Schema.TaggedError()("SignInCheckLimited", { retryAfterSeconds: Schema.Number }) {
22
22
  }
23
23
  export function toServerResponse(response) {
24
- return HttpServerResponse.jsonUnsafe(response.body, {
24
+ return HttpServerResponse.jsonUnsafe(errorBodyForWire(response.body), {
25
25
  status: response.status,
26
26
  ...(response.headers !== undefined ? { headers: response.headers } : {}),
27
27
  });
28
28
  }
29
+ export function errorBodyForWire(body) {
30
+ const { requestId, ...rest } = body;
31
+ return { ...rest, request_id: requestId };
32
+ }
29
33
  /** Issues of a schema failure, as `[{ path, message }]`. */
30
34
  export function schemaIssues(issue) {
31
35
  const formatted = SchemaIssue.makeFormatterStandardSchemaV1()(issue);
@@ -1,6 +1,10 @@
1
1
  import { HttpServerResponse } from "effect/unstable/http";
2
2
  import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi";
4
+ import { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
5
+ import { nameToWire } from "../authoring-names.js";
6
+ import { principalFromWire } from "../authoring.js";
7
+ import { toCamelCase } from "../names.js";
4
8
  import { PermissionDenied } from "../errors.js";
5
9
  import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
6
10
  import { EMAIL_PATTERN } from "../property.js";
@@ -84,7 +88,7 @@ function requireRead(entities) {
84
88
  }
85
89
  /**
86
90
  * Follows a relationship from one record with the query language: the record's own
87
- * `<relationship>Id` for a `rel.one`, and for everything to-many a relationship path from the
91
+ * `<relationship>_id` for a `rel.one`, and for everything to-many a relationship path from the
88
92
  * target back to the record (`lead.notes` is notes whose `lead` is the lead, `lead.tags` is tags
89
93
  * some of whose `leads` is the lead), combined with the caller's query and paged like any find.
90
94
  */
@@ -96,7 +100,7 @@ function traverse(planned, traversal, request, targets) {
96
100
  yield* requireRead([planned.name, ...queryReads(traversal.target, query, targets)]);
97
101
  const source = yield* backend.get({ entity: planned.name, id });
98
102
  if (traversal.kind === "one") {
99
- const targetId = source[`${traversal.name}Id`];
103
+ const targetId = source[`${traversal.name}_id`];
100
104
  if (typeof targetId !== "string") {
101
105
  return { items: [], nextCursor: null };
102
106
  }
@@ -141,26 +145,31 @@ function readEnvelope(request) {
141
145
  * The configured hook as `runAction` runs it after the commit. A failure is logged with the request
142
146
  * id here, so the action's caller still gets the committed result.
143
147
  */
144
- function afterCommitOf(hook, request) {
145
- return (committed) => Effect.tryPromise(async () => hook({
146
- entity: committed.entity,
147
- action: committed.action,
148
- principal: committed.principal,
149
- input: request.input,
150
- result: committed.result,
151
- edits: committed.edits,
152
- requestId: request.requestId,
153
- })).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
154
- requestId: request.requestId,
155
- action: `${committed.entity}.${committed.action}`,
156
- }))));
148
+ function afterCommitOf(hook, request, ontology) {
149
+ return (committed) => {
150
+ const definition = ontology.config.entities.find((entity) => nameToWire(entity.name) === committed.entity)?.definition;
151
+ return Effect.tryPromise(async () => hook({
152
+ entity: toCamelCase(committed.entity),
153
+ action: toCamelCase(committed.action),
154
+ principal: principalFromWire(committed.principal),
155
+ input: request.generated ? recordFromWire(committed.input, definition) : committed.input,
156
+ result: request.generated
157
+ ? generatedResultFromWire(committed.result, committed.action, definition)
158
+ : committed.result,
159
+ edits: editsFromWire(committed.edits, ontology.config.entities),
160
+ requestId: request.requestId,
161
+ })).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
162
+ requestId: request.requestId,
163
+ action: `${committed.entity}.${committed.action}`,
164
+ }))));
165
+ };
157
166
  }
158
167
  /** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
159
168
  function identityToken(request) {
160
169
  const header = request.headers[OIDC_TOKEN_HEADER];
161
170
  return typeof header === "string" && header !== "" ? header : process.env.VERCEL_OIDC_TOKEN;
162
171
  }
163
- function runAction(planned, { name }, hook, connections) {
172
+ function runAction(planned, { name, generated }, ontology, hook, connections) {
164
173
  return ({ request }) => Effect.gen(function* () {
165
174
  const backend = yield* OntologyBackend;
166
175
  const principal = yield* CurrentPrincipal;
@@ -180,7 +189,7 @@ function runAction(planned, { name }, hook, connections) {
180
189
  }),
181
190
  ...(hook === undefined
182
191
  ? {}
183
- : { afterCommit: afterCommitOf(hook, { input: envelope.input, requestId }) }),
192
+ : { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
184
193
  });
185
194
  const { issues } = outcome.validation;
186
195
  if (!outcome.validation.ok && !validateOnly) {
@@ -214,7 +223,7 @@ function entityHandlers(built, planned, hooks, targets, connections) {
214
223
  registered = registered.handle(`related.${traversal.name}`, (request) => traverse(planned, traversal, request, targets).pipe(Effect.provideService(OntologyBackend, backend)));
215
224
  }
216
225
  for (const action of planned.actions) {
217
- const handler = runAction(planned, action, hooks.get(`${planned.name}.${action.name}`), connections);
226
+ const handler = runAction(planned, action, built.plan.ontology, hooks.get(`${planned.name}.${action.name}`), connections);
218
227
  registered = registered.handleRaw(`actions.${action.name}`, (request) => handler(request).pipe(Effect.provideService(OntologyBackend, backend)));
219
228
  }
220
229
  return registered;
@@ -35,7 +35,7 @@ export interface RunActionInput {
35
35
  readonly connections?: ConnectionTransport["Service"];
36
36
  }
37
37
  /**
38
- * `generatedActions(entity, ontology)`: `create`, `update`, `delete`, `upsertBy<Property>`, `link`
38
+ * `generatedActions(entity, ontology)`: `create`, `update`, `delete`, `upsert_by_<property>`, `link`
39
39
  * and `unlink`, as `src/runtime/generated-actions.ts` derives them.
40
40
  */
41
41
  export type GeneratedActionsProvider = (entity: ResolvedEntity, ontology: Ontology) => Readonly<Record<string, AnyAction>>;