@segmentstream-cloud/ontology 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -8,12 +8,13 @@ database and the HTTP service under `/api/ontology`. Every read and every action
8
8
  a permission that the caller's roles grant, and every change to a record goes through an
9
9
  action and is kept in history.
10
10
 
11
- Ontology names and record fields use lowercase snake_case, including relationships,
12
- actions, measures, select values, and system fields such as `created_at`. Generated
13
- actions use names such as `upsert_by_email`. TypeScript helpers and configuration
14
- options retain camelCase (`defineOntology`, `upsertBy`, `orderBy`, `renamedFrom`).
15
- The generated REST API and version 4 manifest encode their metadata keys in
16
- snake_case; `formatManifest` is the serializer for that wire contract.
11
+ TypeScript definitions, handler records and SDK calls use camelCase: `jobTitle`,
12
+ `assignedOwnerId`, `createdAt`, and `changeStatus`. The SDK converts ontology names,
13
+ select identifiers and declared action input fields to snake_case for the generated
14
+ REST API, version 4 manifest and storage. TypeScript helpers and options stay
15
+ camelCase. JSON property contents, provider payloads, opaque action results and
16
+ historical change names remain data and are preserved. `formatManifest` serializes
17
+ the canonical wire contract shared by REST, storage and future transports.
17
18
 
18
19
  ## Install
19
20
 
