@segmentstream-cloud/ontology 0.7.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.
- package/README.md +8 -7
- package/dist/analyze.js +12 -4
- package/dist/authoring-input.d.ts +3 -0
- package/dist/authoring-input.js +107 -0
- package/dist/authoring-names.d.ts +4 -0
- package/dist/authoring-names.js +13 -0
- package/dist/authoring-records.d.ts +16 -0
- package/dist/authoring-records.js +144 -0
- package/dist/authoring.d.ts +12 -0
- package/dist/authoring.js +237 -0
- package/dist/entity.d.ts +7 -8
- package/dist/entity.js +31 -8
- package/dist/migrate/plan.js +5 -4
- package/dist/names.d.ts +4 -1
- package/dist/names.js +26 -0
- package/dist/permissions.js +5 -5
- package/dist/property.js +2 -1
- package/dist/runtime/generated-actions.js +1 -2
- package/dist/runtime/principals.js +2 -2
- package/dist/service/config.d.ts +1 -1
- package/dist/service/handlers.js +25 -16
- package/dist/service/service.js +9 -2
- package/dist/user.d.ts +3 -3
- package/dist/user.js +4 -4
- package/package.json +1 -1
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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: "
|
|
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.",
|
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
|
|
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
|
|
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,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
|
+
}
|
package/dist/entity.d.ts
CHANGED
|
@@ -3,7 +3,6 @@ import type { Measures } from "./measure.js";
|
|
|
3
3
|
import type { WarehouseSource } from "./source.js";
|
|
4
4
|
import type { Actions } from "./action.js";
|
|
5
5
|
import type { Conflict, NotFound } from "./errors.js";
|
|
6
|
-
import type { SystemProperty } from "./names.js";
|
|
7
6
|
import { type AnyProperty } from "./property.js";
|
|
8
7
|
import type { AnyRelationship, ManyRelationship, OneRelationship } from "./relationship.js";
|
|
9
8
|
import { OntologyReads, OntologyWrites, type FindOptions, type GetOptions, type UpsertResult } from "./runtime.js";
|
|
@@ -20,11 +19,11 @@ export interface SystemFields {
|
|
|
20
19
|
/** A UUID. */
|
|
21
20
|
readonly id: string;
|
|
22
21
|
/** ISO 8601 timestamps. */
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
22
|
+
readonly createdAt: string;
|
|
23
|
+
readonly updatedAt: string;
|
|
25
24
|
/** The `user` who created or last updated the record; `null` when the platform wrote it. */
|
|
26
|
-
readonly
|
|
27
|
-
readonly
|
|
25
|
+
readonly createdBy: string | null;
|
|
26
|
+
readonly updatedBy: string | null;
|
|
28
27
|
}
|
|
29
28
|
/** A record id, or a record. */
|
|
30
29
|
export type RecordRef = string | {
|
|
@@ -58,9 +57,9 @@ type UniqueKeys<P> = {
|
|
|
58
57
|
} ? K : never;
|
|
59
58
|
}[keyof P] & string;
|
|
60
59
|
type RelationshipIds<R> = {
|
|
61
|
-
readonly [K in OneKeys<R> as `${K}
|
|
60
|
+
readonly [K in OneKeys<R> as `${K}Id`]: R[K] extends OneRelationship<true> ? string | null : string;
|
|
62
61
|
};
|
|
63
|
-
/** A record of the entity: system fields, declared properties, and `<relationship>
|
|
62
|
+
/** A record of the entity: system fields, declared properties, and `<relationship>Id` per `rel.one`. */
|
|
64
63
|
export type EntityRecord<P, R> = Simplify<SystemFields & {
|
|
65
64
|
readonly [K in keyof P]: ValueOf<P[K]>;
|
|
66
65
|
} & RelationshipIds<R>>;
|
|
@@ -88,7 +87,7 @@ export type UpdateValues<P, R> = Simplify<{
|
|
|
88
87
|
readonly [K in ManyKeys<R>]?: ReadonlyArray<string>;
|
|
89
88
|
}>;
|
|
90
89
|
/** Fields an index may cover: declared properties, system properties and `rel.one` relationships. */
|
|
91
|
-
export type IndexField<P, R> = (keyof P & string) |
|
|
90
|
+
export type IndexField<P, R> = (keyof P & string) | keyof SystemFields | OneKeys<R>;
|
|
92
91
|
/** An index: the covered fields in order, or the fields with options. */
|
|
93
92
|
export type IndexDefinition<Field extends string = string> = ReadonlyArray<Field> | {
|
|
94
93
|
readonly properties: ReadonlyArray<Field>;
|
package/dist/entity.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
2
|
import { p } from "./property.js";
|
|
3
|
+
import { recordFromWire, valuesToWire, findToWire } from "./authoring-records.js";
|
|
4
|
+
import { toSnakeCase } from "./names.js";
|
|
3
5
|
import { OntologyReads, OntologyWrites, } from "./runtime.js";
|
|
4
6
|
export const EntityTypeId = Symbol.for("@segmentstream-cloud/ontology/Entity");
|
|
5
7
|
function idOf(record) {
|
|
@@ -25,17 +27,38 @@ export function entity(name, definition) {
|
|
|
25
27
|
relationships: definition.relationships ?? {},
|
|
26
28
|
ref: p.ref(name),
|
|
27
29
|
get: (record, options) => withReads((runtime) => runtime.get({
|
|
28
|
-
entity: name,
|
|
30
|
+
entity: toSnakeCase(name),
|
|
29
31
|
id: idOf(record),
|
|
30
32
|
...(options?.forUpdate === true ? { forUpdate: true } : {}),
|
|
33
|
+
})).pipe(Effect.map((record) => recordFromWire(record, definition))),
|
|
34
|
+
find: (options) => withReads((runtime) => runtime.find({ entity: toSnakeCase(name), options: findToWire(options ?? {}, definition) })).pipe(Effect.map((records) => records.map((record) => recordFromWire(record, definition)))),
|
|
35
|
+
create: (values) => withWrites((runtime) => runtime.create({ entity: toSnakeCase(name), values: valuesToWire(values, definition) })).pipe(Effect.map((record) => recordFromWire(record, definition))),
|
|
36
|
+
update: (record, values) => withWrites((runtime) => runtime.update({
|
|
37
|
+
entity: toSnakeCase(name),
|
|
38
|
+
id: idOf(record),
|
|
39
|
+
values: valuesToWire(values, definition),
|
|
40
|
+
})).pipe(Effect.map((record) => recordFromWire(record, definition))),
|
|
41
|
+
upsertBy: (property, values) => withWrites((runtime) => runtime.upsertBy({
|
|
42
|
+
entity: toSnakeCase(name),
|
|
43
|
+
property: toSnakeCase(property),
|
|
44
|
+
values: valuesToWire(values, definition),
|
|
45
|
+
})).pipe(Effect.map((result) => ({
|
|
46
|
+
...result,
|
|
47
|
+
record: recordFromWire(result.record, definition),
|
|
48
|
+
}))),
|
|
49
|
+
delete: (record) => withWrites((runtime) => runtime.delete({ entity: toSnakeCase(name), id: idOf(record) })),
|
|
50
|
+
link: (relationship, record, target) => withWrites((runtime) => runtime.link({
|
|
51
|
+
entity: toSnakeCase(name),
|
|
52
|
+
relationship: toSnakeCase(relationship),
|
|
53
|
+
id: idOf(record),
|
|
54
|
+
targetId: idOf(target),
|
|
55
|
+
})),
|
|
56
|
+
unlink: (relationship, record, target) => withWrites((runtime) => runtime.unlink({
|
|
57
|
+
entity: toSnakeCase(name),
|
|
58
|
+
relationship: toSnakeCase(relationship),
|
|
59
|
+
id: idOf(record),
|
|
60
|
+
targetId: idOf(target),
|
|
31
61
|
})),
|
|
32
|
-
find: (options) => withReads((runtime) => runtime.find({ entity: name, options: options ?? {} })),
|
|
33
|
-
create: (values) => withWrites((runtime) => runtime.create({ entity: name, values })),
|
|
34
|
-
update: (record, values) => withWrites((runtime) => runtime.update({ entity: name, id: idOf(record), values })),
|
|
35
|
-
upsertBy: (property, values) => withWrites((runtime) => runtime.upsertBy({ entity: name, property, values })),
|
|
36
|
-
delete: (record) => withWrites((runtime) => runtime.delete({ entity: name, id: idOf(record) })),
|
|
37
|
-
link: (relationship, record, target) => withWrites((runtime) => runtime.link({ entity: name, relationship, id: idOf(record), targetId: idOf(target) })),
|
|
38
|
-
unlink: (relationship, record, target) => withWrites((runtime) => runtime.unlink({ entity: name, relationship, id: idOf(record), targetId: idOf(target) })),
|
|
39
62
|
};
|
|
40
63
|
// Every form of the entity shares `definition`, which is how defineOntology recognises them.
|
|
41
64
|
const self = { ...handle, actions: {} };
|
package/dist/migrate/plan.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SNAKE_CASE_NAME, isSystemProperty } from "../names.js";
|
|
1
|
+
import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
|
|
2
2
|
import { CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
|
|
3
3
|
const GLOBAL_KINDS = new Set(["table", "type", "index"]);
|
|
4
4
|
function step(input) {
|
|
@@ -296,9 +296,10 @@ function matchProperties(current, target) {
|
|
|
296
296
|
const addedProperties = [];
|
|
297
297
|
for (const property of target.properties) {
|
|
298
298
|
const sameName = byName.get(property.name);
|
|
299
|
-
const
|
|
300
|
-
?
|
|
301
|
-
:
|
|
299
|
+
const source = property.renamedFrom === undefined
|
|
300
|
+
? undefined
|
|
301
|
+
: (byName.get(property.renamedFrom) ?? byName.get(toSnakeCase(property.renamedFrom)));
|
|
302
|
+
const previous = source !== undefined && !claimed.has(source.name) ? source : undefined;
|
|
302
303
|
const match = sameName ?? previous;
|
|
303
304
|
if (match === undefined) {
|
|
304
305
|
addedProperties.push(property);
|
package/dist/names.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/** Ontology names: lowercase snake_case. */
|
|
2
2
|
export declare const SNAKE_CASE_NAME: RegExp;
|
|
3
|
+
/** Convert each character once, including acronym boundaries such as HTTPClient. */
|
|
4
|
+
export declare function toSnakeCase(name: string): string;
|
|
5
|
+
export declare function toCamelCase(name: string): string;
|
|
3
6
|
/** Historical property names accepted only as explicit rename sources. */
|
|
4
7
|
export declare const LEGACY_MEMBER_NAME: RegExp;
|
|
5
8
|
/** Stable entity identities, for example `ent_01J9Q4M8ZK`. */
|
|
6
9
|
export declare const ENTITY_ID: RegExp;
|
|
7
10
|
/** Properties every entity has. They are never declared and cannot be redeclared. */
|
|
8
11
|
export declare const SYSTEM_PROPERTIES: readonly ["id", "created_at", "updated_at", "created_by", "updated_by"];
|
|
9
|
-
export type SystemProperty =
|
|
12
|
+
export type SystemProperty = "id" | "createdAt" | "updatedAt" | "createdBy" | "updatedBy";
|
|
10
13
|
/** Actions the platform generates for every entity; authored actions cannot use these names. */
|
|
11
14
|
export declare const GENERATED_ACTIONS: readonly ["create", "update", "delete", "link", "unlink"];
|
|
12
15
|
/** Prefix of the generated `upsert_by_<property>` actions. */
|
package/dist/names.js
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
/** Ontology names: lowercase snake_case. */
|
|
2
2
|
export const SNAKE_CASE_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
|
|
3
|
+
/** TypeScript names are converted only at the ontology's serialization boundary. */
|
|
4
|
+
function isUppercase(letter) {
|
|
5
|
+
return letter !== undefined && letter >= "A" && letter <= "Z";
|
|
6
|
+
}
|
|
7
|
+
function isLowercase(letter) {
|
|
8
|
+
return letter !== undefined && letter >= "a" && letter <= "z";
|
|
9
|
+
}
|
|
10
|
+
/** Convert each character once, including acronym boundaries such as HTTPClient. */
|
|
11
|
+
export function toSnakeCase(name) {
|
|
12
|
+
const characters = [];
|
|
13
|
+
for (let index = 0; index < name.length; index++) {
|
|
14
|
+
const current = name[index];
|
|
15
|
+
const previous = name[index - 1];
|
|
16
|
+
const next = name[index + 1];
|
|
17
|
+
const followsWord = isLowercase(previous) || (previous !== undefined && previous >= "0" && previous <= "9");
|
|
18
|
+
const endsAcronym = isUppercase(previous) && isLowercase(next);
|
|
19
|
+
if (isUppercase(current) && (followsWord || endsAcronym)) {
|
|
20
|
+
characters.push("_");
|
|
21
|
+
}
|
|
22
|
+
characters.push(current);
|
|
23
|
+
}
|
|
24
|
+
return characters.join("").toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
export function toCamelCase(name) {
|
|
27
|
+
return name.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
|
|
28
|
+
}
|
|
3
29
|
/** Historical property names accepted only as explicit rename sources. */
|
|
4
30
|
export const LEGACY_MEMBER_NAME = /^(?:[a-z][a-zA-Z0-9]*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*)$/;
|
|
5
31
|
/** Stable entity identities, for example `ent_01J9Q4M8ZK`. */
|
package/dist/permissions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GENERATED_UPSERT_PREFIX } from "./names.js";
|
|
1
|
+
import { GENERATED_UPSERT_PREFIX, toSnakeCase } from "./names.js";
|
|
2
2
|
/**
|
|
3
3
|
* Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
|
|
4
4
|
* `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
|
|
@@ -12,7 +12,7 @@ export const READ_VERB = "read";
|
|
|
12
12
|
/** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
|
|
13
13
|
export const DEFAULT_MEMBER_PERMISSIONS = [`${WILDCARD}:${READ_VERB}`];
|
|
14
14
|
/** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
|
|
15
|
-
const PERMISSION = /^(\*|[a-z][a-
|
|
15
|
+
const PERMISSION = /^(\*|[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*)$/;
|
|
16
16
|
/** The entity and verb of a permission, or `undefined` when the value is not one. */
|
|
17
17
|
export function parsePermission(value) {
|
|
18
18
|
if (typeof value !== "string") {
|
|
@@ -32,8 +32,8 @@ export function grants(granted, required) {
|
|
|
32
32
|
if (held === undefined || wanted === undefined) {
|
|
33
33
|
return false;
|
|
34
34
|
}
|
|
35
|
-
return ((held.entity === WILDCARD || held.entity === wanted.entity) &&
|
|
36
|
-
(held.verb === WILDCARD || held.verb === wanted.verb));
|
|
35
|
+
return ((held.entity === WILDCARD || toSnakeCase(held.entity) === toSnakeCase(wanted.entity)) &&
|
|
36
|
+
(held.verb === WILDCARD || toSnakeCase(held.verb) === toSnakeCase(wanted.verb)));
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* Whether the principal may do what `required` names: one of its permissions equals it, or covers it
|
|
@@ -51,7 +51,7 @@ export function missingPermission(required, principal) {
|
|
|
51
51
|
* create or update and so requires both `<entity>:create` and `<entity>:update`.
|
|
52
52
|
*/
|
|
53
53
|
export function generatedActionPermissions(entity, action) {
|
|
54
|
-
return action.startsWith(GENERATED_UPSERT_PREFIX)
|
|
54
|
+
return toSnakeCase(action).startsWith(GENERATED_UPSERT_PREFIX)
|
|
55
55
|
? [permissionOf(entity, "create"), permissionOf(entity, "update")]
|
|
56
56
|
: [permissionOf(entity, action)];
|
|
57
57
|
}
|
package/dist/property.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { humanize } from "./names.js";
|
|
3
|
+
import { nameToWire } from "./authoring-names.js";
|
|
3
4
|
/** The closed set of storage types. Each has a known column, JSON Schema, filters and rendering. */
|
|
4
5
|
export const PROPERTY_TYPES = [
|
|
5
6
|
"text",
|
|
@@ -171,7 +172,7 @@ function ref(target, options) {
|
|
|
171
172
|
...(settings.label !== undefined ? { title: settings.label } : {}),
|
|
172
173
|
description: settings.description ?? `The id of a ${entityName} record.`,
|
|
173
174
|
format: "uuid",
|
|
174
|
-
[REFERENCE_ANNOTATION]: entityName,
|
|
175
|
+
[REFERENCE_ANNOTATION]: nameToWire(entityName),
|
|
175
176
|
}).check(Schema.isPattern(UUID));
|
|
176
177
|
return (settings.optional === true ? Schema.optionalKey(schema) : schema);
|
|
177
178
|
}
|
|
@@ -5,7 +5,6 @@ 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();
|
|
@@ -98,7 +97,7 @@ export function generatedActions(ontology, entityName) {
|
|
|
98
97
|
}
|
|
99
98
|
function buildGeneratedActions(ontology, entityName) {
|
|
100
99
|
const entity = entityModel(ontologyModel(ontology), entityName);
|
|
101
|
-
if (entity.resolved.
|
|
100
|
+
if (entity.resolved.builtIn) {
|
|
102
101
|
return {};
|
|
103
102
|
}
|
|
104
103
|
const label = entity.label.toLowerCase();
|
|
@@ -6,7 +6,7 @@ import { SqlClient } from "effect/unstable/sql";
|
|
|
6
6
|
import { isSqlError } from "effect/unstable/sql/SqlError";
|
|
7
7
|
import { entity, } from "../entity.js";
|
|
8
8
|
import { API_KEYS_TABLE, USER_ROLES_TABLE, USER_TABLE } from "../migrate/storage.js";
|
|
9
|
-
import { MEMBER_ROLE } from "../names.js";
|
|
9
|
+
import { MEMBER_ROLE, toSnakeCase } from "../names.js";
|
|
10
10
|
import { buildOntology } from "../ontology.js";
|
|
11
11
|
import { permissionsOf } from "../permissions.js";
|
|
12
12
|
import { EMAIL_PATTERN } from "../property.js";
|
|
@@ -318,7 +318,7 @@ function deployedUser(manifest) {
|
|
|
318
318
|
return User;
|
|
319
319
|
}
|
|
320
320
|
const names = new Set(deployed.properties.map((property) => property.name));
|
|
321
|
-
const kept = Object.entries(User.definition.properties).filter(([name, property]) => names.has(name) ||
|
|
321
|
+
const kept = Object.entries(User.definition.properties).filter(([name, property]) => names.has(toSnakeCase(name)) ||
|
|
322
322
|
(propertyDefinition(property)?.renamedFrom !== undefined &&
|
|
323
323
|
names.has(propertyDefinition(property).renamedFrom)));
|
|
324
324
|
if (kept.length === Object.keys(User.definition.properties).length) {
|
package/dist/service/config.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/service/handlers.js
CHANGED
|
@@ -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";
|
|
@@ -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) =>
|
|
146
|
-
entity
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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, {
|
|
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;
|
package/dist/service/service.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Cause, Context, Effect, Layer } from "effect";
|
|
2
2
|
import { HttpRouter, HttpServer, HttpServerError } from "effect/unstable/http";
|
|
3
3
|
import { HttpApiBuilder } from "effect/unstable/httpapi";
|
|
4
|
+
import { nameToWire } from "../authoring-names.js";
|
|
4
5
|
import { sqlServiceRuntime } from "../runtime/service-binding.js";
|
|
5
6
|
import { ErrorResponsesLive, buildApi } from "./api.js";
|
|
6
7
|
import { authenticationLayer, tokenVerifier } from "./authentication.js";
|
|
@@ -15,13 +16,19 @@ export const ONTOLOGY_HASH_HEADER = "x-ontology-hash";
|
|
|
15
16
|
export const REQUEST_ID_HEADER = "x-request-id";
|
|
16
17
|
function afterCommitHooks(plan, hooks = {}) {
|
|
17
18
|
const actions = new Set(plan.entities.flatMap(({ name, actions: planned }) => planned.map((action) => `${name}.${action.name}`)));
|
|
18
|
-
const
|
|
19
|
+
const entries = Object.entries(hooks).map(([key, hook]) => [key.split(".").map(nameToWire).join("."), hook]);
|
|
20
|
+
const unknown = Object.keys(hooks).filter((key) => !actions.has(key.split(".").map(nameToWire).join(".")));
|
|
19
21
|
if (unknown.length > 0) {
|
|
20
22
|
throw new OntologyServiceConfigError({
|
|
21
23
|
message: `afterCommit names actions the ontology does not have: ${unknown.join(", ")}.`,
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
if (new Set(entries.map(([key]) => key)).size !== entries.length) {
|
|
27
|
+
throw new OntologyServiceConfigError({
|
|
28
|
+
message: "afterCommit names collide after snake_case conversion.",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return new Map(entries);
|
|
25
32
|
}
|
|
26
33
|
/**
|
|
27
34
|
* Requests that match no route, and defects that escape a route's own error handling, still get a
|
package/dist/user.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export declare const User: import("./entity.js").DraftEntity<"user", {
|
|
|
13
13
|
readonly email: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, true>>;
|
|
14
14
|
readonly kind: import("./property.js").Property<"member" | "service", false, false>;
|
|
15
15
|
readonly active: import("./property.js").Property<boolean, true, false>;
|
|
16
|
-
readonly
|
|
17
|
-
readonly
|
|
18
|
-
readonly
|
|
16
|
+
readonly identityProvider: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
|
|
17
|
+
readonly identitySubject: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
|
|
18
|
+
readonly lastSignInAt: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
|
|
19
19
|
}, import("./entity.js").Relationships>;
|
|
20
20
|
export type User = typeof User.Type;
|
package/dist/user.js
CHANGED
|
@@ -41,13 +41,13 @@ export const User = entity("user", {
|
|
|
41
41
|
managed: true,
|
|
42
42
|
description: "Inactive users cannot sign in or run actions.",
|
|
43
43
|
}),
|
|
44
|
-
|
|
44
|
+
identityProvider: p.text({
|
|
45
45
|
renamedFrom: "identityProvider",
|
|
46
46
|
optional: true,
|
|
47
47
|
managed: true,
|
|
48
48
|
description: "Who signs the member in, for example neon-auth. Empty for services.",
|
|
49
49
|
}),
|
|
50
|
-
|
|
50
|
+
identitySubject: p.text({
|
|
51
51
|
renamedFrom: "identitySubject",
|
|
52
52
|
optional: true,
|
|
53
53
|
managed: true,
|
|
@@ -55,7 +55,7 @@ export const User = entity("user", {
|
|
|
55
55
|
}),
|
|
56
56
|
// Optional so that a table created before it gains the column without a backfill: a member
|
|
57
57
|
// who has not signed in since simply has none.
|
|
58
|
-
|
|
58
|
+
lastSignInAt: p.timestamp({
|
|
59
59
|
renamedFrom: "lastSignInAt",
|
|
60
60
|
optional: true,
|
|
61
61
|
managed: true,
|
|
@@ -63,5 +63,5 @@ export const User = entity("user", {
|
|
|
63
63
|
description: "When the member last signed in to the app. Empty until they do.",
|
|
64
64
|
}),
|
|
65
65
|
},
|
|
66
|
-
indexes: [{ properties: ["
|
|
66
|
+
indexes: [{ properties: ["identityProvider", "identitySubject"], unique: true }],
|
|
67
67
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|