@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/relationship.d.ts
CHANGED
|
@@ -12,19 +12,42 @@ export type OnDelete = (typeof ON_DELETE)[number];
|
|
|
12
12
|
export type LazyEntity = Function;
|
|
13
13
|
/** Where a relationship points: an entity, a thunk returning one, or an entity name. */
|
|
14
14
|
export type EntityTarget = AnyEntity | LazyEntity | string;
|
|
15
|
+
/**
|
|
16
|
+
* The name of the entity a target points at, as far as its type shows: the given name, or the
|
|
17
|
+
* entity's name. A thunk is a `Function`, whose `name` is any string, so its target stays unknown.
|
|
18
|
+
*/
|
|
19
|
+
type TargetName<Target> = Target extends string ? Target : Target extends {
|
|
20
|
+
readonly name: infer Name extends string;
|
|
21
|
+
} ? Name : string;
|
|
15
22
|
/** Names a relationship in both directions, for example `["is owned by", "owns"]`. */
|
|
16
23
|
export type Phrase = readonly [forward: string, inverse: string];
|
|
17
|
-
/**
|
|
18
|
-
|
|
24
|
+
/**
|
|
25
|
+
* A many-to-one relationship. Between stored entities it is declared on the side that holds the key
|
|
26
|
+
* (`owner_id` on `lead`). When either end is warehouse-backed it is value-matched: it declares `on`,
|
|
27
|
+
* one pair of an own property and a target property, and has no key column, delete rule or
|
|
28
|
+
* optionality of its own.
|
|
29
|
+
*/
|
|
30
|
+
export interface OneRelationship<Optional extends boolean = boolean, Matched extends boolean = boolean, Target extends string = string> {
|
|
19
31
|
readonly [RelationshipTypeId]: typeof RelationshipTypeId;
|
|
20
32
|
readonly cardinality: "one";
|
|
21
33
|
readonly on?: Readonly<Record<string, string>>;
|
|
22
34
|
readonly target: EntityTarget;
|
|
23
35
|
readonly optional: Optional;
|
|
24
36
|
readonly inverse: string;
|
|
25
|
-
|
|
37
|
+
/** As declared. A stored → stored relationship without one restricts deletes. */
|
|
38
|
+
readonly onDelete?: OnDelete;
|
|
26
39
|
readonly phrase?: Phrase;
|
|
27
40
|
readonly description: string;
|
|
41
|
+
/**
|
|
42
|
+
* Exists only in the type: whether `on` was given, which makes the relationship value-matched.
|
|
43
|
+
* Value-matched relationships add no `<relationship>Id` to records and inputs.
|
|
44
|
+
*/
|
|
45
|
+
readonly "~ontology.matched": Matched;
|
|
46
|
+
/**
|
|
47
|
+
* Exists only in the type: the target entity's name when the target was given as an entity or a
|
|
48
|
+
* name, and `string` for a thunk. Typed aggregate clients resolve relationship paths with it.
|
|
49
|
+
*/
|
|
50
|
+
readonly "~ontology.target": Target;
|
|
28
51
|
}
|
|
29
52
|
/** A plain many-to-many relationship, stored in a hidden join table. It never carries properties. */
|
|
30
53
|
export interface ManyRelationship {
|
|
@@ -38,11 +61,16 @@ export interface ManyRelationship {
|
|
|
38
61
|
}
|
|
39
62
|
export type AnyRelationship = OneRelationship | ManyRelationship;
|
|
40
63
|
export interface OneOptions {
|
|
64
|
+
/**
|
|
65
|
+
* The property pair a relationship with a warehouse-backed end matches on: `{ ownProperty:
|
|
66
|
+
* targetProperty }`. Required for those relationships and refused between stored entities.
|
|
67
|
+
*/
|
|
41
68
|
readonly on?: Readonly<Record<string, string>>;
|
|
42
|
-
/** The record may point at nothing: the key column is nullable. */
|
|
69
|
+
/** The record may point at nothing: the key column is nullable. Stored → stored only. */
|
|
43
70
|
readonly optional?: boolean;
|
|
44
71
|
/** The name of the relationship as seen from the target, for example `ownedLeads` on `user`. */
|
|
45
72
|
readonly inverse: string;
|
|
73
|
+
/** What deleting the target does to this record, `restrict` by default. Stored → stored only. */
|
|
46
74
|
readonly onDelete?: OnDelete;
|
|
47
75
|
readonly phrase?: Phrase;
|
|
48
76
|
readonly description: string;
|
|
@@ -57,7 +85,10 @@ export interface ManyOptions {
|
|
|
57
85
|
type IsOptional<O> = O extends {
|
|
58
86
|
readonly optional: true;
|
|
59
87
|
} ? true : false;
|
|
60
|
-
|
|
88
|
+
type IsMatched<O> = O extends {
|
|
89
|
+
readonly on: Readonly<Record<string, string>>;
|
|
90
|
+
} ? true : false;
|
|
91
|
+
declare function one<const Target extends EntityTarget, const O extends OneOptions>(target: Target, options: O): OneRelationship<IsOptional<O>, IsMatched<O>, TargetName<Target>>;
|
|
61
92
|
declare function many(target: EntityTarget, options: ManyOptions): ManyRelationship;
|
|
62
93
|
/** Relationship constructors. */
|
|
63
94
|
export declare const rel: {
|
package/dist/relationship.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
export const RelationshipTypeId = Symbol.for("@segmentstream-cloud/ontology/Relationship");
|
|
2
2
|
export const ON_DELETE = ["setNull", "cascade", "restrict"];
|
|
3
3
|
function one(target, options) {
|
|
4
|
-
|
|
4
|
+
// `onDelete` stays absent unless declared, so that check can refuse it where it means nothing.
|
|
5
|
+
const relationship = {
|
|
5
6
|
[RelationshipTypeId]: RelationshipTypeId,
|
|
6
7
|
cardinality: "one",
|
|
7
8
|
target,
|
|
8
|
-
optional:
|
|
9
|
+
optional: options.optional === true,
|
|
9
10
|
inverse: options.inverse,
|
|
10
|
-
onDelete: options.onDelete
|
|
11
|
+
...(options.onDelete !== undefined ? { onDelete: options.onDelete } : {}),
|
|
11
12
|
...(options.on !== undefined ? { on: options.on } : {}),
|
|
12
13
|
...(options.phrase !== undefined ? { phrase: options.phrase } : {}),
|
|
13
14
|
description: options.description,
|
|
14
15
|
};
|
|
16
|
+
return relationship;
|
|
15
17
|
}
|
|
16
18
|
function many(target, options) {
|
|
17
19
|
return {
|
package/dist/render.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { ActionManifest, OntologyManifest } from "./manifest.js";
|
|
1
|
+
import type { ActionManifest, EntityManifest, OntologyManifest } from "./manifest.js";
|
|
2
2
|
export interface RenderOptions {
|
|
3
3
|
/**
|
|
4
|
-
* The permissions of the reader. Only the actions these permissions may run are listed
|
|
5
|
-
*
|
|
4
|
+
* The permissions of the reader. Only the actions these permissions may run are listed, and only
|
|
5
|
+
* the measures and routes of entities they may read. Without it, every action is listed with the
|
|
6
|
+
* permission it requires and the roles that grant it, and every measure and route.
|
|
6
7
|
*/
|
|
7
8
|
readonly as?: ReadonlyArray<string>;
|
|
8
9
|
}
|
|
@@ -16,3 +17,46 @@ export declare function actionPermission(entity: string, action: ActionManifest)
|
|
|
16
17
|
* the console, the CLI and the service describe the same ontology.
|
|
17
18
|
*/
|
|
18
19
|
export declare function renderCatalog(manifest: OntologyManifest, options?: RenderOptions): string;
|
|
20
|
+
/**
|
|
21
|
+
* The catalog as `describe` answers a reader: {@link renderCatalog} with the reader's permissions,
|
|
22
|
+
* or everything without them.
|
|
23
|
+
*/
|
|
24
|
+
export declare function describeCatalog(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;
|
|
25
|
+
type MeasureManifest = NonNullable<EntityManifest["measures"]>[number];
|
|
26
|
+
/** A measure as aggregates name it: `<entity>.<measure>`. */
|
|
27
|
+
export interface NamedMeasure {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly entity: EntityManifest;
|
|
30
|
+
readonly measure: MeasureManifest;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* An entity a measure entity reaches, and the shortest routes there the reader may follow:
|
|
34
|
+
* `product.region`. `ambiguous` when the resolver finds several shortest routes, readable or not,
|
|
35
|
+
* so a request must write the route.
|
|
36
|
+
*/
|
|
37
|
+
export interface Reach {
|
|
38
|
+
readonly target: string;
|
|
39
|
+
readonly routes: ReadonlyArray<string>;
|
|
40
|
+
readonly ambiguous: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** The rule every surface repeats (docs/ontology-aggregates-spec.md, "Three ways to ask"). */
|
|
43
|
+
export declare const RECORDS_OR_AGGREGATES = "look at or change records through find and actions; count, sum or compare through aggregate";
|
|
44
|
+
/**
|
|
45
|
+
* Every measure of the entities the reader may read, warehouse-backed and stored alike, in
|
|
46
|
+
* declaration order. Without permissions, every measure.
|
|
47
|
+
*/
|
|
48
|
+
export declare function measuresFor(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): ReadonlyArray<NamedMeasure>;
|
|
49
|
+
/**
|
|
50
|
+
* The entities one entity reaches, with the shortest routes to each: exactly the routes aggregate
|
|
51
|
+
* resolution weighs ({@link shortestRoutesFrom}). Only then is the reader applied: a target they may
|
|
52
|
+
* not read is left out, and so is any route through an entity they may not read, which the service
|
|
53
|
+
* would refuse. A target keeps `ambiguous` from before that filter, so a route is never shown as the
|
|
54
|
+
* only one when the resolver would ask for it to be written.
|
|
55
|
+
*/
|
|
56
|
+
export declare function reachesOf(manifest: OntologyManifest, from: string, permissions?: ReadonlyArray<string>): ReadonlyArray<Reach>;
|
|
57
|
+
/**
|
|
58
|
+
* The aggregate part of the catalog's prose: every measure the reader may read, model-wide, and
|
|
59
|
+
* per measure entity the entities it reaches. Empty when the reader may read no measure.
|
|
60
|
+
*/
|
|
61
|
+
export declare function renderAggregates(manifest: OntologyManifest, permissions?: ReadonlyArray<string>): string;
|
|
62
|
+
export {};
|
package/dist/render.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { shortestRoutesFrom } from "./aggregate-routes.js";
|
|
1
2
|
import { isAllowed, permissionOf, READ_VERB, rolesGranting } from "./permissions.js";
|
|
2
3
|
import { REFERENCE_ANNOTATION } from "./property.js";
|
|
3
4
|
/** What an action of the manifest requires; version 1 manifests leave it implicit. */
|
|
@@ -24,11 +25,14 @@ function withDescription(head, description) {
|
|
|
24
25
|
function formatValue(value) {
|
|
25
26
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
/** A warehouse-backed property's `unique` is an assertion the platform does not enforce. */
|
|
29
|
+
function propertyLines(input) {
|
|
30
|
+
const { property, warehouseBacked } = input;
|
|
31
|
+
const unique = warehouseBacked ? "unique, not enforced" : "unique";
|
|
28
32
|
const traits = [
|
|
29
33
|
property.type,
|
|
30
34
|
...(property.optional ? ["optional"] : []),
|
|
31
|
-
...(property.unique ? [
|
|
35
|
+
...(property.unique ? [unique] : []),
|
|
32
36
|
...(property.default !== undefined ? [`default ${formatValue(property.default)}`] : []),
|
|
33
37
|
...(property.min !== undefined ? [`min ${property.min}`] : []),
|
|
34
38
|
...(property.max !== undefined ? [`max ${property.max}`] : []),
|
|
@@ -43,18 +47,40 @@ function propertyLines(property) {
|
|
|
43
47
|
...options.map((option) => `${INDENT}${option}`),
|
|
44
48
|
];
|
|
45
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The inverse of a relationship as its target reads it. A value-matched inverse, one with `on`, has
|
|
52
|
+
* no route: a warehouse-backed source is read through aggregates, a stored one by its `on` property.
|
|
53
|
+
*/
|
|
54
|
+
function inverseLine(source, relationship) {
|
|
55
|
+
const { on, phrase, inverse } = relationship;
|
|
56
|
+
if (on === undefined) {
|
|
57
|
+
return `${phrase[1]} many ${source.plural} (${inverse})`;
|
|
58
|
+
}
|
|
59
|
+
if (source.source !== undefined) {
|
|
60
|
+
return `${phrase[1]} ${source.plural} (${inverse}), through aggregates`;
|
|
61
|
+
}
|
|
62
|
+
return `${phrase[1]} ${source.plural} (${inverse}), matched on ${on.property}`;
|
|
63
|
+
}
|
|
46
64
|
function relationshipLines(entity, entities, all) {
|
|
47
65
|
const declared = entity.relationships.map((relationship) => {
|
|
66
|
+
const { on, phrase, name } = relationship;
|
|
48
67
|
const target = entities.get(relationship.to);
|
|
68
|
+
const label = target?.label ?? relationship.to;
|
|
49
69
|
const optional = relationship.optional === true ? ", optional" : "";
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
let head = `${phrase[0]} one ${label} (${name})${optional}`;
|
|
71
|
+
if (relationship.cardinality === "many") {
|
|
72
|
+
head = `${phrase[0]} many ${target?.plural ?? relationship.to} (${name})`;
|
|
73
|
+
}
|
|
74
|
+
else if (on !== undefined) {
|
|
75
|
+
// Value-matched: nothing promises a match, and the key is the declared property pair.
|
|
76
|
+
const key = `${on.property} = ${label}.${on.targetProperty}`;
|
|
77
|
+
head = `${phrase[0]} at most one ${label} (${name}), matched on ${key}`;
|
|
78
|
+
}
|
|
53
79
|
return withDescription(head, relationship.description);
|
|
54
80
|
});
|
|
55
81
|
const inverses = all.flatMap((source) => source.relationships
|
|
56
82
|
.filter((relationship) => relationship.to === entity.name)
|
|
57
|
-
.map((relationship) =>
|
|
83
|
+
.map((relationship) => inverseLine(source, relationship)));
|
|
58
84
|
return [...declared, ...inverses];
|
|
59
85
|
}
|
|
60
86
|
function inputType(schema) {
|
|
@@ -143,10 +169,8 @@ function renderEntity(entity, manifest, entities, options) {
|
|
|
143
169
|
...(entity.source
|
|
144
170
|
? [`${INDENT}Source: ${entity.source.asset}; key: ${entity.source.key.join(", ")}.`]
|
|
145
171
|
: []),
|
|
146
|
-
...section("Properties", entity.properties.flatMap(propertyLines)),
|
|
147
|
-
...(entity.
|
|
148
|
-
? section("Measures", (entity.measures ?? []).map((measure) => `${measure.name}: ${measure.description} (${measure.unit}).`))
|
|
149
|
-
: []),
|
|
172
|
+
...section("Properties", entity.properties.flatMap((property) => propertyLines({ property, warehouseBacked: entity.source !== undefined }))),
|
|
173
|
+
...section("Measures", (entity.measures ?? []).map((measure) => `${measure.name}: ${measure.description} (${measure.unit}).`)),
|
|
150
174
|
...section("Relationships", relationshipLines(entity, entities, manifest.entities)),
|
|
151
175
|
...section(reader === undefined ? "Actions" : "Actions you may run", actions.flatMap((action) => actionLines(action, reader === undefined
|
|
152
176
|
? requirement("Requires", actionPermission(entity.name, action), manifest.roles)
|
|
@@ -180,5 +204,87 @@ export function renderCatalog(manifest, options = {}) {
|
|
|
180
204
|
options.as === undefined ||
|
|
181
205
|
isAllowed(entity.permissions?.read ?? `${entity.name}:read`, { permissions: options.as }))
|
|
182
206
|
.map((entity) => renderEntity(entity, manifest, entities, options));
|
|
183
|
-
|
|
207
|
+
const aggregates = renderAggregates(manifest, options.as);
|
|
208
|
+
return `${[...sections, renderRoles(manifest.roles), ...(aggregates === "" ? [] : [aggregates])].join("\n\n")}\n`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The catalog as `describe` answers a reader: {@link renderCatalog} with the reader's permissions,
|
|
212
|
+
* or everything without them.
|
|
213
|
+
*/
|
|
214
|
+
export function describeCatalog(manifest, permissions) {
|
|
215
|
+
return renderCatalog(manifest, permissions === undefined ? {} : { as: permissions });
|
|
216
|
+
}
|
|
217
|
+
/** The rule every surface repeats (docs/ontology-aggregates-spec.md, "Three ways to ask"). */
|
|
218
|
+
export const RECORDS_OR_AGGREGATES = "look at or change records through find and actions; count, sum or compare through aggregate";
|
|
219
|
+
/** Whether the reader may read the entity; without a reader, everything is described. */
|
|
220
|
+
function readable(entity, permissions) {
|
|
221
|
+
return permissions === undefined || isAllowed(permissionOf(entity, READ_VERB), { permissions });
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Every measure of the entities the reader may read, warehouse-backed and stored alike, in
|
|
225
|
+
* declaration order. Without permissions, every measure.
|
|
226
|
+
*/
|
|
227
|
+
export function measuresFor(manifest, permissions) {
|
|
228
|
+
return manifest.entities
|
|
229
|
+
.filter((entity) => readable(entity.name, permissions))
|
|
230
|
+
.flatMap((entity) => (entity.measures ?? []).map((measure) => ({
|
|
231
|
+
name: `${entity.name}.${measure.name}`,
|
|
232
|
+
entity,
|
|
233
|
+
measure,
|
|
234
|
+
})));
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The entities one entity reaches, with the shortest routes to each: exactly the routes aggregate
|
|
238
|
+
* resolution weighs ({@link shortestRoutesFrom}). Only then is the reader applied: a target they may
|
|
239
|
+
* not read is left out, and so is any route through an entity they may not read, which the service
|
|
240
|
+
* would refuse. A target keeps `ambiguous` from before that filter, so a route is never shown as the
|
|
241
|
+
* only one when the resolver would ask for it to be written.
|
|
242
|
+
*/
|
|
243
|
+
export function reachesOf(manifest, from, permissions) {
|
|
244
|
+
return [...shortestRoutesFrom(manifest, from)].flatMap(([target, routes]) => {
|
|
245
|
+
const followed = routes.filter((route) => route.entities.every((entity) => readable(entity, permissions)));
|
|
246
|
+
return followed.length === 0
|
|
247
|
+
? []
|
|
248
|
+
: [
|
|
249
|
+
{
|
|
250
|
+
target,
|
|
251
|
+
routes: followed.map((route) => route.relationships.join(".")),
|
|
252
|
+
ambiguous: routes.length > 1,
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** `sales reaches product (product), region (product.region or listing.region; write the route).` */
|
|
258
|
+
function reachLine(entity, reaches) {
|
|
259
|
+
const targets = reaches.map(({ target, routes, ambiguous }) => `${target} (${routes.join(" or ")}${ambiguous ? "; write the route" : ""})`);
|
|
260
|
+
return `${entity} reaches ${targets.join(", ")}.`;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The aggregate part of the catalog's prose: every measure the reader may read, model-wide, and
|
|
264
|
+
* per measure entity the entities it reaches. Empty when the reader may read no measure.
|
|
265
|
+
*/
|
|
266
|
+
export function renderAggregates(manifest, permissions) {
|
|
267
|
+
const measures = measuresFor(manifest, permissions);
|
|
268
|
+
if (measures.length === 0) {
|
|
269
|
+
return "";
|
|
270
|
+
}
|
|
271
|
+
const owners = [...new Set(measures.map(({ entity }) => entity.name))];
|
|
272
|
+
const reachLines = owners.flatMap((owner) => {
|
|
273
|
+
const reaches = reachesOf(manifest, owner, permissions);
|
|
274
|
+
return reaches.length === 0 ? [] : [reachLine(owner, reaches)];
|
|
275
|
+
});
|
|
276
|
+
const measureLines = measures.map(({ name, measure }) => `${name} (${measure.unit}): ${sentence(measure.description)}`);
|
|
277
|
+
return [
|
|
278
|
+
`Aggregates: ${RECORDS_OR_AGGREGATES} (POST /api/ontology/aggregate, or the aggregate tool).`,
|
|
279
|
+
`${INDENT}Measures, as <entity>.<measure>:`,
|
|
280
|
+
...measureLines.map((line) => `${INDENT}${INDENT}${line}`),
|
|
281
|
+
...(reachLines.length === 0
|
|
282
|
+
? []
|
|
283
|
+
: [
|
|
284
|
+
`${INDENT}Group and filter by <entity>.<property>. Where a target says write the route, ` +
|
|
285
|
+
"name it from the measure's entity: <entity>.<relationship>.<property>. Through " +
|
|
286
|
+
"to-one relationships:",
|
|
287
|
+
...reachLines.map((line) => `${INDENT}${INDENT}${line}`),
|
|
288
|
+
]),
|
|
289
|
+
].join("\n");
|
|
184
290
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { SqlClient } from "effect/unstable/sql";
|
|
3
|
+
import type { OntologyManifest } from "../manifest.js";
|
|
4
|
+
import { type ReplicationSelection, type ReplicationExclusion } from "./model.js";
|
|
5
|
+
export interface ReplicationTarget {
|
|
6
|
+
readonly generation: string;
|
|
7
|
+
readonly source_id: string;
|
|
8
|
+
readonly from_revision: string;
|
|
9
|
+
readonly manifest: OntologyManifest;
|
|
10
|
+
readonly selection: ReadonlyArray<ReplicationSelection>;
|
|
11
|
+
}
|
|
12
|
+
/** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
|
|
13
|
+
export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: {
|
|
14
|
+
readonly generation: string;
|
|
15
|
+
readonly manifest: OntologyManifest;
|
|
16
|
+
readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
|
|
17
|
+
readonly selection: ReadonlyArray<ReplicationSelection>;
|
|
18
|
+
readonly newSource?: boolean;
|
|
19
|
+
}) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { joinColumnNames, joinTableName, quoteIdentifier as qi, quoteLiteral as ql, tableName, } from "../migrate/storage.js";
|
|
4
|
+
import { REPLICATION_RECORD_BYTES, validateReplicationSelection, } from "./model.js";
|
|
5
|
+
import { snapshotExpression } from "./schema.js";
|
|
6
|
+
const MAX_BASELINE_RECORDS = 50_000;
|
|
7
|
+
/** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
|
|
8
|
+
export const createReplicationBaseline = (sql, input) => sql.withTransaction(Effect.gen(function* () {
|
|
9
|
+
yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
|
|
10
|
+
yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
11
|
+
const [state] = yield* sql.unsafe("SELECT source_id::text, revision::text, enabled FROM _ontology_replication WHERE id FOR UPDATE");
|
|
12
|
+
if (!state) {
|
|
13
|
+
return yield* Effect.die(new Error("Run the ontology SDK migration before enabling replication."));
|
|
14
|
+
}
|
|
15
|
+
const existing = yield* sql.unsafe("SELECT * FROM _ontology_replication_targets WHERE generation = $1::uuid", [input.generation]);
|
|
16
|
+
if (existing[0]) {
|
|
17
|
+
return existing[0];
|
|
18
|
+
}
|
|
19
|
+
if (state.enabled) {
|
|
20
|
+
return yield* Effect.die(new Error("Pause replication before starting a new generation."));
|
|
21
|
+
}
|
|
22
|
+
const manifest = input.manifest;
|
|
23
|
+
yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
|
|
24
|
+
// Drain older SDK transactions that started before the compatibility fence was enabled.
|
|
25
|
+
const tables = manifest.entities
|
|
26
|
+
.filter((e) => e.source === undefined && e.name !== "user")
|
|
27
|
+
.flatMap((e) => [
|
|
28
|
+
tableName(e.name),
|
|
29
|
+
...e.relationships
|
|
30
|
+
.filter((r) => r.cardinality === "many")
|
|
31
|
+
.map((r) => joinTableName(e.name, r.name)),
|
|
32
|
+
]);
|
|
33
|
+
if (tables.length > 0) {
|
|
34
|
+
yield* sql.unsafe(`LOCK TABLE ${tables.map(qi).join(", ")} IN SHARE MODE`);
|
|
35
|
+
}
|
|
36
|
+
const source = input.newSource ? randomUUID() : state.source_id;
|
|
37
|
+
const selection = Object.fromEntries(input.selection.map((s) => [s.entityId, s]));
|
|
38
|
+
yield* sql.unsafe(`UPDATE _ontology_replication SET source_id = $1::uuid, selection = $2::jsonb, enabled = true,
|
|
39
|
+
exclusions = $3::jsonb, active_generation = $4::uuid WHERE id`, [
|
|
40
|
+
source,
|
|
41
|
+
JSON.stringify(selection),
|
|
42
|
+
JSON.stringify(input.exclusions ?? []),
|
|
43
|
+
input.generation,
|
|
44
|
+
]);
|
|
45
|
+
const target = {
|
|
46
|
+
generation: input.generation,
|
|
47
|
+
source_id: source,
|
|
48
|
+
from_revision: state.revision,
|
|
49
|
+
manifest,
|
|
50
|
+
selection: input.selection,
|
|
51
|
+
};
|
|
52
|
+
yield* sql.unsafe(`INSERT INTO _ontology_replication_targets (generation, source_id, from_revision, manifest, selection)
|
|
53
|
+
VALUES ($1::uuid, $2::uuid, $3::bigint, $4::jsonb, $5::jsonb)`, [
|
|
54
|
+
target.generation,
|
|
55
|
+
source,
|
|
56
|
+
state.revision,
|
|
57
|
+
JSON.stringify(manifest),
|
|
58
|
+
JSON.stringify(input.selection),
|
|
59
|
+
]);
|
|
60
|
+
let total = 0;
|
|
61
|
+
const baseline = (entityId, kind, records) => Effect.gen(function* () {
|
|
62
|
+
// Keep the snapshot in PostgreSQL; large baselines must not fill a serverless process's heap.
|
|
63
|
+
const [size] = yield* sql.unsafe(`SELECT count(*)::int AS count, max(octet_length(payload::text)) AS largest
|
|
64
|
+
FROM (${records} LIMIT ${MAX_BASELINE_RECORDS + 1}) snapshot`);
|
|
65
|
+
total += size.count;
|
|
66
|
+
if (total > MAX_BASELINE_RECORDS) {
|
|
67
|
+
return yield* Effect.die(new Error("The replication baseline exceeds 50,000 records; use a bulk bootstrap."));
|
|
68
|
+
}
|
|
69
|
+
if ((size.largest ?? 0) > REPLICATION_RECORD_BYTES) {
|
|
70
|
+
return yield* Effect.die(new Error("A projected baseline record exceeds the replication size limit."));
|
|
71
|
+
}
|
|
72
|
+
yield* sql.unsafe(`INSERT INTO _ontology_replication_baselines (generation, event)
|
|
73
|
+
SELECT $1::uuid, jsonb_build_object('source_id', $2::text, 'entity_id', $3::text,
|
|
74
|
+
'record_key', record_key, 'record_kind', $4::text, 'revision', $5::text,
|
|
75
|
+
'deleted', false, 'schema_hash', $6::text, 'payload', payload::text)
|
|
76
|
+
FROM (${records}) snapshot`, [target.generation, source, entityId, kind, state.revision, manifest.hash]);
|
|
77
|
+
});
|
|
78
|
+
for (const item of input.selection) {
|
|
79
|
+
const entity = manifest.entities.find((e) => e.id === item.entityId);
|
|
80
|
+
yield* baseline(entity.id, "entity", `SELECT r.id::text AS record_key,
|
|
81
|
+
(SELECT jsonb_object_agg(key, value) FROM jsonb_each(${snapshotExpression(entity, "r")})
|
|
82
|
+
WHERE key = 'id' OR ${ql(JSON.stringify(item.fields))}::jsonb ? key) AS payload
|
|
83
|
+
FROM ${qi(tableName(entity.name))} r`);
|
|
84
|
+
for (const relationship of entity.relationships.filter((r) => item.relationships?.includes(r.name))) {
|
|
85
|
+
const columns = joinColumnNames({
|
|
86
|
+
entity: entity.name,
|
|
87
|
+
relationship: relationship.name,
|
|
88
|
+
target: relationship.to,
|
|
89
|
+
});
|
|
90
|
+
yield* baseline(entity.id, `edge:${relationship.name}`, `SELECT ${qi(columns.source)}::text || ':' || ${qi(columns.target)}::text AS record_key,
|
|
91
|
+
jsonb_build_object('source_id', ${qi(columns.source)}::text,
|
|
92
|
+
'target_id', ${qi(columns.target)}::text) AS payload
|
|
93
|
+
FROM ${qi(joinTableName(entity.name, relationship.name))}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return target;
|
|
97
|
+
}));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReplicationTarget } from "./store.js";
|
|
2
|
+
export declare const REPLICATION_TABLE_SCHEMA: readonly [...{
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
mode: string;
|
|
6
|
+
}[], {
|
|
7
|
+
readonly name: "revision";
|
|
8
|
+
readonly type: "NUMERIC";
|
|
9
|
+
readonly mode: "REQUIRED";
|
|
10
|
+
}, {
|
|
11
|
+
readonly name: "deleted";
|
|
12
|
+
readonly type: "BOOLEAN";
|
|
13
|
+
readonly mode: "REQUIRED";
|
|
14
|
+
}, {
|
|
15
|
+
readonly name: "payload";
|
|
16
|
+
readonly type: "JSON";
|
|
17
|
+
readonly mode: "REQUIRED";
|
|
18
|
+
}];
|
|
19
|
+
/** Rank first, then apply tombstones. Every view reads the whole retained event history. */
|
|
20
|
+
export declare function replicationViews(project: string, target: ReplicationTarget): {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
readonly query: string;
|
|
23
|
+
}[];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { queryFields } from "../query.js";
|
|
2
|
+
export const REPLICATION_TABLE_SCHEMA = [
|
|
3
|
+
...[
|
|
4
|
+
"event_id",
|
|
5
|
+
"source_id",
|
|
6
|
+
"generation",
|
|
7
|
+
"entity_id",
|
|
8
|
+
"record_key",
|
|
9
|
+
"record_kind",
|
|
10
|
+
"schema_hash",
|
|
11
|
+
].map((name) => ({ name, type: "STRING", mode: "REQUIRED" })),
|
|
12
|
+
{ name: "revision", type: "NUMERIC", mode: "REQUIRED" },
|
|
13
|
+
{ name: "deleted", type: "BOOLEAN", mode: "REQUIRED" },
|
|
14
|
+
{ name: "payload", type: "JSON", mode: "REQUIRED" },
|
|
15
|
+
];
|
|
16
|
+
const literal = (value) => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
17
|
+
const identifier = (value) => {
|
|
18
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
19
|
+
throw new Error("Invalid BigQuery replication identifier.");
|
|
20
|
+
}
|
|
21
|
+
return `\`${value}\``;
|
|
22
|
+
};
|
|
23
|
+
/** Rank first, then apply tombstones. Every view reads the whole retained event history. */
|
|
24
|
+
export function replicationViews(project, target) {
|
|
25
|
+
identifier(project);
|
|
26
|
+
const result = [];
|
|
27
|
+
const latest = (entity, kind, columns) => `SELECT ${columns} FROM (
|
|
28
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY record_key ORDER BY revision DESC, event_id DESC) AS position
|
|
29
|
+
FROM \`${project}.ontology_internal.events\`
|
|
30
|
+
WHERE generation = ${literal(target.generation)} AND source_id = ${literal(target.source_id)}
|
|
31
|
+
AND entity_id = ${literal(entity)} AND record_kind = ${literal(kind)}
|
|
32
|
+
) WHERE position = 1 AND NOT deleted`;
|
|
33
|
+
for (const selection of target.selection) {
|
|
34
|
+
const entity = target.manifest.entities.find((e) => e.id === selection.entityId);
|
|
35
|
+
const columns = queryFields(entity)
|
|
36
|
+
.filter((field) => field.name === "id" || selection.fields.includes(field.name))
|
|
37
|
+
.map((field) => {
|
|
38
|
+
const path = literal(`$.${field.name}`);
|
|
39
|
+
const type = {
|
|
40
|
+
integer: "INT64",
|
|
41
|
+
number: "FLOAT64",
|
|
42
|
+
boolean: "BOOL",
|
|
43
|
+
date: "DATE",
|
|
44
|
+
timestamp: "TIMESTAMP",
|
|
45
|
+
}[field.storage];
|
|
46
|
+
const value = field.storage === "json"
|
|
47
|
+
? `JSON_QUERY(payload, ${path})`
|
|
48
|
+
: `JSON_VALUE(payload, ${path})`;
|
|
49
|
+
return `${type ? `SAFE_CAST(${value} AS ${type})` : value} AS ${identifier(field.name)}`;
|
|
50
|
+
});
|
|
51
|
+
result.push({ name: entity.name, query: latest(entity.id, "entity", columns.join(", ")) });
|
|
52
|
+
for (const relationship of selection.relationships ?? []) {
|
|
53
|
+
result.push({
|
|
54
|
+
name: `${entity.name}_${relationship}`,
|
|
55
|
+
query: latest(entity.id, `edge:${relationship}`, "JSON_VALUE(payload, '$.source_id') AS source_id, JSON_VALUE(payload, '$.target_id') AS target_id"),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (new Set(result.map((v) => v.name.toLowerCase())).size !== result.length) {
|
|
60
|
+
throw new Error("Replicated entity and relationship view names collide.");
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type OntologyManifest } from "../manifest.js";
|
|
2
|
+
/** The resolved projection for one immutable generation. `id` is always included. */
|
|
3
|
+
export interface ReplicationSelection {
|
|
4
|
+
readonly entityId: string;
|
|
5
|
+
readonly fields: ReadonlyArray<string>;
|
|
6
|
+
readonly relationships?: ReadonlyArray<string>;
|
|
7
|
+
}
|
|
8
|
+
/** Everything is replicated unless explicitly excluded. Internal identity data is never exported. */
|
|
9
|
+
export interface ReplicationExclusion {
|
|
10
|
+
readonly entityId: string;
|
|
11
|
+
readonly disabled?: boolean;
|
|
12
|
+
readonly fields?: ReadonlyArray<string>;
|
|
13
|
+
readonly relationships?: ReadonlyArray<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare function resolveReplicationSelection(manifest: OntologyManifest, exclusions?: ReadonlyArray<ReplicationExclusion>): ReadonlyArray<ReplicationSelection>;
|
|
16
|
+
export interface ReplicationEvent {
|
|
17
|
+
readonly event_id: string;
|
|
18
|
+
readonly source_id: string;
|
|
19
|
+
readonly generation: string;
|
|
20
|
+
readonly entity_id: string;
|
|
21
|
+
readonly record_key: string;
|
|
22
|
+
readonly record_kind: string;
|
|
23
|
+
/** Decimal strings preserve PostgreSQL bigint precision in JavaScript. */
|
|
24
|
+
readonly revision: string;
|
|
25
|
+
readonly deleted: boolean;
|
|
26
|
+
readonly schema_hash: string;
|
|
27
|
+
/** Pub/Sub's BigQuery table-schema mode expects a string for a JSON column. */
|
|
28
|
+
readonly payload: string;
|
|
29
|
+
}
|
|
30
|
+
export declare const REPLICATION_BATCH_SIZE = 100;
|
|
31
|
+
export declare const REPLICATION_BATCH_BYTES = 1000000;
|
|
32
|
+
export declare const REPLICATION_RECORD_BYTES = 200000;
|
|
33
|
+
export declare function validateReplicationSelection(manifest: OntologyManifest, selection: ReadonlyArray<ReplicationSelection>): void;
|
|
34
|
+
export declare function replicationEventId(source: string, history: string, generation: string): string;
|
|
35
|
+
/** Includes base64 and JSON overhead, not just the original payload bytes. */
|
|
36
|
+
export declare function publishMessage(event: ReplicationEvent): {
|
|
37
|
+
data: string;
|
|
38
|
+
};
|