@@ -58,7 +59,7 @@ export const Lead = entity("lead", {
58
59
  relationships: {
59
60
  owner: rel.one(User, {
60
61
  optional: true,
61
- inverse: "owned_leads",
62
+ inverse: "ownedLeads",
62
63
  onDelete: "setNull",
63
64
  phrase: ["is owned by", "owns"],
64
65
  description: "The team member responsible for the next step.",
@@ -134,7 +135,7 @@ entity, the verb or both: `lead:*`, `*:read`, `*:*`.
134
135
  Members hold `member` and their assigned roles; an API key holds exactly the roles it was
135
136
  created with. A role the ontology no longer declares grants nothing.
136
137
  - The catalog, `describe`, `graph`, `openapi.json` and `me` need only authentication;
137
- `status` and `sign-in/check` need none. `?as=me` lists only the actions the caller may run.
138
+ `status`, `sign-in/check` and the `docs` page need none. `?as=me` lists only the actions the caller may run.
138
139
  - `check` fails on a role without permissions, on a permission whose entity or verb does not
139
140
  exist, and on `allow`, which version 0.2 replaced. It warns on an action no role may run
140
141
  and on `*:*`.
@@ -154,6 +155,12 @@ export function handleOntologyRequest(request: Request): Promise<Response> {
154
155
  }
155
156
  ```
156
157
 
158
+ `GET /api/ontology/docs` serves a Scalar API reference over `openapi.json`. A browser signed
159
+ in to the app authenticates with the app's Neon Auth session cookie
160
+ (`__Secure-neon-auth.session_token`), which the service exchanges at Neon Auth for the
161
+ session's token and verifies like a bearer token. A bearer token always takes precedence. The
162
+ cookie authenticates a `POST` only when its `Origin` is the app's own.
163
+
157
164
  `handler(request)` takes a web `Request` and returns a `Response`. `dispose()` closes the
158
165
  database pool. `configFromEnv()` reads these variables (`ENVIRONMENT_VARIABLES`, and the
159
166
  optional `NEON_AUTH_ENVIRONMENT_VARIABLES`). It throws `OntologyServiceConfigError` when
package/dist/analyze.js CHANGED
@@ -10,6 +10,8 @@ import { ON_DELETE, isRelationship } from "./relationship.js";
10
10
  import { isRole, member, role } from "./role.js";
11
11
  import { DEFAULT_SIGN_IN_POLICY, isSignInPolicy } from "./sign-in.js";
12
12
  import { User } from "./user.js";
13
+ import { authoringToWire } from "./authoring.js";
14
+ import { nameToWire } from "./authoring-names.js";
13
15
  const GENERATED_UPSERT = new RegExp(`^${GENERATED_UPSERT_PREFIX}`);
14
16
  function errorMessage(error) {
15
17
  return error instanceof Error ? error.message : String(error);
@@ -29,7 +31,10 @@ export function analyze(config, options = {}) {
29
31
  const report = (code, path, message, severity = "error") => {
30
32
  diagnostics.push({ severity, code, path, message });
31
33
  };
32
- const user = options.user ?? User;
34
+ const original = config;
35
+ const normalized = authoringToWire(config, options.user ?? User, report);
36
+ config = normalized.config;
37
+ const user = normalized.user;
33
38
  const entities = collectEntities(config, user, report);
34
39
  const roles = collectRoles(config, report);
35
40
  const signIn = signInOptions(config, report);
@@ -45,7 +50,7 @@ export function analyze(config, options = {}) {
45
50
  user,
46
51
  entitiesByName,
47
52
  definitions: new Set(entities.map((candidate) => candidate.definition)),
48
- entityProperties: new Set(entities.flatMap((candidate) => Object.values(candidate.definition.properties ?? {}))),
53
+ entityProperties: new Set([...entities, ...(original.entities ?? []).filter(isEntity)].flatMap((candidate) => Object.values(candidate.definition.properties ?? {}))),
49
54
  connections: new Set(connections.map((candidate) => candidate.key)),
50
55
  };
51
56
  const resolved = entities.map((candidate) => resolveEntity(candidate, scope));
@@ -314,7 +319,10 @@ function resolveProperties(candidate, scope) {
314
319
  const properties = [];
315
320
  for (const [name, schema] of entries) {
316
321
  const path = `${candidate.name}.properties.${name}`;
317
- const definition = propertyDefinition(schema);
322
+ const originalDefinition = propertyDefinition(schema);
323
+ const definition = !Array.isArray(originalDefinition?.scope)
324
+ ? originalDefinition
325
+ : { ...originalDefinition, scope: originalDefinition.scope.map(nameToWire) };
318
326
  checkMemberName("property", path, name, report);
319
327
  if (definition === undefined) {
320
328
  const hint = referenceTarget(schema) === undefined
@@ -603,7 +611,7 @@ function checkInputFields(path, fields, scope) {
603
611
  for (const [name, field] of Object.entries(fields)) {
604
612
  const fieldPath = `${path}.${name}`;
605
613
  const reference = referenceTarget(field);
606
- if (reference !== undefined && !scope.entitiesByName.has(reference)) {
614
+ if (reference !== undefined && !scope.entitiesByName.has(nameToWire(reference))) {
607
615
  scope.report("unknown-reference", fieldPath, `references "${reference}", which is not an entity of this ontology.`);
608
616
  }
609
617
  if (scope.entityProperties.has(field)) {
@@ -0,0 +1,3 @@
1
+ import { Schema } from "effect";
2
+ /** A schema-aware codec: declared fields convert, JSON/dictionary keys and provider data do not. */
3
+ export declare function actionInputToWire(input: Schema.Top): Schema.Top;
@@ -0,0 +1,107 @@
1
+ import { Schema, SchemaAST, SchemaTransformation } from "effect";
2
+ import { propertyDefinition } from "./property.js";
3
+ import { isRecord, nameToWire } from "./authoring-names.js";
4
+ function mapChildren(ast, convert) {
5
+ switch (ast._tag) {
6
+ case "Objects":
7
+ return new SchemaAST.Objects(ast.propertySignatures.map((field) => new SchemaAST.PropertySignature(field.name, convert(field.type))), ast.indexSignatures.map((field) => new SchemaAST.IndexSignature(field.parameter, convert(field.type))), ast.annotations, ast.checks, ast.encoding, ast.context);
8
+ case "Arrays":
9
+ return new SchemaAST.Arrays(ast.isMutable, ast.elements.map(convert), ast.rest.map(convert), ast.annotations, ast.checks, ast.encoding, ast.context);
10
+ case "Union":
11
+ return new SchemaAST.Union(ast.types.map(convert), ast.options, ast.annotations, ast.checks, ast.encoding, ast.context);
12
+ case "Suspend":
13
+ return new SchemaAST.Suspend(() => convert(ast.thunk()), ast.annotations, undefined, ast.encoding, ast.context);
14
+ default:
15
+ return ast;
16
+ }
17
+ }
18
+ function selectToWire(ast) {
19
+ if (ast._tag === "Literal" && typeof ast.literal === "string") {
20
+ return new SchemaAST.Literal(nameToWire(ast.literal), ast.annotations, ast.checks, ast.encoding, ast.context);
21
+ }
22
+ return mapChildren(ast, selectToWire);
23
+ }
24
+ /** A schema-aware codec: declared fields convert, JSON/dictionary keys and provider data do not. */
25
+ export function actionInputToWire(input) {
26
+ const encoded = Schema.toEncoded(input).ast;
27
+ const nodes = new WeakMap();
28
+ const wire = (ast) => {
29
+ const existing = nodes.get(ast);
30
+ if (existing !== undefined) {
31
+ return existing;
32
+ }
33
+ const property = propertyDefinition(Schema.make(ast));
34
+ if (property?.type === "json") {
35
+ return ast;
36
+ }
37
+ if (property?.type === "select") {
38
+ return selectToWire(ast);
39
+ }
40
+ const nested = mapChildren(ast, wire);
41
+ if (nested._tag !== "Objects") {
42
+ nodes.set(ast, nested);
43
+ return nested;
44
+ }
45
+ const names = new Set();
46
+ const fields = nested.propertySignatures.map((field) => {
47
+ const name = typeof field.name === "string" ? nameToWire(field.name) : field.name;
48
+ if (names.has(name)) {
49
+ throw new Error(`Input fields collide on wire name ${String(name)}.`);
50
+ }
51
+ names.add(name);
52
+ return new SchemaAST.PropertySignature(name, field.type);
53
+ });
54
+ // Object checks run on the original camelCase shape after decoding below.
55
+ const result = new SchemaAST.Objects(fields, nested.indexSignatures, nested.annotations, undefined, undefined, nested.context);
56
+ nodes.set(ast, result);
57
+ return result;
58
+ };
59
+ const rename = (value, ast, decode) => {
60
+ const property = propertyDefinition(Schema.make(ast));
61
+ if (property?.type === "json") {
62
+ return value;
63
+ }
64
+ if (property?.type === "select" && typeof value === "string") {
65
+ return decode
66
+ ? (property.options?.find((option) => nameToWire(option.value) === value)?.value ?? value)
67
+ : nameToWire(value);
68
+ }
69
+ if (ast._tag === "Suspend") {
70
+ return rename(value, ast.thunk(), decode);
71
+ }
72
+ if (ast._tag === "Union") {
73
+ const member = ast.types.find((candidate) => Schema.is(Schema.make(decode ? wire(candidate) : candidate))(value));
74
+ return member === undefined ? value : rename(value, member, decode);
75
+ }
76
+ if (ast._tag === "Arrays" && Array.isArray(value)) {
77
+ return value.map((child, index) => {
78
+ const member = ast.elements[index] ?? ast.rest[0];
79
+ return member === undefined ? child : rename(child, member, decode);
80
+ });
81
+ }
82
+ if (ast._tag !== "Objects" || !isRecord(value)) {
83
+ return value;
84
+ }
85
+ return Object.fromEntries(Object.entries(value).map(([name, child]) => {
86
+ const field = ast.propertySignatures.find((candidate) => (decode && typeof candidate.name === "string"
87
+ ? nameToWire(candidate.name)
88
+ : candidate.name) === name);
89
+ return field === undefined
90
+ ? [
91
+ name,
92
+ ast.indexSignatures[0] === undefined
93
+ ? child
94
+ : rename(child, ast.indexSignatures[0].type, decode),
95
+ ]
96
+ : [
97
+ decode ? field.name : nameToWire(String(field.name)),
98
+ rename(child, field.type, decode),
99
+ ];
100
+ }));
101
+ };
102
+ const codec = Schema.make(wire(encoded)).pipe(Schema.decodeTo(input, SchemaTransformation.transform({
103
+ decode: (value) => rename(value, encoded, true),
104
+ encode: (value) => rename(value, encoded, false),
105
+ })));
106
+ return "fields" in input ? Object.assign(codec, { fields: input.fields }) : codec;
107
+ }
@@ -0,0 +1,4 @@
1
+ /** Leave malformed identifiers intact so validation still reports them. */
2
+ export declare function nameToWire(name: string): string;
3
+ export declare function permissionToWire(permission: string): string;
4
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -0,0 +1,13 @@
1
+ import { toSnakeCase } from "./names.js";
2
+ /** Leave malformed identifiers intact so validation still reports them. */
3
+ export function nameToWire(name) {
4
+ return typeof name === "string" && /^[a-z][a-zA-Z0-9]*$/.test(name) ? toSnakeCase(name) : name;
5
+ }
6
+ export function permissionToWire(permission) {
7
+ return typeof permission === "string"
8
+ ? permission.split(":").map(nameToWire).join(":")
9
+ : permission;
10
+ }
11
+ export function isRecord(value) {
12
+ return value !== null && typeof value === "object" && !Array.isArray(value);
13
+ }
@@ -0,0 +1,16 @@
1
+ import { type AnyEntity, type Properties, type Relationships } from "./entity.js";
2
+ import type { Edit } from "./runtime/history.js";
3
+ import type { FindOptions, StoredValues } from "./runtime.js";
4
+ interface Definition {
5
+ readonly properties: Properties;
6
+ readonly relationships?: Relationships;
7
+ }
8
+ /** Only record keys and select identifiers convert; property values remain data. */
9
+ export declare function recordFromWire<A>(value: unknown, definition?: Definition): A;
10
+ export declare function valuesToWire(values: StoredValues, definition: Definition): StoredValues;
11
+ export declare function findToWire(options: FindOptions, definition: Definition): FindOptions;
12
+ /** Current action edits use the same property names and values as authored entity records. */
13
+ export declare function editsFromWire(edits: ReadonlyArray<Edit>, entities: ReadonlyArray<AnyEntity>): ReadonlyArray<Edit>;
14
+ /** Generated actions return entity records; authored action results remain application data. */
15
+ export declare function generatedResultFromWire(value: unknown, action: string, definition?: Definition): unknown;
16
+ export {};
@@ -0,0 +1,144 @@
1
+ import { isEntity } from "./entity.js";
2
+ import { toCamelCase } from "./names.js";
3
+ import { propertyDefinition } from "./property.js";
4
+ import { isRecord, nameToWire } from "./authoring-names.js";
5
+ function targetDefinition(target) {
6
+ const resolved = typeof target === "function" ? target() : target;
7
+ return isEntity(resolved) ? resolved.definition : undefined;
8
+ }
9
+ function selectValue(value, property, decode) {
10
+ const definition = propertyDefinition(property);
11
+ if (definition?.type !== "select") {
12
+ return value;
13
+ }
14
+ const convert = (child) => typeof child !== "string"
15
+ ? child
16
+ : decode
17
+ ? (definition.options?.find((option) => nameToWire(option.value) === child)?.value ?? child)
18
+ : nameToWire(child);
19
+ return Array.isArray(value) ? value.map(convert) : convert(value);
20
+ }
21
+ /** Only record keys and select identifiers convert; property values remain data. */
22
+ export function recordFromWire(value, definition) {
23
+ if (!isRecord(value)) {
24
+ return value;
25
+ }
26
+ return Object.fromEntries(Object.entries(value).map(([name, child]) => {
27
+ const property = Object.entries(definition?.properties ?? {}).find(([key]) => nameToWire(key) === name);
28
+ if (property !== undefined) {
29
+ return [property[0], selectValue(child, property[1], true)];
30
+ }
31
+ const relationship = Object.entries(definition?.relationships ?? {}).find(([key]) => nameToWire(key) === name);
32
+ if (relationship !== undefined) {
33
+ const target = targetDefinition(relationship[1].target);
34
+ return [
35
+ relationship[0],
36
+ Array.isArray(child)
37
+ ? child.map((item) => recordFromWire(item, target))
38
+ : recordFromWire(child, target),
39
+ ];
40
+ }
41
+ return [toCamelCase(name), child];
42
+ }));
43
+ }
44
+ export function valuesToWire(values, definition) {
45
+ return Object.fromEntries(Object.entries(values).map(([name, value]) => [
46
+ nameToWire(name),
47
+ selectValue(value, definition.properties[name], false),
48
+ ]));
49
+ }
50
+ /** Translate query fields and relationships; operands of JSON filters stay opaque. */
51
+ function whereToWire(where, definition) {
52
+ return Object.fromEntries(Object.entries(where).map(([name, condition]) => {
53
+ if ((name === "and" || name === "or") && Array.isArray(condition)) {
54
+ return [name, condition.map((child) => whereToWire(child, definition))];
55
+ }
56
+ if (name === "not" && isRecord(condition)) {
57
+ return [name, whereToWire(condition, definition)];
58
+ }
59
+ const property = definition?.properties[name];
60
+ const relationship = definition?.relationships?.[name];
61
+ if (relationship !== undefined && isRecord(condition)) {
62
+ const target = targetDefinition(relationship.target);
63
+ return [
64
+ nameToWire(name),
65
+ relationship.cardinality === "many"
66
+ ? Object.fromEntries(Object.entries(condition).map(([quantifier, child]) => [
67
+ quantifier,
68
+ isRecord(child) ? whereToWire(child, target) : child,
69
+ ]))
70
+ : whereToWire(condition, target),
71
+ ];
72
+ }
73
+ if (isRecord(condition)) {
74
+ const operators = [
75
+ "eq",
76
+ "ne",
77
+ "in",
78
+ "isNull",
79
+ "contains",
80
+ "startsWith",
81
+ "gt",
82
+ "gte",
83
+ "lt",
84
+ "lte",
85
+ "between",
86
+ ];
87
+ if (Object.keys(condition).every((key) => operators.includes(key))) {
88
+ return [
89
+ nameToWire(name),
90
+ Object.fromEntries(Object.entries(condition).map(([key, child]) => [
91
+ key,
92
+ selectValue(child, property, false),
93
+ ])),
94
+ ];
95
+ }
96
+ if (propertyDefinition(property)?.type !== "json") {
97
+ return [nameToWire(name), whereToWire(condition)];
98
+ }
99
+ }
100
+ return [nameToWire(name), selectValue(condition, property, false)];
101
+ }));
102
+ }
103
+ export function findToWire(options, definition) {
104
+ return {
105
+ ...options,
106
+ ...(options.where === undefined ? {} : { where: whereToWire(options.where, definition) }),
107
+ ...(options.orderBy === undefined
108
+ ? {}
109
+ : {
110
+ orderBy: options.orderBy.map((order) => ({ ...order, field: nameToWire(order.field) })),
111
+ }),
112
+ ...(options.include === undefined
113
+ ? {}
114
+ : {
115
+ include: Object.fromEntries(Object.entries(options.include).map(([name, value]) => [nameToWire(name), value])),
116
+ }),
117
+ };
118
+ }
119
+ /** Current action edits use the same property names and values as authored entity records. */
120
+ export function editsFromWire(edits, entities) {
121
+ return edits.map((edit) => {
122
+ const definition = entities.find((entity) => nameToWire(entity.name) === edit.entity)?.definition;
123
+ const before = recordFromWire(Object.fromEntries(Object.entries(edit.changes).map(([field, change]) => [field, change.before])), definition);
124
+ const after = recordFromWire(Object.fromEntries(Object.entries(edit.changes).map(([field, change]) => [field, change.after])), definition);
125
+ return {
126
+ ...edit,
127
+ entity: toCamelCase(edit.entity),
128
+ changes: Object.fromEntries(Object.keys(before).map((field) => [
129
+ field,
130
+ { before: before[field], after: after[field] },
131
+ ])),
132
+ };
133
+ });
134
+ }
135
+ /** Generated actions return entity records; authored action results remain application data. */
136
+ export function generatedResultFromWire(value, action, definition) {
137
+ if (action === "create" || action === "update") {
138
+ return recordFromWire(value, definition);
139
+ }
140
+ if (action.startsWith("upsert_by_") && isRecord(value)) {
141
+ return { ...value, record: recordFromWire(value.record, definition) };
142
+ }
143
+ return value;
144
+ }
@@ -0,0 +1,12 @@
1
+ import type { OntologyConfig } from "./analyze.js";
2
+ import type { DiagnosticCode } from "./diagnostics.js";
3
+ import { type AnyEntity } from "./entity.js";
4
+ import type { Principal } from "./runtime.js";
5
+ type Report = (code: DiagnosticCode, path: string, message: string) => void;
6
+ export declare function principalFromWire(principal: Principal): Principal;
7
+ /** Canonical definitions feed REST, storage and future transports; the public config stays camelCase. */
8
+ export declare function authoringToWire(config: OntologyConfig, user: AnyEntity, report: Report): {
9
+ readonly config: OntologyConfig;
10
+ readonly user: AnyEntity;
11
+ };
12
+ export {};
@@ -0,0 +1,237 @@
1
+ import { Schema } from "effect";
2
+ import { isAction } from "./action.js";
3
+ import { actionInputToWire } from "./authoring-input.js";
4
+ import { isRecord, nameToWire, permissionToWire } from "./authoring-names.js";
5
+ import { isEntity } from "./entity.js";
6
+ import { toCamelCase } from "./names.js";
7
+ import { p, propertyDefinition } from "./property.js";
8
+ import { isRelationship } from "./relationship.js";
9
+ import { isRole, member } from "./role.js";
10
+ export function principalFromWire(principal) {
11
+ return {
12
+ ...principal,
13
+ roles: principal.roles.map(toCamelCase),
14
+ permissions: principal.permissions.map(toCamelCase),
15
+ };
16
+ }
17
+ function indexToWire(index) {
18
+ if (Array.isArray(index)) {
19
+ return index.map(nameToWire);
20
+ }
21
+ const definition = index;
22
+ return { ...definition, properties: (definition.properties ?? []).map(nameToWire) };
23
+ }
24
+ function expressionToWire(value) {
25
+ if (!isRecord(value)) {
26
+ return value;
27
+ }
28
+ const expression = { ...value };
29
+ if (typeof value.name === "string" && (value.op === "measure" || value.op === "column")) {
30
+ expression.name = nameToWire(value.name);
31
+ }
32
+ if (Array.isArray(value.args)) {
33
+ expression.args = value.args.map(expressionToWire);
34
+ }
35
+ return expression;
36
+ }
37
+ /** Preserve shared definitions and lazy relationship targets while building the canonical model. */
38
+ class AuthoringEncoder {
39
+ report;
40
+ entities = new WeakMap();
41
+ definitions = new WeakMap();
42
+ properties = new WeakMap();
43
+ roles = new WeakMap();
44
+ constructor(report) {
45
+ this.report = report;
46
+ }
47
+ name(name, path) {
48
+ const encoded = nameToWire(name);
49
+ if (typeof name === "string" &&
50
+ /^[a-z][a-zA-Z0-9]*$/.test(name) &&
51
+ name !== toCamelCase(encoded)) {
52
+ this.report("invalid-name", path, `Use lower camelCase ${toCamelCase(encoded)} ` +
53
+ "so the TypeScript and wire names round-trip.");
54
+ }
55
+ return encoded;
56
+ }
57
+ named(values, path, convert) {
58
+ const result = Object.create(null);
59
+ for (const [name, value] of Object.entries(values ?? {})) {
60
+ const fieldPath = `${path}.${name}`;
61
+ const wireName = this.name(name, fieldPath);
62
+ if (Object.hasOwn(result, wireName)) {
63
+ this.report("duplicate-name", fieldPath, `Names collide on ${wireName} after snake_case conversion.`);
64
+ }
65
+ result[wireName] = convert(value, fieldPath);
66
+ }
67
+ return result;
68
+ }
69
+ property(value, path) {
70
+ if (!Schema.isSchema(value)) {
71
+ return value;
72
+ }
73
+ const cached = this.properties.get(value);
74
+ if (cached !== undefined) {
75
+ return cached;
76
+ }
77
+ let normalized = value;
78
+ const definition = propertyDefinition(value);
79
+ if (definition?.type === "select") {
80
+ const options = (definition.options ?? []).map((option) => ({
81
+ ...option,
82
+ description: option.description ?? "",
83
+ value: nameToWire(option.value),
84
+ }));
85
+ for (const option of definition.options ?? []) {
86
+ this.name(option.value, `${path}.options`);
87
+ }
88
+ const settings = { ...definition };
89
+ if (typeof settings.default === "string") {
90
+ settings.default = nameToWire(settings.default);
91
+ }
92
+ normalized = p.select(options, settings);
93
+ }
94
+ this.properties.set(value, normalized);
95
+ return normalized;
96
+ }
97
+ target(value) {
98
+ if (typeof value === "string") {
99
+ return nameToWire(value);
100
+ }
101
+ if (typeof value === "function") {
102
+ return () => this.entity(value());
103
+ }
104
+ return this.entity(value);
105
+ }
106
+ relationship(value) {
107
+ if (!isRelationship(value)) {
108
+ return value;
109
+ }
110
+ const normalized = {
111
+ ...value,
112
+ inverse: nameToWire(value.inverse),
113
+ target: this.target(value.target),
114
+ };
115
+ if ("on" in value && value.on !== undefined) {
116
+ return {
117
+ ...normalized,
118
+ on: Object.fromEntries(Object.entries(value.on).map(([property, target]) => [
119
+ nameToWire(property),
120
+ nameToWire(target),
121
+ ])),
122
+ };
123
+ }
124
+ return normalized;
125
+ }
126
+ definition(entity) {
127
+ const original = entity.definition;
128
+ const cached = this.definitions.get(original);
129
+ if (cached !== undefined) {
130
+ return cached;
131
+ }
132
+ const normalized = {
133
+ ...original,
134
+ title: nameToWire(original.title),
135
+ ...(original.source?.currency === undefined
136
+ ? {}
137
+ : { source: { ...original.source, currency: nameToWire(original.source.currency) } }),
138
+ properties: this.named(original.properties, `${entity.name}.properties`, (value, path) => this.property(value, path)),
139
+ relationships: this.named(original.relationships ?? {}, `${entity.name}.relationships`, (value) => this.relationship(value)),
140
+ ...(original.indexes === undefined ? {} : { indexes: original.indexes.map(indexToWire) }),
141
+ ...(original.measures === undefined
142
+ ? {}
143
+ : {
144
+ measures: this.named(original.measures, `${entity.name}.measures`, (value) => expressionToWire(value)),
145
+ }),
146
+ };
147
+ this.definitions.set(original, normalized);
148
+ return normalized;
149
+ }
150
+ action(value, path) {
151
+ if (!isAction(value)) {
152
+ return value;
153
+ }
154
+ let input = value.input;
155
+ try {
156
+ if (Schema.isSchema(input)) {
157
+ input = actionInputToWire(input);
158
+ }
159
+ }
160
+ catch (error) {
161
+ this.report("invalid-action-input", `${path}.input`, error instanceof Error ? error.message : String(error));
162
+ }
163
+ const normalized = {
164
+ ...value,
165
+ input,
166
+ permission: value.permission === undefined ? undefined : permissionToWire(value.permission),
167
+ };
168
+ if (typeof value.handler === "function") {
169
+ normalized.handler = (context) => value.handler({ ...context, principal: principalFromWire(context.principal) });
170
+ }
171
+ if (typeof value.prepare === "function") {
172
+ const prepare = value.prepare;
173
+ normalized.prepare = (context) => prepare({ ...context, principal: principalFromWire(context.principal) });
174
+ }
175
+ return normalized;
176
+ }
177
+ entity(value) {
178
+ if (!isEntity(value)) {
179
+ return value;
180
+ }
181
+ const cached = this.entities.get(value);
182
+ if (cached !== undefined) {
183
+ return cached;
184
+ }
185
+ // Register the handle before resolving relationships, which can point back to it.
186
+ const normalized = { ...value, name: this.name(value.name, value.name) };
187
+ this.entities.set(value, normalized);
188
+ const definition = this.definition(value);
189
+ Object.assign(normalized, {
190
+ definition,
191
+ properties: definition.properties,
192
+ relationships: definition.relationships,
193
+ });
194
+ if (isRecord(value.actions)) {
195
+ Object.assign(normalized, {
196
+ actions: this.named(value.actions, `${value.name}.actions`, (action, path) => this.action(action, path)),
197
+ });
198
+ }
199
+ return normalized;
200
+ }
201
+ role(value) {
202
+ if (!isRole(value) || value === member) {
203
+ return value;
204
+ }
205
+ const cached = this.roles.get(value);
206
+ if (cached !== undefined) {
207
+ return cached;
208
+ }
209
+ const normalized = {
210
+ ...value,
211
+ name: this.name(value.name, `roles.${value.name}`),
212
+ permissions: Array.isArray(value.permissions)
213
+ ? value.permissions.map(permissionToWire)
214
+ : value.permissions,
215
+ };
216
+ this.roles.set(value, normalized);
217
+ return normalized;
218
+ }
219
+ }
220
+ /** Canonical definitions feed REST, storage and future transports; the public config stays camelCase. */
221
+ export function authoringToWire(config, user, report) {
222
+ const encoder = new AuthoringEncoder(report);
223
+ const normalized = {
224
+ ...config,
225
+ entities: (config.entities ?? []).map((entity) => encoder.entity(entity)),
226
+ ...(config.roles === undefined
227
+ ? {}
228
+ : { roles: config.roles.map((role) => encoder.role(role)) }),
229
+ };
230
+ if (isRecord(config.member) && Array.isArray(config.member.permissions)) {
231
+ normalized.member = {
232
+ ...config.member,
233
+ permissions: config.member.permissions.map(permissionToWire),
234
+ };
235
+ }
236
+ return { config: normalized, user: encoder.entity(user) };
237
+ }