@segmentstream-cloud/ontology 0.11.0 → 0.13.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 +51 -15
- package/dist/aggregate-routes.d.ts +95 -0
- package/dist/aggregate-routes.js +332 -0
- package/dist/analyze.js +83 -16
- package/dist/entity.d.ts +31 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +57 -23
- package/dist/manifest.js +77 -30
- package/dist/measure.d.ts +11 -2
- package/dist/measure.js +14 -1
- package/dist/migrate/apply.d.ts +1 -1
- package/dist/migrate/apply.js +36 -3
- package/dist/migrate/plan.d.ts +1 -1
- package/dist/migrate/plan.js +38 -13
- package/dist/migrate/storage.d.ts +3 -2
- package/dist/migrate/storage.js +15 -5
- package/dist/names.d.ts +4 -2
- package/dist/names.js +4 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.js +6 -2
- package/dist/relationship.d.ts +36 -5
- package/dist/relationship.js +5 -3
- package/dist/render.d.ts +47 -3
- package/dist/render.js +117 -11
- package/dist/replication/baseline.d.ts +19 -0
- package/dist/replication/baseline.js +97 -0
- package/dist/replication/bigquery.d.ts +23 -0
- package/dist/replication/bigquery.js +63 -0
- package/dist/replication/model.d.ts +38 -0
- package/dist/replication/model.js +99 -0
- package/dist/replication/schema.d.ts +7 -0
- package/dist/replication/schema.js +123 -0
- package/dist/replication/store.d.ts +35 -0
- package/dist/replication/store.js +88 -0
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +11 -0
- package/dist/runtime/pipeline.js +2 -1
- package/dist/runtime/principals.js +6 -0
- package/dist/runtime/service-binding.js +8 -0
- package/dist/runtime/sql-runtime.js +2 -1
- package/dist/service/aggregate-platform.d.ts +60 -0
- package/dist/service/aggregate-platform.js +189 -0
- package/dist/service/aggregate.d.ts +42 -0
- package/dist/service/aggregate.js +145 -0
- package/dist/service/api.d.ts +5 -3
- package/dist/service/api.js +52 -6
- package/dist/service/catalog.d.ts +10 -1
- package/dist/service/catalog.js +12 -3
- package/dist/service/config.d.ts +13 -1
- package/dist/service/config.js +11 -1
- package/dist/service/errors.d.ts +32 -0
- package/dist/service/errors.js +102 -0
- package/dist/service/handlers.d.ts +3 -1
- package/dist/service/handlers.js +67 -29
- package/dist/service/mcp/server.d.ts +5 -0
- package/dist/service/mcp/server.js +61 -9
- package/dist/service/mcp/tools.d.ts +13 -2
- package/dist/service/mcp/tools.js +165 -6
- package/dist/service/operations.d.ts +2 -1
- package/dist/service/operations.js +16 -6
- package/dist/service/plan.js +7 -1
- package/dist/service/replication.d.ts +5 -0
- package/dist/service/replication.js +25 -0
- package/dist/service/runtime-contract.d.ts +1 -0
- package/dist/service/schemas.d.ts +99 -1
- package/dist/service/schemas.js +150 -2
- package/dist/service/service.js +10 -2
- package/dist/user.d.ts +1 -1
- package/dist/warehouse-backed.js +222 -121
- package/package.json +1 -1
package/dist/manifest.js
CHANGED
|
@@ -10,9 +10,16 @@ import { DEFAULT_SIGN_IN_POLICY, isSignInPolicy, } from "./sign-in.js";
|
|
|
10
10
|
* 2 replaced the actions' `allow` with `permission`, and added the roles' `permissions` and the
|
|
11
11
|
* entities' `permissions`; those fields are optional when decoding, so version 1 manifests still read.
|
|
12
12
|
* `signIn` came later within version 2 and is optional too, as are the actions' `connections` and the
|
|
13
|
-
* top-level `connections`, which are written only when an action calls a connection.
|
|
13
|
+
* top-level `connections`, which are written only when an action calls a connection. Version 3 added
|
|
14
|
+
* warehouse sources and measures. Version 4 writes snake_case names on the wire. Version 5 lets
|
|
15
|
+
* relationships target warehouse-backed entities and warehouse-backed properties declare `unique`:
|
|
16
|
+
* a relationship with a warehouse-backed end carries `on` and omits `optional` and `onDelete`.
|
|
17
|
+
* Version 6 adds the `countDistinct` measure operator and lets stored entities declare `measures`,
|
|
18
|
+
* whose columns name the stored properties' replication view columns.
|
|
14
19
|
*/
|
|
15
|
-
export const MANIFEST_VERSION =
|
|
20
|
+
export const MANIFEST_VERSION = 6;
|
|
21
|
+
/** The first version whose documents use snake_case names on the wire. */
|
|
22
|
+
const WIRE_MANIFEST_VERSION = 4;
|
|
16
23
|
/** The phrase used for a direction the author did not phrase. */
|
|
17
24
|
export const DEFAULT_PHRASE = "has";
|
|
18
25
|
export const SelectOptionManifest = Schema.Struct({
|
|
@@ -45,14 +52,21 @@ export const PropertyManifest = Schema.Struct({
|
|
|
45
52
|
* from `to`, `inverse` and the second `phrase`.
|
|
46
53
|
*/
|
|
47
54
|
export const RelationshipManifest = Schema.Struct({
|
|
55
|
+
/**
|
|
56
|
+
* The property pair of a value-matched relationship, one with a warehouse-backed end. Absent
|
|
57
|
+
* between stored entities, which use a `<name>_id` key instead.
|
|
58
|
+
*/
|
|
48
59
|
on: Schema.optionalKey(Schema.Struct({ property: Schema.String, targetProperty: Schema.String })),
|
|
49
60
|
name: Schema.String,
|
|
50
61
|
to: Schema.String,
|
|
51
62
|
inverse: Schema.String,
|
|
52
63
|
cardinality: Schema.Literals(["one", "many"]),
|
|
53
|
-
/** `rel.one` only. */
|
|
64
|
+
/** `rel.one` between stored entities only. */
|
|
54
65
|
optional: Schema.optionalKey(Schema.Boolean),
|
|
55
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* `rel.one` between stored entities only. Before version 5, warehouse → stored relationships
|
|
68
|
+
* carried it too, as `restrict`, though nothing enforced it.
|
|
69
|
+
*/
|
|
56
70
|
onDelete: Schema.optionalKey(Schema.Literals(ON_DELETE)),
|
|
57
71
|
/** `rel.many` only. */
|
|
58
72
|
ordered: Schema.optionalKey(Schema.Boolean),
|
|
@@ -60,6 +74,14 @@ export const RelationshipManifest = Schema.Struct({
|
|
|
60
74
|
phrase: Schema.Tuple([Schema.String, Schema.String]),
|
|
61
75
|
description: Schema.String,
|
|
62
76
|
});
|
|
77
|
+
/**
|
|
78
|
+
* Whether a relationship is matched by value: either end is warehouse-backed. A value-matched
|
|
79
|
+
* relationship always declares `on`; a stored → stored relationship never does. It has no
|
|
80
|
+
* `<name>_id` column, foreign key, index or traversal route.
|
|
81
|
+
*/
|
|
82
|
+
export function isValueMatched(relationship) {
|
|
83
|
+
return relationship.on !== undefined;
|
|
84
|
+
}
|
|
63
85
|
export const IndexManifest = Schema.Struct({
|
|
64
86
|
properties: Schema.Array(Schema.String),
|
|
65
87
|
unique: Schema.Boolean,
|
|
@@ -190,7 +212,7 @@ const EntityManifestWire = Schema.Struct({
|
|
|
190
212
|
relationships: Schema.Array(RelationshipManifestWire),
|
|
191
213
|
}).pipe(Schema.encodeKeys({ builtIn: "built_in", renamedFrom: "renamed_from" }));
|
|
192
214
|
const RoleManifestWire = RoleManifest.pipe(Schema.encodeKeys({ builtIn: "built_in" }));
|
|
193
|
-
/** The JSON contract written
|
|
215
|
+
/** The JSON contract written since version 4; decoded SDK models remain camelCase. */
|
|
194
216
|
export const OntologyManifestWire = Schema.Struct({
|
|
195
217
|
...OntologyManifestModel.fields,
|
|
196
218
|
entities: Schema.Array(EntityManifestWire),
|
|
@@ -210,7 +232,7 @@ export function encodeManifest(manifest) {
|
|
|
210
232
|
if (original !== undefined) {
|
|
211
233
|
return original;
|
|
212
234
|
}
|
|
213
|
-
return manifest.version <
|
|
235
|
+
return manifest.version < WIRE_MANIFEST_VERSION
|
|
214
236
|
? manifest
|
|
215
237
|
: Schema.encodeSync(OntologyManifestWire)(manifest);
|
|
216
238
|
}
|
|
@@ -292,7 +314,13 @@ function propertyManifest({ name, definition }) {
|
|
|
292
314
|
: {}),
|
|
293
315
|
};
|
|
294
316
|
}
|
|
295
|
-
|
|
317
|
+
/**
|
|
318
|
+
* `stored` says whether both ends are stored: only then does a `rel.one` have a key column, and so
|
|
319
|
+
* an optionality and a delete rule.
|
|
320
|
+
*/
|
|
321
|
+
function relationshipManifest(input) {
|
|
322
|
+
const { resolved, stored } = input;
|
|
323
|
+
const { name, relationship, target } = resolved;
|
|
296
324
|
const common = {
|
|
297
325
|
name,
|
|
298
326
|
to: target ?? "",
|
|
@@ -306,8 +334,9 @@ function relationshipManifest({ name, relationship, target, }) {
|
|
|
306
334
|
return {
|
|
307
335
|
...common,
|
|
308
336
|
cardinality: "one",
|
|
309
|
-
|
|
310
|
-
|
|
337
|
+
...(stored
|
|
338
|
+
? { optional: relationship.optional, onDelete: relationship.onDelete ?? "restrict" }
|
|
339
|
+
: {}),
|
|
311
340
|
...(relationship.on === undefined
|
|
312
341
|
? {}
|
|
313
342
|
: {
|
|
@@ -318,27 +347,39 @@ function relationshipManifest({ name, relationship, target, }) {
|
|
|
318
347
|
}),
|
|
319
348
|
};
|
|
320
349
|
}
|
|
321
|
-
|
|
350
|
+
/**
|
|
351
|
+
* The measures of an entity. A warehouse-backed property's column is its `column`; a stored property
|
|
352
|
+
* has none, and its column in the replication view is its wire name.
|
|
353
|
+
*/
|
|
354
|
+
function measuresManifest(resolved) {
|
|
355
|
+
const columns = new Map(resolved.properties.map((property) => [
|
|
356
|
+
property.name,
|
|
357
|
+
property.definition.column ?? property.name,
|
|
358
|
+
]));
|
|
359
|
+
return Object.entries(resolved.entity.definition.measures ?? {}).map(([name, measure]) => ({
|
|
360
|
+
name,
|
|
361
|
+
label: measure.label ?? humanize(name),
|
|
362
|
+
description: measure.description ?? "",
|
|
363
|
+
unit: measure.unit ?? "number",
|
|
364
|
+
format: measure.format ??
|
|
365
|
+
(measure.unit === "currency"
|
|
366
|
+
? "currency"
|
|
367
|
+
: measure.unit === "percent"
|
|
368
|
+
? "percent"
|
|
369
|
+
: "decimal"),
|
|
370
|
+
expression: measureExpression(measure, columns),
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
/** `warehouseBacked` names the ontology's warehouse-backed entities, which relationships may target. */
|
|
374
|
+
function entityManifest(input) {
|
|
375
|
+
const { resolved, warehouseBacked } = input;
|
|
322
376
|
const { definition } = resolved.entity;
|
|
377
|
+
const storedOwner = definition.source === undefined;
|
|
378
|
+
const measures = measuresManifest(resolved);
|
|
323
379
|
return {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
source: definition.source,
|
|
328
|
-
measures: Object.entries(definition.measures ?? {}).map(([name, measure]) => ({
|
|
329
|
-
name,
|
|
330
|
-
label: measure.label ?? humanize(name),
|
|
331
|
-
description: measure.description ?? "",
|
|
332
|
-
unit: measure.unit ?? "number",
|
|
333
|
-
format: measure.format ??
|
|
334
|
-
(measure.unit === "currency"
|
|
335
|
-
? "currency"
|
|
336
|
-
: measure.unit === "percent"
|
|
337
|
-
? "percent"
|
|
338
|
-
: "decimal"),
|
|
339
|
-
expression: measureExpression(measure, new Map(resolved.properties.map((p) => [p.name, p.definition.column]))),
|
|
340
|
-
})),
|
|
341
|
-
}),
|
|
380
|
+
// A warehouse-backed entity always lists its measures; a stored entity only when it has some.
|
|
381
|
+
...(definition.source === undefined ? {} : { source: definition.source, measures }),
|
|
382
|
+
...(definition.source === undefined && measures.length > 0 ? { measures } : {}),
|
|
342
383
|
id: definition.id,
|
|
343
384
|
name: resolved.name,
|
|
344
385
|
label: definition.label,
|
|
@@ -349,7 +390,10 @@ function entityManifest(resolved) {
|
|
|
349
390
|
...(definition.renamedFrom !== undefined ? { renamedFrom: definition.renamedFrom } : {}),
|
|
350
391
|
...(definition.guide !== undefined ? { guide: definition.guide } : {}),
|
|
351
392
|
properties: resolved.properties.map(propertyManifest),
|
|
352
|
-
relationships: resolved.relationships.map(relationshipManifest
|
|
393
|
+
relationships: resolved.relationships.map((relationship) => relationshipManifest({
|
|
394
|
+
resolved: relationship,
|
|
395
|
+
stored: storedOwner && !warehouseBacked.has(relationship.target ?? ""),
|
|
396
|
+
})),
|
|
353
397
|
indexes: resolved.indexes.map((index) => ({
|
|
354
398
|
properties: [...index.properties],
|
|
355
399
|
unique: index.unique,
|
|
@@ -368,9 +412,12 @@ function entityManifest(resolved) {
|
|
|
368
412
|
/** Builds the manifest of a resolved ontology that passed `check`. */
|
|
369
413
|
export function buildManifest(input) {
|
|
370
414
|
const connections = input.connections ?? [];
|
|
415
|
+
const warehouseBacked = new Set(input.entities
|
|
416
|
+
.filter((candidate) => candidate.entity.definition.source !== undefined)
|
|
417
|
+
.map((candidate) => candidate.name));
|
|
371
418
|
const body = {
|
|
372
419
|
version: MANIFEST_VERSION,
|
|
373
|
-
entities: input.entities.map(entityManifest),
|
|
420
|
+
entities: input.entities.map((candidate) => entityManifest({ resolved: candidate, warehouseBacked })),
|
|
374
421
|
roles: input.roles.map((candidate) => ({
|
|
375
422
|
name: candidate.name,
|
|
376
423
|
description: candidate.description,
|
package/dist/measure.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type MeasureExpression = {
|
|
|
7
7
|
readonly op: "literal";
|
|
8
8
|
readonly value: string;
|
|
9
9
|
} | {
|
|
10
|
-
readonly op: "sum" | "add" | "subtract" | "multiply" | "divide";
|
|
10
|
+
readonly op: "sum" | "countDistinct" | "add" | "subtract" | "multiply" | "divide";
|
|
11
11
|
readonly args: ReadonlyArray<MeasureExpression>;
|
|
12
12
|
};
|
|
13
13
|
export interface MeasureOptions {
|
|
@@ -22,6 +22,11 @@ export type Measures = Readonly<Record<string, Measure>>;
|
|
|
22
22
|
export declare const m: {
|
|
23
23
|
readonly count: (options?: MeasureOptions) => Measure;
|
|
24
24
|
readonly sum: (property: string, options?: MeasureOptions) => Measure;
|
|
25
|
+
/**
|
|
26
|
+
* The number of distinct non-null values of a text, email, url, select, integer, date or timestamp
|
|
27
|
+
* property. Distinct counts do not add up across groups.
|
|
28
|
+
*/
|
|
29
|
+
readonly countDistinct: (property: string, options?: MeasureOptions) => Measure;
|
|
25
30
|
readonly ref: (name: string, options?: MeasureOptions) => Measure;
|
|
26
31
|
readonly literal: (value: number | string, options?: MeasureOptions) => Measure;
|
|
27
32
|
readonly add: (left: MeasureExpression, right: MeasureExpression, options?: MeasureOptions) => Measure;
|
|
@@ -29,5 +34,9 @@ export declare const m: {
|
|
|
29
34
|
readonly multiply: (left: MeasureExpression, right: MeasureExpression, options?: MeasureOptions) => Measure;
|
|
30
35
|
readonly divide: (left: MeasureExpression, right: MeasureExpression, options?: MeasureOptions) => Measure;
|
|
31
36
|
};
|
|
32
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Strip authoring options and resolve logical properties to physical columns for the manifest: a
|
|
39
|
+
* warehouse-backed property's `column`, or a stored property's replication view column, which is its
|
|
40
|
+
* wire name.
|
|
41
|
+
*/
|
|
33
42
|
export declare function measureExpression(value: MeasureExpression, columns: ReadonlyMap<string, string>): MeasureExpression;
|
package/dist/measure.js
CHANGED
|
@@ -9,6 +9,15 @@ export const m = {
|
|
|
9
9
|
args: [{ op: "column", name: property }],
|
|
10
10
|
...options,
|
|
11
11
|
}),
|
|
12
|
+
/**
|
|
13
|
+
* The number of distinct non-null values of a text, email, url, select, integer, date or timestamp
|
|
14
|
+
* property. Distinct counts do not add up across groups.
|
|
15
|
+
*/
|
|
16
|
+
countDistinct: (property, options) => ({
|
|
17
|
+
op: "countDistinct",
|
|
18
|
+
args: [{ op: "column", name: property }],
|
|
19
|
+
...options,
|
|
20
|
+
}),
|
|
12
21
|
ref: (name, options) => ({ op: "measure", name, ...options }),
|
|
13
22
|
literal: (value, options) => ({
|
|
14
23
|
op: "literal",
|
|
@@ -20,7 +29,11 @@ export const m = {
|
|
|
20
29
|
multiply: binary("multiply"),
|
|
21
30
|
divide: binary("divide"),
|
|
22
31
|
};
|
|
23
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Strip authoring options and resolve logical properties to physical columns for the manifest: a
|
|
34
|
+
* warehouse-backed property's `column`, or a stored property's replication view column, which is its
|
|
35
|
+
* wire name.
|
|
36
|
+
*/
|
|
24
37
|
export function measureExpression(value, columns) {
|
|
25
38
|
switch (value.op) {
|
|
26
39
|
case "count":
|
package/dist/migrate/apply.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export declare const readCatalog: (input: CatalogInput) => Effect.Effect<Ontolog
|
|
|
46
46
|
* then runs in one transaction with `statement_timeout` and `lock_timeout` set, which also verifies
|
|
47
47
|
* that the catalog still records the manifest the plan starts from and finally records the target. A
|
|
48
48
|
* failure rolls the transaction back and leaves the applied version in place. A no-op plan writes
|
|
49
|
-
* nothing.
|
|
49
|
+
* nothing when the runtime schema is also current.
|
|
50
50
|
*/
|
|
51
51
|
export declare const applyMigration: (plan: MigrationPlan, options: ApplyMigrationOptions) => Effect.Effect<MigrationResult, ApplyMigrationError>;
|
|
52
52
|
/**
|
package/dist/migrate/apply.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Duration, Effect, Schema } from "effect";
|
|
2
3
|
import { canonicalJson, decodeManifestDocument, encodeManifest, manifestHash, } from "../manifest.js";
|
|
3
4
|
import { CatalogInvalid, CatalogMismatch, MigrationFailed, MigrationRefused } from "./errors.js";
|
|
4
5
|
import { isNoopMigration } from "./model.js";
|
|
5
6
|
import { planMigration } from "./plan.js";
|
|
7
|
+
import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
|
|
8
|
+
import { createReplicationBaseline } from "../replication/baseline.js";
|
|
9
|
+
import { resolveReplicationSelection } from "../replication/model.js";
|
|
6
10
|
import { API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
|
|
7
11
|
const CATALOG = quoteIdentifier(CATALOG_TABLE);
|
|
8
12
|
/** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
|
|
@@ -79,7 +83,7 @@ const runStep = (sql, step, stepIndex) => Effect.forEach(step.sql, (statement) =
|
|
|
79
83
|
* then runs in one transaction with `statement_timeout` and `lock_timeout` set, which also verifies
|
|
80
84
|
* that the catalog still records the manifest the plan starts from and finally records the target. A
|
|
81
85
|
* failure rolls the transaction back and leaves the applied version in place. A no-op plan writes
|
|
82
|
-
* nothing.
|
|
86
|
+
* nothing when the runtime schema is also current.
|
|
83
87
|
*/
|
|
84
88
|
export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
85
89
|
const { sql } = options;
|
|
@@ -89,8 +93,12 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
89
93
|
refusals: plan.refusals,
|
|
90
94
|
});
|
|
91
95
|
}
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
const [presence] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
|
|
97
|
+
if (presence?.present) {
|
|
98
|
+
const [state] = yield* sql.unsafe("SELECT version, enabled FROM _ontology_replication WHERE id");
|
|
99
|
+
if (isNoopMigration(plan) && state?.version === REPLICATION_SCHEMA_VERSION) {
|
|
100
|
+
return { hash: plan.target.hash, appliedSteps: 0, changed: false };
|
|
101
|
+
}
|
|
94
102
|
}
|
|
95
103
|
const statementTimeout = Duration.toMillis(options.statementTimeout ?? DEFAULT_STATEMENT_TIMEOUT);
|
|
96
104
|
const lockTimeout = Duration.toMillis(options.lockTimeout ?? DEFAULT_LOCK_TIMEOUT);
|
|
@@ -109,11 +117,26 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
109
117
|
MIGRATION_LOCK,
|
|
110
118
|
]);
|
|
111
119
|
yield* verifyCatalog(sql, plan, { forUpdate: true });
|
|
120
|
+
let refresh;
|
|
121
|
+
if (presence?.present) {
|
|
122
|
+
const [state] = yield* sql.unsafe("SELECT * FROM _ontology_replication WHERE id FOR UPDATE");
|
|
123
|
+
if (state?.enabled) {
|
|
124
|
+
refresh = state.exclusions ?? [];
|
|
125
|
+
// Writers wait on this row. Capture, schema changes and the new baseline commit together.
|
|
126
|
+
yield* sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
112
129
|
for (const [stepIndex, step] of plan.steps.entries()) {
|
|
113
130
|
if (step.transactional) {
|
|
114
131
|
yield* runStep(sql, step, stepIndex);
|
|
115
132
|
}
|
|
116
133
|
}
|
|
134
|
+
for (const statement of [...REPLICATION_DDL, ...replicationEntityDdl(plan.target)]) {
|
|
135
|
+
yield* sql.unsafe(statement).unprepared;
|
|
136
|
+
}
|
|
137
|
+
yield* sql.unsafe("UPDATE _ontology_replication SET version = $1 WHERE id", [
|
|
138
|
+
REPLICATION_SCHEMA_VERSION,
|
|
139
|
+
]);
|
|
117
140
|
yield* sql.unsafe(`
|
|
118
141
|
INSERT INTO ${CATALOG} ("id", "version", "hash", "manifest")
|
|
119
142
|
VALUES (true, $1, $2, $3::jsonb)
|
|
@@ -123,6 +146,16 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
123
146
|
"manifest" = excluded."manifest",
|
|
124
147
|
"applied_at" = now()
|
|
125
148
|
`, [plan.target.version, plan.target.hash, canonicalJson(encodeManifest(plan.target))]);
|
|
149
|
+
if (refresh !== undefined) {
|
|
150
|
+
const selection = yield* Effect.sync(() => resolveReplicationSelection(plan.target, refresh));
|
|
151
|
+
yield* createReplicationBaseline(sql, {
|
|
152
|
+
generation: randomUUID(),
|
|
153
|
+
manifest: plan.target,
|
|
154
|
+
exclusions: refresh,
|
|
155
|
+
selection,
|
|
156
|
+
newSource: true,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
126
159
|
}));
|
|
127
160
|
return { hash: plan.target.hash, appliedSteps: plan.steps.length, changed: true };
|
|
128
161
|
}).pipe(Effect.withSpan("ontology.migrate.apply", {
|
package/dist/migrate/plan.d.ts
CHANGED
package/dist/migrate/plan.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValueMatched, } from "../manifest.js";
|
|
1
2
|
import { BLOB_TABLES_DDL } from "../storage/tables.js";
|
|
2
3
|
import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
|
|
3
4
|
import { CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
|
|
@@ -150,29 +151,32 @@ function validateManifest(manifest, refusals) {
|
|
|
150
151
|
const invalid = (path, message) => {
|
|
151
152
|
refusals.push(refusal("invalid-manifest", path, message));
|
|
152
153
|
};
|
|
153
|
-
const
|
|
154
|
+
const entitiesByName = new Map();
|
|
154
155
|
const ids = new Set();
|
|
155
156
|
for (const entity of manifest.entities) {
|
|
156
157
|
if (!SNAKE_CASE_NAME.test(entity.name)) {
|
|
157
158
|
invalid(entity.name, "entity names are lowercase snake_case.");
|
|
158
159
|
}
|
|
159
|
-
if (
|
|
160
|
+
if (entitiesByName.has(entity.name)) {
|
|
160
161
|
invalid(entity.name, `two entities are named ${entity.name}.`);
|
|
161
162
|
}
|
|
163
|
+
else {
|
|
164
|
+
entitiesByName.set(entity.name, entity);
|
|
165
|
+
}
|
|
162
166
|
if (ids.has(entity.id)) {
|
|
163
167
|
invalid(entity.name, `two entities have the id ${entity.id}.`);
|
|
164
168
|
}
|
|
165
|
-
names.add(entity.name);
|
|
166
169
|
ids.add(entity.id);
|
|
167
170
|
}
|
|
168
|
-
if (!
|
|
171
|
+
if (!entitiesByName.has(USER_TABLE)) {
|
|
169
172
|
invalid(USER_TABLE, "the built-in user entity is missing; every ontology includes it.");
|
|
170
173
|
}
|
|
171
174
|
for (const entity of manifest.entities) {
|
|
172
|
-
validateEntity(entity,
|
|
175
|
+
validateEntity({ entity, entitiesByName, invalid });
|
|
173
176
|
}
|
|
174
177
|
}
|
|
175
|
-
function validateEntity(
|
|
178
|
+
function validateEntity(input) {
|
|
179
|
+
const { entity, entitiesByName, invalid } = input;
|
|
176
180
|
for (const property of entity.properties) {
|
|
177
181
|
const path = `${entity.name}.properties.${property.name}`;
|
|
178
182
|
const values = (property.options ?? []).map((option) => option.value);
|
|
@@ -195,11 +199,21 @@ function validateEntity(entity, entityNames, invalid) {
|
|
|
195
199
|
if (!SNAKE_CASE_NAME.test(relationship.name) || isSystemProperty(relationship.name)) {
|
|
196
200
|
invalid(path, "relationship names are lowercase snake_case and cannot be system properties.");
|
|
197
201
|
}
|
|
198
|
-
|
|
202
|
+
const target = entitiesByName.get(relationship.to);
|
|
203
|
+
if (target === undefined) {
|
|
199
204
|
invalid(path, `it points at ${relationship.to}, which is not an entity.`);
|
|
200
205
|
}
|
|
201
|
-
if (relationship.
|
|
202
|
-
|
|
206
|
+
else if (isValueMatched(relationship) !== (entity.source !== undefined || target.source !== undefined)) {
|
|
207
|
+
// `on` is what keeps a relationship with a warehouse-backed end out of storage.
|
|
208
|
+
invalid(path, isValueMatched(relationship)
|
|
209
|
+
? "a relationship between stored entities uses a key column and cannot declare on."
|
|
210
|
+
: "a relationship with a warehouse-backed end is matched by value and needs on.");
|
|
211
|
+
}
|
|
212
|
+
// A value-matched relationship has no foreign key, so no delete rule.
|
|
213
|
+
if (relationship.cardinality === "one" &&
|
|
214
|
+
!isValueMatched(relationship) &&
|
|
215
|
+
relationship.onDelete === undefined) {
|
|
216
|
+
invalid(path, "a rel.one relationship between stored entities needs onDelete.");
|
|
203
217
|
}
|
|
204
218
|
if (relationship.onDelete === "setNull" && relationship.optional !== true) {
|
|
205
219
|
invalid(path, 'onDelete "setNull" needs an optional relationship.');
|
|
@@ -344,12 +358,17 @@ function matchEntities(currentEntities, targetEntities, refusals) {
|
|
|
344
358
|
for (const relationship of target.relationships) {
|
|
345
359
|
const previous = current.relationships.find(({ name }) => name === relationship.name);
|
|
346
360
|
if (previous === undefined) {
|
|
347
|
-
|
|
361
|
+
if (!isValueMatched(relationship)) {
|
|
362
|
+
addedRelationships.push(relationship);
|
|
363
|
+
}
|
|
348
364
|
continue;
|
|
349
365
|
}
|
|
366
|
+
// Both lists hold stored entities only, so a warehouse-backed target has no id here.
|
|
350
367
|
const previousTarget = entityIdByName(currentEntities, previous.to);
|
|
351
368
|
const nextTarget = entityIdByName(targetEntities, relationship.to);
|
|
352
|
-
if (previous.cardinality !== relationship.cardinality ||
|
|
369
|
+
if (previous.cardinality !== relationship.cardinality ||
|
|
370
|
+
previousTarget !== nextTarget ||
|
|
371
|
+
isValueMatched(previous) !== isValueMatched(relationship)) {
|
|
353
372
|
changed.add(relationship.name);
|
|
354
373
|
refusals.push(refusal("relationship-change", `${target.name}.relationships.${relationship.name}`, `was rel.${previous.cardinality} to ${previous.to} and is now ` +
|
|
355
374
|
`rel.${relationship.cardinality} to ${relationship.to}; a relationship keeps its ` +
|
|
@@ -357,9 +376,15 @@ function matchEntities(currentEntities, targetEntities, refusals) {
|
|
|
357
376
|
continue;
|
|
358
377
|
}
|
|
359
378
|
kept.add(relationship.name);
|
|
360
|
-
|
|
379
|
+
if (!isValueMatched(relationship)) {
|
|
380
|
+
relationships.push({ current: previous, target: relationship });
|
|
381
|
+
}
|
|
361
382
|
}
|
|
362
|
-
|
|
383
|
+
// A value-matched relationship is stored as the property its `on` names: adding, keeping or
|
|
384
|
+
// removing one plans no statement and drops nothing.
|
|
385
|
+
const removedRelationships = current.relationships.filter((relationship) => !kept.has(relationship.name) &&
|
|
386
|
+
!changed.has(relationship.name) &&
|
|
387
|
+
!isValueMatched(relationship));
|
|
363
388
|
pairs.push({
|
|
364
389
|
current,
|
|
365
390
|
target,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EntityManifest, type PropertyManifest, type RelationshipManifest } from "../manifest.js";
|
|
2
2
|
/**
|
|
3
3
|
* The physical mapping of an ontology onto Postgres. The planner creates exactly these objects and the
|
|
4
4
|
* runtime reads and writes them, so every name here is part of the storage contract.
|
|
@@ -121,7 +121,8 @@ export declare function primaryKeyName(table: string): string;
|
|
|
121
121
|
export declare function indexName(table: string, columns: ReadonlyArray<string>): string;
|
|
122
122
|
/**
|
|
123
123
|
* The column of an index field: a system property (`created_at` → `created_at`), a property, or a
|
|
124
|
-
* `rel.one` (`owner` → `owner_id`). Returns undefined for anything else
|
|
124
|
+
* stored `rel.one` (`owner` → `owner_id`). Returns undefined for anything else, value-matched
|
|
125
|
+
* relationships included: they have no column.
|
|
125
126
|
*/
|
|
126
127
|
export declare function fieldColumnName(entity: EntityManifest, field: string): string | undefined;
|
|
127
128
|
/** The SQL default of a property, or undefined when it declares none. */
|
package/dist/migrate/storage.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValueMatched, } from "../manifest.js";
|
|
1
2
|
/**
|
|
2
3
|
* The physical mapping of an ontology onto Postgres. The planner creates exactly these objects and the
|
|
3
4
|
* runtime reads and writes them, so every name here is part of the storage contract.
|
|
@@ -102,7 +103,8 @@ export function indexName(table, columns) {
|
|
|
102
103
|
}
|
|
103
104
|
/**
|
|
104
105
|
* The column of an index field: a system property (`created_at` → `created_at`), a property, or a
|
|
105
|
-
* `rel.one` (`owner` → `owner_id`). Returns undefined for anything else
|
|
106
|
+
* stored `rel.one` (`owner` → `owner_id`). Returns undefined for anything else, value-matched
|
|
107
|
+
* relationships included: they have no column.
|
|
106
108
|
*/
|
|
107
109
|
export function fieldColumnName(entity, field) {
|
|
108
110
|
// Applied manifests may still name system fields in camelCase.
|
|
@@ -121,7 +123,9 @@ export function fieldColumnName(entity, field) {
|
|
|
121
123
|
if (entity.properties.some((property) => property.name === field)) {
|
|
122
124
|
return columnName(field);
|
|
123
125
|
}
|
|
124
|
-
if (entity.relationships.some((relationship) => relationship.name === field &&
|
|
126
|
+
if (entity.relationships.some((relationship) => relationship.name === field &&
|
|
127
|
+
relationship.cardinality === "one" &&
|
|
128
|
+
!isValueMatched(relationship))) {
|
|
125
129
|
return relationshipColumnName(field);
|
|
126
130
|
}
|
|
127
131
|
return undefined;
|
|
@@ -364,8 +368,7 @@ function entityIndexes(entity) {
|
|
|
364
368
|
unique: true,
|
|
365
369
|
origin: `${entity.name}.${property.name} is unique`,
|
|
366
370
|
}));
|
|
367
|
-
const keys = entity
|
|
368
|
-
.filter((relationship) => relationship.cardinality === "one")
|
|
371
|
+
const keys = keyedRelationships(entity)
|
|
369
372
|
.map((relationship) => ({
|
|
370
373
|
name: indexName(table, [relationshipColumnName(relationship.name)]),
|
|
371
374
|
table,
|
|
@@ -376,10 +379,17 @@ function entityIndexes(entity) {
|
|
|
376
379
|
.filter((index) => !declaredNames.has(index.name));
|
|
377
380
|
return [...unique, ...keys, ...declared];
|
|
378
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* The `rel.one` relationships that have a key column and a foreign key: those between stored
|
|
384
|
+
* entities. A value-matched relationship is stored as the ordinary property its `on` names.
|
|
385
|
+
*/
|
|
386
|
+
function keyedRelationships(entity) {
|
|
387
|
+
return entity.relationships.filter((relationship) => relationship.cardinality === "one" && !isValueMatched(relationship));
|
|
388
|
+
}
|
|
379
389
|
/** The table, history table, enum types and join tables of an entity. */
|
|
380
390
|
export function entityStorage(entity) {
|
|
381
391
|
const table = tableName(entity.name);
|
|
382
|
-
const ones = entity
|
|
392
|
+
const ones = keyedRelationships(entity);
|
|
383
393
|
const manys = entity.relationships.filter((relationship) => relationship.cardinality === "many");
|
|
384
394
|
const checks = entity.properties.flatMap((property) => {
|
|
385
395
|
const check = propertyCheck(entity.name, property);
|
package/dist/names.d.ts
CHANGED
|
@@ -20,12 +20,14 @@ export declare const MEMBER_ROLE = "member";
|
|
|
20
20
|
export declare const HISTORY_SUFFIX = "_history";
|
|
21
21
|
/** The combinators of the query language, which take the place of field names in a `where`. */
|
|
22
22
|
export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
|
|
23
|
+
/** The ontology service's route for aggregates across entities: `/api/ontology/aggregate`. */
|
|
24
|
+
export declare const AGGREGATE_ROUTE = "aggregate";
|
|
23
25
|
/** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
|
|
24
|
-
export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
|
|
26
|
+
export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
|
|
25
27
|
/** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
|
|
26
28
|
export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
|
|
27
29
|
/** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
|
|
28
|
-
export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me"];
|
|
30
|
+
export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me", "aggregate"];
|
|
29
31
|
/** The longest tool name that Claude and OpenAI accept. */
|
|
30
32
|
export declare const MCP_TOOL_NAME_MAX_LENGTH = 64;
|
|
31
33
|
/** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
|
package/dist/names.js
CHANGED
|
@@ -48,8 +48,11 @@ export const MEMBER_ROLE = "member";
|
|
|
48
48
|
export const HISTORY_SUFFIX = "_history";
|
|
49
49
|
/** The combinators of the query language, which take the place of field names in a `where`. */
|
|
50
50
|
export const RESERVED_MEMBER_NAMES = ["and", "or", "not"];
|
|
51
|
+
/** The ontology service's route for aggregates across entities: `/api/ontology/aggregate`. */
|
|
52
|
+
export const AGGREGATE_ROUTE = "aggregate";
|
|
51
53
|
/** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
|
|
52
54
|
export const RESERVED_ENTITY_NAMES = [
|
|
55
|
+
AGGREGATE_ROUTE,
|
|
53
56
|
"graph",
|
|
54
57
|
"status",
|
|
55
58
|
"describe",
|
|
@@ -62,7 +65,7 @@ export const RESERVED_ENTITY_NAMES = [
|
|
|
62
65
|
/** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
|
|
63
66
|
export const MCP_READ_TOOLS = ["find", "get", "history"];
|
|
64
67
|
/** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
|
|
65
|
-
export const MCP_GLOBAL_TOOLS = ["describe", "me"];
|
|
68
|
+
export const MCP_GLOBAL_TOOLS = ["describe", "me", "aggregate"];
|
|
66
69
|
/** The longest tool name that Claude and OpenAI accept. */
|
|
67
70
|
export const MCP_TOOL_NAME_MAX_LENGTH = 64;
|
|
68
71
|
/** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
|
package/dist/query.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import type { JsonSchemaObject } from "./analyze.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type EntityManifest, type OntologyManifest } from "./manifest.js";
|
|
4
4
|
import type { PropertyType } from "./property.js";
|
|
5
5
|
/** How a record field is stored: a property type, or a UUID for ids and `<relationship>_id` keys. */
|
|
6
6
|
export type StorageType = PropertyType | "uuid";
|
|
@@ -139,7 +139,10 @@ export interface QueryLink {
|
|
|
139
139
|
readonly declared: "one" | "many";
|
|
140
140
|
readonly inverse: boolean;
|
|
141
141
|
}
|
|
142
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* The fields of an entity's records, in record order. A value-matched relationship has no
|
|
144
|
+
* `<relationship>_id`: its reference is the property named in `on`.
|
|
145
|
+
*/
|
|
143
146
|
export declare function queryFields(entity: EntityManifest): ReadonlyArray<QueryField>;
|
|
144
147
|
/** The relationships of an entity, declared ones first, then the inverses landing on it. */
|
|
145
148
|
export declare function queryLinks(manifest: OntologyManifest, entityName: string): ReadonlyArray<QueryLink>;
|
package/dist/query.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Schema } from "effect";
|
|
2
2
|
import { wireStruct } from "./wire.js";
|
|
3
|
+
import { isValueMatched } from "./manifest.js";
|
|
3
4
|
import { BlobValue } from "./blob.js";
|
|
4
5
|
/**
|
|
5
6
|
* The query language of `find` and of the HTTP API's list and query endpoints.
|
|
@@ -79,7 +80,10 @@ const SYSTEM_FIELDS = [
|
|
|
79
80
|
{ name: "created_by", storage: "uuid", nullable: true },
|
|
80
81
|
{ name: "updated_by", storage: "uuid", nullable: true },
|
|
81
82
|
];
|
|
82
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* The fields of an entity's records, in record order. A value-matched relationship has no
|
|
85
|
+
* `<relationship>_id`: its reference is the property named in `on`.
|
|
86
|
+
*/
|
|
83
87
|
export function queryFields(entity) {
|
|
84
88
|
const properties = entity.properties.map((property) => ({
|
|
85
89
|
name: property.name,
|
|
@@ -90,7 +94,7 @@ export function queryFields(entity) {
|
|
|
90
94
|
: {}),
|
|
91
95
|
}));
|
|
92
96
|
const keys = entity.relationships
|
|
93
|
-
.filter((relationship) => relationship.cardinality === "one")
|
|
97
|
+
.filter((relationship) => relationship.cardinality === "one" && !isValueMatched(relationship))
|
|
94
98
|
.map((relationship) => ({
|
|
95
99
|
name: `${relationship.name}_id`,
|
|
96
100
|
storage: "uuid",
|