@unoverse-platform/studio 0.1.96 → 0.1.98
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/bin/studio.mjs +3 -3
- package/local/authoring.ts +1 -1
- package/local/catalog.ts +22 -2
- package/local/keys.ts +1 -1
- package/local/nodes.ts +29 -42
- package/local/plugin.ts +75 -13
- package/local/sample-identity/brand.yaml +24 -0
- package/local/sample-identity/organisation.yaml +14 -0
- package/local/sample-identity/purpose.yaml +14 -0
- package/local/sample-identity/story.yaml +18 -0
- package/local/scaffold.mjs +51 -22
- package/local/source.ts +4 -9
- package/package.json +1 -1
- package/screens/AgentsView.tsx +173 -0
- package/screens/ComponentsView.tsx +2 -2
- package/screens/EmptyLibrary.tsx +1 -1
- package/screens/IdentityHeader.tsx +1 -1
- package/screens/IdentityView.tsx +206 -0
- package/screens/PromptBlocksView.tsx +1 -1
- package/screens/SourceView.tsx +1 -1
- package/screens/TemplatesLibraryView.tsx +91 -6
- package/screens/TemplatesView.tsx +24 -9
- package/screens/index.ts +5 -2
- package/src/App.tsx +14 -4
- package/src/NodeKeys.tsx +1 -1
- package/src/host.ts +3 -16
- package/vendor/base/src/agent-mcp/discovery.ts +76 -18
- package/vendor/base/src/agent-mcp/handoff.ts +11 -31
- package/vendor/base/src/agent-mcp/index.ts +5 -5
- package/vendor/base/src/agent-mcp/invoke.ts +37 -19
- package/vendor/base/src/agent-mcp/lean.ts +19 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +15 -9
- package/vendor/base/src/agent-mcp/memoryIngest.ts +8 -3
- package/vendor/base/src/agent-mcp/rowComponents.ts +184 -72
- package/vendor/base/src/agent-mcp/tools.ts +136 -7
- package/vendor/base/src/agent-mcp/types.ts +47 -10
- package/vendor/base/src/definitions/load/compile/component.ts +59 -4
- package/vendor/base/src/definitions/load/compile/sections.ts +7 -1
- package/vendor/base/src/definitions/load/compile/template.ts +19 -15
- package/vendor/base/src/definitions/load/expand/inherit.ts +5 -1
- package/vendor/base/src/definitions/load/expand/referenced.ts +21 -2
- package/vendor/base/src/definitions/load/expand/refs.ts +14 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +88 -38
- package/vendor/base/src/definitions/load/load.ts +37 -28
- package/vendor/base/src/definitions/model.ts +8 -12
- package/vendor/base/src/definitions/registry/agents.ts +111 -0
- package/vendor/base/src/definitions/registry/catalog.ts +28 -12
- package/vendor/base/src/definitions/registry/datasetSql.ts +149 -0
- package/vendor/base/src/definitions/registry/homes.ts +69 -5
- package/vendor/base/src/definitions/registry/objects.ts +413 -0
- package/vendor/base/src/definitions/registry/orgDocuments.ts +107 -0
- package/vendor/base/src/definitions/registry/orgPack.ts +50 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +197 -0
- package/vendor/base/src/definitions/theme.ts +1 -2
- package/vendor/base/src/items/collect.ts +33 -25
- package/vendor/base/src/items/publish.ts +31 -7
- package/vendor/base/src/lint/context.mjs +17 -0
- package/vendor/base/src/lint/design/file.mjs +199 -2
- package/vendor/base/src/lint/design/index.mjs +66 -73
- package/vendor/base/src/lint/design/vocabulary.mjs +6 -3
- package/vendor/base/src/lint/design/walk.mjs +14 -4
- package/vendor/base/src/lint/index.mjs +15 -9
- package/vendor/base/src/lint/nodes/blocks.mjs +6 -2
- package/vendor/base/src/lint/nodes/index.mjs +6 -1
- package/vendor/base/src/lint/nodes/node.mjs +157 -7
- package/vendor/base/src/lint/objects/index.d.mts +32 -0
- package/vendor/base/src/lint/objects/index.mjs +324 -0
- package/vendor/base/src/lint/objects/rules.d.mts +44 -0
- package/vendor/base/src/lint/objects/rules.mjs +446 -0
- package/vendor/base/src/lint/prompts/index.d.mts +34 -0
- package/vendor/base/src/lint/prompts/index.mjs +198 -0
- package/vendor/base/src/manifests/compose.ts +81 -10
- package/vendor/base/src/manifests/executor/callback.ts +15 -4
- package/vendor/base/src/manifests/executor/context.ts +12 -1
- package/vendor/base/src/manifests/executor/memory.ts +83 -3
- package/vendor/base/src/manifests/executor/session.ts +50 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +114 -49
- package/vendor/base/src/manifests/index.ts +1 -1
- package/vendor/base/src/manifests/runtime/auth/aws.ts +14 -1
- package/vendor/base/src/manifests/runtime/channels/final.ts +46 -34
- package/vendor/base/src/manifests/runtime/channels/service.ts +30 -32
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +6 -11
- package/vendor/base/src/manifests/runtime/context.ts +18 -6
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +159 -3
- package/vendor/base/src/manifests/runtime/duplex/session.ts +304 -9
- package/vendor/base/src/manifests/runtime/events.ts +39 -3
- package/vendor/base/src/manifests/runtime/http/concurrency.ts +44 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +36 -7
- package/vendor/base/src/manifests/runtime/index.ts +3 -2
- package/vendor/base/src/manifests/runtime/loops/repeat.ts +86 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +30 -3
- package/vendor/base/src/manifests/runtime/usage.ts +10 -0
- package/vendor/base/src/manifests/source.ts +1 -1
- package/vendor/base/src/paths.ts +16 -8
- package/vendor/base/src/platform/audioManager.ts +32 -0
- package/vendor/base/src/platform/executionContext.ts +14 -0
- package/vendor/base/src/platform/liveInput.ts +52 -0
- package/vendor/base/src/plugins/README.md +1 -1
- package/vendor/base/src/plugins/discovery.ts +1 -1
- package/vendor/base/src/template/SafeExpression.ts +6 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +12 -11
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +23 -10
- package/vendor/sdk/appEngine.tsx +31 -12
- package/vendor/sdk/lib/UnoverseComponent.tsx +8 -4
- package/vendor/sdk/lib/app.tsx +70 -95
- package/vendor/sdk/lib/connection.tsx +47 -57
- package/vendor/sdk/lib/core/actions.ts +6 -0
- package/vendor/sdk/lib/core/apps.ts +66 -81
- package/vendor/sdk/lib/core/client.ts +5 -14
- package/vendor/sdk/lib/core/connection.ts +30 -0
- package/vendor/sdk/lib/core/index.ts +2 -2
- package/vendor/sdk/lib/core/store.ts +90 -15
- package/vendor/sdk/lib/core/types.ts +9 -8
- package/vendor/sdk/lib/primitives.tsx +113 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +8 -1
- package/vendor/sdk/lib/render.tsx +3 -3
- package/vendor/sdk/lib/voice.tsx +27 -13
- package/vite.config.ts +1 -1
|
@@ -11,6 +11,12 @@ export interface UnoverseDefinition {
|
|
|
11
11
|
* `template` is the Template Model kind (UNOVERSE_TEMPLATE_MODEL.md). */
|
|
12
12
|
kind: "component" | "app" | "atom" | "template";
|
|
13
13
|
name: string;
|
|
14
|
+
/** APPS: the places the app's layouts declare, in the order first drawn. The screen puts
|
|
15
|
+
* interfaces into them by name (docs/unoverse/design/screen/UNOVERSE_SCREEN.md §2.2). */
|
|
16
|
+
places?: { name: string; holds: "one" | "many" }[];
|
|
17
|
+
/** COMPONENTS AND TEMPLATES: the place each state names. An interface entering a state
|
|
18
|
+
* that names a place moves there (UNOVERSE_SCREEN.md §2.3). */
|
|
19
|
+
statePlaces?: Record<string, string>;
|
|
14
20
|
/** TEMPLATES (§9b RULED 2026-08-29): the compiled SECTIONS of each state's
|
|
15
21
|
* layout — one entry per `static:` / `copywriter:` / `director:` line, in
|
|
16
22
|
* arrangement order. `static`/`copywriter` link a component (face = its
|
|
@@ -89,8 +95,8 @@ export interface UnoverseDefinition {
|
|
|
89
95
|
* `defaultState` seeds it so the SDK renders that face on arrival (merged beneath
|
|
90
96
|
* live data). Server-composed from the manifest, never hand-written. */
|
|
91
97
|
state?: Record<string, unknown>;
|
|
92
|
-
/** COMPONENTS: projected capability flag — true iff the
|
|
93
|
-
* manifest
|
|
98
|
+
/** COMPONENTS and TEMPLATES: projected capability flag — true iff the folder carries a
|
|
99
|
+
* manifest (offerable to the map by its switch). Derived from presence, never authored. */
|
|
94
100
|
spatial?: boolean;
|
|
95
101
|
/** Display icon URL (from the manifest; catalogs/drawers only). */
|
|
96
102
|
icon?: string;
|
|
@@ -147,16 +153,6 @@ export interface UnoverseDefinition {
|
|
|
147
153
|
* ladder and arrival-scan order, from the manifest's `stateOrder`. First entry
|
|
148
154
|
* outranks all below it; no name is special. Absent → legacy recency semantics. */
|
|
149
155
|
stateOrder?: string[];
|
|
150
|
-
/** THE LATCH, PER STATE (MCP guide §The Component Latch, moved here from the manifest
|
|
151
|
-
* 2026-08-31): the states whose wearer the conversation may be ADDRESSED AT, keyed by
|
|
152
|
-
* state name. Compiled from each state's own `latch:` block and stamped beside
|
|
153
|
-
* `stateOrder`, because states themselves are compiled away into `layouts`.
|
|
154
|
-
*
|
|
155
|
-
* A state absent from this map can never hold the latch, so the SAME instance
|
|
156
|
-
* retracted from its page state to an inline one drops the pill by construction —
|
|
157
|
-
* which is the thing a manifest-level declaration could not say. `resolveLatch` reads
|
|
158
|
-
* this map; nothing about the latch travels on the wire with a render. */
|
|
159
|
-
latches?: Record<string, { title: string; icon?: string }>;
|
|
160
156
|
/** COMPONENTS (STATE_MODEL v2 §5 rule 2): the PUBLIC MENU — the top-level state
|
|
161
157
|
* names of the authored `state.view` tree, compiled at serve time. What hosts may
|
|
162
158
|
* place at spawn and templates may react to; nested substates never appear here. */
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGENTS, READ (docs.unoverse.ai/design/tasks; docs/unoverse/data/objects/elements/task.md, ruled 2026-09-06).
|
|
3
|
+
*
|
|
4
|
+
* An Agent is a workflow with no interface of its own, listed on the map so a conversation
|
|
5
|
+
* can reach for it. It is the ONE thing authored for the map alone: every other asset (a
|
|
6
|
+
* component, a template, an app) is offered to the map by its Available switch and keeps
|
|
7
|
+
* its meta on its own manifest, never written twice. One file per Agent:
|
|
8
|
+
*
|
|
9
|
+
* design/<org>/agents/<name>.yaml type: agent, the discovery meta, the door
|
|
10
|
+
*
|
|
11
|
+
* The door is a canvas in the org, its Input Trigger, and the node whose output is the
|
|
12
|
+
* result; all three, always. The face while it runs is the shared agent-card unless the
|
|
13
|
+
* file names another. The input schema is the trigger's, declared on the canvas.
|
|
14
|
+
*
|
|
15
|
+
* Like the identity reader beside it, this is NOT a definition kind: nothing here composes
|
|
16
|
+
* a UI tree. It only reads, so Studio can list and the registry lane can project.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { parse } from "yaml";
|
|
21
|
+
import { listOrgs, projectDir } from "./homes.js";
|
|
22
|
+
|
|
23
|
+
export const AGENT_CARD = "unoverse://components/agent-card";
|
|
24
|
+
|
|
25
|
+
export interface AgentFile {
|
|
26
|
+
/** `<org>/<name>`: the identity every registry row and toggle keys on. */
|
|
27
|
+
name: string;
|
|
28
|
+
org: string;
|
|
29
|
+
/** The bare file name, which is also the tool's name on the wire. */
|
|
30
|
+
id: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
whenToUse?: string;
|
|
34
|
+
/** What to do: the steps the Agent follows. Rides the tool description. */
|
|
35
|
+
instructions?: string;
|
|
36
|
+
/** The skills the Agent needs, by name. */
|
|
37
|
+
skills?: string[];
|
|
38
|
+
category?: string;
|
|
39
|
+
version?: string;
|
|
40
|
+
/** What the face says while the Agent runs. */
|
|
41
|
+
message?: string;
|
|
42
|
+
/** The face while it runs. Defaults to the shared agent-card. */
|
|
43
|
+
app: string;
|
|
44
|
+
/** The door. */
|
|
45
|
+
workflow?: string;
|
|
46
|
+
trigger?: string;
|
|
47
|
+
output?: string;
|
|
48
|
+
/** Where it lives, relative to design/<org>/. */
|
|
49
|
+
path: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** design/<org>/agents/ */
|
|
53
|
+
export const agentsDir = (root: string): string => join(root, "agents");
|
|
54
|
+
|
|
55
|
+
const isDef = (f: string): boolean => /\.ya?ml$/.test(f) && !f.startsWith(".");
|
|
56
|
+
|
|
57
|
+
/** One Agent file, by its bare name. Null when the file is missing or is not an Agent. */
|
|
58
|
+
export function readAgent(root: string, org: string, id: string): AgentFile | null {
|
|
59
|
+
const dir = agentsDir(root);
|
|
60
|
+
const file = [join(dir, `${id}.yaml`), join(dir, `${id}.yml`)].find((f) => existsSync(f));
|
|
61
|
+
if (!file) return null;
|
|
62
|
+
const json = (parse(readFileSync(file, "utf8")) as Record<string, any>) ?? {};
|
|
63
|
+
if (json.type !== "agent") return null;
|
|
64
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v : undefined);
|
|
65
|
+
return {
|
|
66
|
+
name: `${org}/${id}`,
|
|
67
|
+
org,
|
|
68
|
+
id,
|
|
69
|
+
title: str(json.title),
|
|
70
|
+
description: str(json.description),
|
|
71
|
+
whenToUse: str(json.whenToUse),
|
|
72
|
+
instructions: str(json.instructions),
|
|
73
|
+
...(Array.isArray(json.skills) ? { skills: json.skills.filter((x: unknown): x is string => typeof x === "string" && !!x.trim()) } : {}),
|
|
74
|
+
category: str(json.category),
|
|
75
|
+
version: str(json.version),
|
|
76
|
+
message: str(json.message),
|
|
77
|
+
app: str(json.app) ?? AGENT_CARD,
|
|
78
|
+
workflow: str(json.workflow),
|
|
79
|
+
trigger: str(json.trigger),
|
|
80
|
+
output: str(json.output),
|
|
81
|
+
path: `agents/${file.slice(dir.length + 1)}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every Agent an org authored, sorted by name. */
|
|
86
|
+
export function listAgentsIn(root: string, org: string): AgentFile[] {
|
|
87
|
+
const dir = agentsDir(root);
|
|
88
|
+
if (!existsSync(dir)) return [];
|
|
89
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
90
|
+
.filter((e) => e.isFile() && isDef(e.name))
|
|
91
|
+
.map((e) => readAgent(root, org, e.name.replace(/\.ya?ml$/, "")))
|
|
92
|
+
.filter((a): a is AgentFile => a !== null)
|
|
93
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every Agent across the workspace, or one org's. */
|
|
97
|
+
export function listAgents(org?: string): AgentFile[] {
|
|
98
|
+
const orgs = org && org !== "all" ? [org] : listOrgs();
|
|
99
|
+
return orgs.flatMap((o) => listAgentsIn(projectDir(o), o));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** An Agent by its qualified ref (`<org>/<name>`), the form every toggle and row carries. */
|
|
103
|
+
export function loadAgent(ref: string): AgentFile | null {
|
|
104
|
+
const slash = ref.indexOf("/");
|
|
105
|
+
if (slash <= 0) return null;
|
|
106
|
+
const org = ref.slice(0, slash);
|
|
107
|
+
return readAgent(projectDir(org), org, ref.slice(slash + 1));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The door is three fields, always. */
|
|
111
|
+
export const hasDoor = (a: AgentFile): boolean => Boolean(a.workflow && a.trigger && a.output);
|
|
@@ -100,8 +100,12 @@ export interface AppManifest {
|
|
|
100
100
|
* NOT `type`, which is the legacy full-surface/inline ROUTER word (`type: "template"`
|
|
101
101
|
* means a fluid-height app, and says nothing about where its definition lives). Two
|
|
102
102
|
* meanings on one field is how a template app would come to be loaded as a component and
|
|
103
|
-
* fail with a resolver error nobody could read.
|
|
104
|
-
|
|
103
|
+
* fail with a resolver error nobody could read.
|
|
104
|
+
*
|
|
105
|
+
* "app" joined the union when APPS started naming themselves as their own deliverable
|
|
106
|
+
* (`loadAppManifest`): an app's definition lives in the app tier, so the same rule that
|
|
107
|
+
* keeps a template from loading as a component keeps an app from doing it too. */
|
|
108
|
+
kind?: "component" | "template" | "app";
|
|
105
109
|
/** Human display name; falls back to `name`. */
|
|
106
110
|
title?: string;
|
|
107
111
|
description?: string;
|
|
@@ -171,12 +175,6 @@ export interface AppManifest {
|
|
|
171
175
|
* the CONVERSATION (not the turn) so every re-call hydrates the same slice, and the
|
|
172
176
|
* client's new-turn reset skips it — it stays on screen until replaced or closed. */
|
|
173
177
|
lifetime?: "turn" | "conversation";
|
|
174
|
-
/* NO `latch` HERE (moved 2026-08-31, MCP guide §The Component Latch). It was a manifest
|
|
175
|
-
* field seeded onto the slice with every render, which said one thing about a component
|
|
176
|
-
* for the whole of its life; a page is addressable when it is THE PAGE and not when it
|
|
177
|
-
* is a summary in the flow, and the state is what knows that. It is now declared on the
|
|
178
|
-
* app's state and compiled to `def.latches` — a fact the client looks up, not one the
|
|
179
|
-
* server has to send. */
|
|
180
178
|
/** Workbench MOCK: what each STATE's preview seeds — `{ "<state>": ["course-card",
|
|
181
179
|
* "course-card"] }` (a repeated name = several instances, e.g. a card rail). The
|
|
182
180
|
* author decides per state; the seeded instances are written INTO that state's view
|
|
@@ -287,7 +285,24 @@ export function loadAppManifest(ref: string): AppManifest | null {
|
|
|
287
285
|
m.autoTrigger === undefined && typeof m.binding?.autoTrigger === "boolean"
|
|
288
286
|
? { autoTrigger: m.binding.autoTrigger }
|
|
289
287
|
: {};
|
|
290
|
-
|
|
288
|
+
// THE DELIVERABLE IS THE APP, and saying so is what lets a called app open.
|
|
289
|
+
// Both siblings below already default this to themselves (`loadComponentApp`,
|
|
290
|
+
// the template loader); the app loader never did, so `app.previewComponents?.[0]`
|
|
291
|
+
// was undefined and the app tool's whole render block — gated on that ref — was
|
|
292
|
+
// skipped. The call returned cleanly, nothing reached /stream, and the model told
|
|
293
|
+
// the person to use a surface that had never appeared (SAB sab-voice, 2026-09-07).
|
|
294
|
+
// `kind` rides along for the same reason it does on a template: the render path
|
|
295
|
+
// resolves the definition in the tier `kind` names, and an app is not a component.
|
|
296
|
+
return normalizeMode({
|
|
297
|
+
...lifted,
|
|
298
|
+
...bindingAuto,
|
|
299
|
+
...m,
|
|
300
|
+
id,
|
|
301
|
+
org: hit.org,
|
|
302
|
+
kind: m.kind ?? "app",
|
|
303
|
+
previewComponents: m.previewComponents?.length ? m.previewComponents : [`${hit.org}/${id}`],
|
|
304
|
+
states: listStates("app", `${hit.org}/${id}`),
|
|
305
|
+
});
|
|
291
306
|
}
|
|
292
307
|
|
|
293
308
|
/** Load a COMPONENT as a native MCP app (UNOVERSE_MCP_APP_PROTOCOL §0.1 path B): a
|
|
@@ -352,9 +367,10 @@ export function loadTemplateApp(id: string, org?: string): AppManifest | null {
|
|
|
352
367
|
// Template manifests carry `title`, never `name` (the folder is the name).
|
|
353
368
|
name: m.name ?? m.title ?? lower,
|
|
354
369
|
kind: "template",
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
370
|
+
// TURN unless the manifest opts in (docs.unoverse.ai/design/state, "Arrival and
|
|
371
|
+
// lifetime"). A durable slice is exempt from the same-state replace, so a template
|
|
372
|
+
// defaulted durable was never replaced by the next arrival in its state.
|
|
373
|
+
lifetime: m.lifetime ?? "turn",
|
|
358
374
|
// The deliverable IS this template — the render path resolves the definition from this
|
|
359
375
|
// ref, in the tier `kind` names.
|
|
360
376
|
previewComponents: m.previewComponents?.length ? m.previewComponents : [org ? `${org}/${lower}` : lower],
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DATASET'S TABLE, AS SQL (docs/unoverse/data/objects/dataset.md, PROPOSED 2026-09-18).
|
|
3
|
+
*
|
|
4
|
+
* The declaration in an object's folder is the schema, so nobody writes SQL and nobody edits a
|
|
5
|
+
* schema by hand. This turns that declaration into a migration: a file a person reads, approves
|
|
6
|
+
* and can reverse. Nothing here runs anything.
|
|
7
|
+
*
|
|
8
|
+
* WHERE IT GOES. Beside the declaration, as `dataset.sql`, and never into the migrations folder:
|
|
9
|
+
* a dataset's table is made at publish by the platform's one door, and a file kept where the
|
|
10
|
+
* migration runner finds it would be a step someone has to run. This is what ran, written down,
|
|
11
|
+
* so a schema change can be read and compared and is never invisible.
|
|
12
|
+
*
|
|
13
|
+
* THE POLICIES ARE PART OF THE TABLE, not a later step. They are written in the same statement
|
|
14
|
+
* block, so a table cannot exist without them. A table created today and secured on Thursday is
|
|
15
|
+
* a table that was open until Thursday.
|
|
16
|
+
*/
|
|
17
|
+
import type { DatasetColumn, DatasetDefinition } from "./objects.js";
|
|
18
|
+
|
|
19
|
+
/** What each declared type becomes. `number` is NUMERIC: exact decimal, never floating point,
|
|
20
|
+
* so a total that must reconcile against a stated figure does not drift. */
|
|
21
|
+
const SQL_TYPE: Record<string, string> = {
|
|
22
|
+
string: "TEXT",
|
|
23
|
+
number: "NUMERIC",
|
|
24
|
+
integer: "BIGINT",
|
|
25
|
+
date: "DATE",
|
|
26
|
+
datetime: "TIMESTAMPTZ",
|
|
27
|
+
boolean: "BOOLEAN",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* THE TABLE A DATASET IS STORED IN. Built here, never taken from input, and never able to name a
|
|
32
|
+
* table the platform already owns: every one begins `dataset_`.
|
|
33
|
+
*
|
|
34
|
+
* THE ORG IS PART OF THE NAME. Two organisations may each declare an object called the same
|
|
35
|
+
* thing, with different columns, and without the org in the name the second would find the
|
|
36
|
+
* first's table already there and quietly write into it. A marketplace object, belonging to no
|
|
37
|
+
* org, keeps the bare name.
|
|
38
|
+
*
|
|
39
|
+
* The squeeze to letters, digits and underscores is what makes the name safe; it is also what
|
|
40
|
+
* would let "my-thing" and "my_thing" become one table, so the lint refuses two objects whose
|
|
41
|
+
* names arrive at the same table (rules.mjs).
|
|
42
|
+
*/
|
|
43
|
+
const squeeze = (s: string): string => s.replace(/[^a-z0-9]+/gi, "_").toLowerCase();
|
|
44
|
+
export const tableNameOf = (object: string, org?: string | null): string =>
|
|
45
|
+
org ? `dataset_${squeeze(org)}_${squeeze(object)}` : `dataset_${squeeze(object)}`;
|
|
46
|
+
|
|
47
|
+
// THE DESCRIPTION GOES ABOVE THE COLUMN, never after it: a trailing `-- comment` swallows the
|
|
48
|
+
// comma that separates one column from the next, and the statement stops parsing.
|
|
49
|
+
// A WORD THAT IS THE LANGUAGE IS CHECKED BY THE DATABASE. Without this, "exposure", "Exposure"
|
|
50
|
+
// and "exposures" are three measures that never add up with each other, and nothing says so: the
|
|
51
|
+
// rows are all there, and every total is quietly short. The list is quoted here and comes from a
|
|
52
|
+
// declaration the lint already refused if it was malformed.
|
|
53
|
+
const allowedList = (c: DatasetColumn): string =>
|
|
54
|
+
(c.enum ?? []).map((v) => `'${v.replace(/'/g, "''")}'`).join(", ");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* THE WORDS A COLUMN ALLOWS, as the database will hold them. Named here and nowhere else, so the
|
|
58
|
+
* table a migration makes and the table the one door brings forward are checked the same way: a
|
|
59
|
+
* check written twice is a check that drifts, and drift here is a word that passes in one table
|
|
60
|
+
* and is refused in another.
|
|
61
|
+
*
|
|
62
|
+
* EMPTY IS NOT A WORD, and that is the point: a column whose words are closed still takes nothing
|
|
63
|
+
* at all, so a reading we have no word for yet is kept with the document's own wording and no word
|
|
64
|
+
* of ours (dataset.md, "The language"). `IN` passes NULL of its own accord, which is why this does
|
|
65
|
+
* not have to say so.
|
|
66
|
+
*/
|
|
67
|
+
export const checkOf = (c: DatasetColumn): string | null =>
|
|
68
|
+
c.enum?.length ? `(${c.name} IN (${allowedList(c)}))` : null;
|
|
69
|
+
|
|
70
|
+
/** What that check is called on a table. One name per column, so bringing it forward replaces it. */
|
|
71
|
+
export const checkNameOf = (table: string, column: string): string => `chk_${table}_${column}`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ONE ROW PER THING MEASURED. The identity a declaration gives its rows, as the index that
|
|
75
|
+
* enforces it. NULLS ARE DISTINCT here, as Postgres has it by default: a row whose identity is
|
|
76
|
+
* partly unknown collides with nothing, and two readings we have no word for yet are two rows.
|
|
77
|
+
*/
|
|
78
|
+
export const uniqueIndexOf = (object: string, d: DatasetDefinition, org?: string | null): string | null => {
|
|
79
|
+
if (!d.uniqueBy?.length) return null;
|
|
80
|
+
const t = tableNameOf(object, org);
|
|
81
|
+
return `CREATE UNIQUE INDEX IF NOT EXISTS idx_${t}_item ON ${t} (COALESCE(org, ''), of_id, ${d.uniqueBy.join(", ")})`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** The columns that index is over, in order, as bare names: what it is compared against. */
|
|
85
|
+
export const uniqueColumnsOf = (d: DatasetDefinition): string[] => ["org", "of_id", ...(d.uniqueBy ?? [])];
|
|
86
|
+
|
|
87
|
+
const column = (c: DatasetColumn): string => {
|
|
88
|
+
const said = c.description ? ` -- ${c.description.replace(/\s+/g, " ")}\n` : "";
|
|
89
|
+
const only = checkOf(c) ? ` CHECK ${checkOf(c)}` : "";
|
|
90
|
+
return `${said} ${c.name} ${SQL_TYPE[c.type]}${c.required ? " NOT NULL" : ""}${only}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The migration for one dataset. Idempotent: every statement is IF NOT EXISTS, so applying it
|
|
95
|
+
* again does nothing, which is what lets a migration be re-run safely after a failure.
|
|
96
|
+
*/
|
|
97
|
+
export function datasetMigration(object: string, d: DatasetDefinition, org?: string | null): string {
|
|
98
|
+
const t = tableNameOf(object, org);
|
|
99
|
+
const cols = d.columns.map(column).join(",\n");
|
|
100
|
+
const indexed = d.columns.filter((c) => c.indexed);
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
|
|
103
|
+
lines.push(`-- THE ${object.toUpperCase()} DATASET (docs/unoverse/data/objects/dataset.md).`);
|
|
104
|
+
lines.push(`-- Generated from ${org ? `design/${org}` : "design"}/objects/${object}/dataset.yaml. Do not edit by hand:`);
|
|
105
|
+
lines.push(`-- edit the declaration and generate it again, so the folder and the database never disagree.`);
|
|
106
|
+
if (d.description) lines.push(`--\n-- ${d.description.replace(/\s+/g, " ")}`);
|
|
107
|
+
lines.push("");
|
|
108
|
+
lines.push(`CREATE TABLE IF NOT EXISTS ${t} (`);
|
|
109
|
+
lines.push(` id UUID PRIMARY KEY DEFAULT gen_random_uuid(),`);
|
|
110
|
+
lines.push(` -- WHOSE IT IS, and what it belongs to. Not the author's to name, and not optional:`);
|
|
111
|
+
lines.push(` -- these are what the access policies stand on and what makes a delete complete.`);
|
|
112
|
+
lines.push(` org VARCHAR(64),`);
|
|
113
|
+
lines.push(` of_id TEXT NOT NULL REFERENCES dictionary_need_states(universal_id) ON DELETE CASCADE,`);
|
|
114
|
+
lines.push(` -- WHAT IT WAS READ FROM. A row outlives neither the thing it belongs to nor the`);
|
|
115
|
+
lines.push(` -- document it came from: delete either and it goes, with no sweep to remember.`);
|
|
116
|
+
lines.push(` document_id TEXT REFERENCES dictionary_need_states(universal_id) ON DELETE CASCADE,`);
|
|
117
|
+
lines.push(cols + ",");
|
|
118
|
+
lines.push(` created_at TIMESTAMPTZ NOT NULL DEFAULT now(),`);
|
|
119
|
+
lines.push(` updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`);
|
|
120
|
+
lines.push(`);`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push(`-- THE POLICY'S OWN COLUMNS ARE INDEXED, or the policy filters every row of every query.`);
|
|
123
|
+
lines.push(`CREATE INDEX IF NOT EXISTS idx_${t}_of ON ${t} (org, of_id);`);
|
|
124
|
+
lines.push(`CREATE INDEX IF NOT EXISTS idx_${t}_document ON ${t} (document_id);`);
|
|
125
|
+
for (const c of indexed) lines.push(`CREATE INDEX IF NOT EXISTS idx_${t}_${c.name} ON ${t} (${c.name});`);
|
|
126
|
+
const unique = uniqueIndexOf(object, d, org);
|
|
127
|
+
if (unique) {
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push(`-- ONE ROW PER THING MEASURED: a read again replaces its rows rather than adding a second`);
|
|
130
|
+
lines.push(`-- set beside them, so a total never counts one reading twice.`);
|
|
131
|
+
lines.push(`${unique};`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
134
|
+
lines.push(`-- ACCESS IS THE DATABASE'S, NEVER THE QUERY'S (dataset.md). A read function of an object`);
|
|
135
|
+
lines.push(`-- may be called by a component, and a component is a browser: a filter written inside`);
|
|
136
|
+
lines.push(`-- that function is one missed condition from leaking. A policy is not.`);
|
|
137
|
+
lines.push(`--`);
|
|
138
|
+
lines.push(`-- The context is set PER TRANSACTION (SET LOCAL), never per connection: with a pooler,`);
|
|
139
|
+
lines.push(`-- a setting that outlives its transaction hands the next request the last one's org.`);
|
|
140
|
+
lines.push(`-- And the application must not own this table: an owner ignores every policy silently.`);
|
|
141
|
+
lines.push(`ALTER TABLE ${t} ENABLE ROW LEVEL SECURITY;`);
|
|
142
|
+
lines.push(`ALTER TABLE ${t} FORCE ROW LEVEL SECURITY;`);
|
|
143
|
+
lines.push(`DROP POLICY IF EXISTS ${t}_org ON ${t};`);
|
|
144
|
+
lines.push(`CREATE POLICY ${t}_org ON ${t}`);
|
|
145
|
+
lines.push(` USING (org IS NOT DISTINCT FROM NULLIF(current_setting('app.org', true), ''))`);
|
|
146
|
+
lines.push(` WITH CHECK (org IS NOT DISTINCT FROM NULLIF(current_setting('app.org', true), ''));`);
|
|
147
|
+
lines.push("");
|
|
148
|
+
return lines.join("\n") + "\n";
|
|
149
|
+
}
|
|
@@ -9,7 +9,7 @@ import { join, dirname } from "path";
|
|
|
9
9
|
import { fileURLToPath } from "url";
|
|
10
10
|
import { packagedDesignSystem } from "../dsPackage.js";
|
|
11
11
|
import { defPath } from "../fsCache.js";
|
|
12
|
-
import {
|
|
12
|
+
import { DESIGN_HOME, INSTALLED_HOME, databaseOnly, designDir } from "../../paths.js";
|
|
13
13
|
|
|
14
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
|
|
@@ -63,8 +63,8 @@ const INSTALLED_DESIGN = designDir(INSTALLED_HOME);
|
|
|
63
63
|
* or nodes, all of which are rows". A dead path that would silently adopt an npm tree if
|
|
64
64
|
* one ever appeared is worse than no path. */
|
|
65
65
|
const MARKETPLACE = join(DESIGN, "marketplace");
|
|
66
|
-
const DS_BUNDLE_CANDIDATES = [
|
|
67
|
-
function marketplaceDir(kind: "components" | "atoms" | "templates"): string {
|
|
66
|
+
const DS_BUNDLE_CANDIDATES: string[] = [];
|
|
67
|
+
function marketplaceDir(kind: "components" | "atoms" | "templates" | "objects" | "pipelines"): string {
|
|
68
68
|
const onDisk = join(MARKETPLACE, kind);
|
|
69
69
|
// Under the switch the authored tiers are skipped, so what a deployed universe would
|
|
70
70
|
// resolve is what a developer resolves.
|
|
@@ -95,7 +95,15 @@ export const SHARED_DIR = {
|
|
|
95
95
|
get template() {
|
|
96
96
|
return marketplaceDir("templates");
|
|
97
97
|
},
|
|
98
|
-
|
|
98
|
+
// `object` (2026-09-08): the kinds of thing that live on the map, docs/unoverse/data/objects/README.md.
|
|
99
|
+
get object() {
|
|
100
|
+
return marketplaceDir("objects");
|
|
101
|
+
},
|
|
102
|
+
// `pipeline` (2026-09-09): the source side of ingestion, docs/unoverse/data/pipelines/README.md layer 1.
|
|
103
|
+
get pipeline() {
|
|
104
|
+
return marketplaceDir("pipelines");
|
|
105
|
+
},
|
|
106
|
+
} as Record<"component" | "atom" | "template" | "object" | "pipeline", string>;
|
|
99
107
|
|
|
100
108
|
// rx-root entries that are NOT projects: the marketplace, the schema, and the legacy
|
|
101
109
|
// orgs/ container itself. Everything else at the root is a flat project (the target model).
|
|
@@ -124,7 +132,8 @@ export function projectDir(name: string): string {
|
|
|
124
132
|
export function listOrgs(): string[] {
|
|
125
133
|
const isProject = (dir: string) =>
|
|
126
134
|
existsSync(join(dir, "styles")) || existsSync(join(dir, "apps")) || existsSync(join(dir, "components")) ||
|
|
127
|
-
existsSync(join(dir, "templates"))
|
|
135
|
+
existsSync(join(dir, "templates")) || existsSync(join(dir, "agents")) ||
|
|
136
|
+
existsSync(join(dir, "objects")) || existsSync(join(dir, "identity")) || existsSync(join(dir, "pipelines"));
|
|
128
137
|
const flat = existsSync(DESIGN) && !databaseOnly()
|
|
129
138
|
? readdirSync(DESIGN, { withFileTypes: true })
|
|
130
139
|
.filter((e) => e.isDirectory() && !RESERVED_DESIGN.has(e.name) && isProject(join(DESIGN, e.name)))
|
|
@@ -178,6 +187,30 @@ export function templateDirs(refOrg: string | null): { org: string | undefined;
|
|
|
178
187
|
return refOrg === null ? [shared, ...orgDirs] : [...orgDirs, shared];
|
|
179
188
|
}
|
|
180
189
|
|
|
190
|
+
/** The OBJECT homes a ref resolves against (docs/unoverse/data/objects/README.md): the same two
|
|
191
|
+
* tiers as components, the marketplace (`design/marketplace/objects`, the platform's kinds,
|
|
192
|
+
* and `need`, the atom every object extends) and the org packs (`design/<org>/objects`, an
|
|
193
|
+
* org's own kinds). An org-qualified ref searches its org first, then the marketplace, so
|
|
194
|
+
* `extends: need` resolves from an org's object; a bare ref searches the marketplace first. */
|
|
195
|
+
export function objectDirs(refOrg: string | null): { org: string | undefined; dir: string }[] {
|
|
196
|
+
const shared = { org: undefined as string | undefined, dir: SHARED_DIR.object };
|
|
197
|
+
const orgDirs = (refOrg === null ? listOrgs() : [refOrg])
|
|
198
|
+
.filter((o) => existsSync(join(projectDir(o), "objects")))
|
|
199
|
+
.map((o) => ({ org: o as string | undefined, dir: join(projectDir(o), "objects") }));
|
|
200
|
+
return refOrg === null ? [shared, ...orgDirs] : [...orgDirs, shared];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The PIPELINE homes a ref resolves against (docs/unoverse/data/pipelines/README.md layer 1): the marketplace
|
|
204
|
+
* (`design/marketplace/pipelines`, the sources everyone gets: website, spreadsheet, images) and
|
|
205
|
+
* the org packs (`design/<org>/pipelines`, a source nobody else has). Same order as objects. */
|
|
206
|
+
export function pipelineDirs(refOrg: string | null): { org: string | undefined; dir: string }[] {
|
|
207
|
+
const shared = { org: undefined as string | undefined, dir: SHARED_DIR.pipeline };
|
|
208
|
+
const orgDirs = (refOrg === null ? listOrgs() : [refOrg])
|
|
209
|
+
.filter((o) => existsSync(join(projectDir(o), "pipelines")))
|
|
210
|
+
.map((o) => ({ org: o as string | undefined, dir: join(projectDir(o), "pipelines") }));
|
|
211
|
+
return refOrg === null ? [shared, ...orgDirs] : [...orgDirs, shared];
|
|
212
|
+
}
|
|
213
|
+
|
|
181
214
|
/** The component homes a ref resolves against. Components live in TWO tiers: the
|
|
182
215
|
* marketplace (`rx/components`, generic, every org may use) and the org packs
|
|
183
216
|
* (`rx/orgs/<org>/components`, the client's own microapps — org-private). Names are
|
|
@@ -210,6 +243,37 @@ export function componentHit(dir: string, lower: string): boolean {
|
|
|
210
243
|
return !!defPath(dir, lower) || !!defPath(join(dir, lower), lower);
|
|
211
244
|
}
|
|
212
245
|
|
|
246
|
+
/**
|
|
247
|
+
* WHOSE item this is, from the homes ALONE — nothing is parsed, expanded or compiled.
|
|
248
|
+
*
|
|
249
|
+
* A catalogue that is scoped to one org must never READ another org's items and drop
|
|
250
|
+
* them at the end: loading a definition expands its includes and inlines every Ref, and
|
|
251
|
+
* an app manifest opens its binding, so a universe carrying five clients paid five
|
|
252
|
+
* clients' worth of work to answer one client's question (the Content Library's Studio
|
|
253
|
+
* list, seen doing exactly that). This answers the only question the scope needs —
|
|
254
|
+
* whose is it — with a directory probe, so an out-of-scope item is skipped BEFORE it
|
|
255
|
+
* is read.
|
|
256
|
+
*
|
|
257
|
+
* Returns the owning org, `undefined` for the marketplace tier (everyone's), and `null`
|
|
258
|
+
* when no home carries the name: the caller must load it to find out, which is the same
|
|
259
|
+
* thing it did before.
|
|
260
|
+
*/
|
|
261
|
+
export function refOwnerOrg(kind: "component" | "template" | "app", ref: string): string | undefined | null {
|
|
262
|
+
const { org: refOrg, name } = parseRef(ref);
|
|
263
|
+
const lower = name.toLowerCase();
|
|
264
|
+
// An org-qualified ref already says whose it is, as long as the org is a real pack —
|
|
265
|
+
// a qualifier naming no org is a template-local part, which componentDirs resolves.
|
|
266
|
+
if (refOrg !== null && listOrgs().includes(refOrg)) return refOrg;
|
|
267
|
+
const dirs = kind === "app" ? appDirs(refOrg) : kind === "template" ? templateDirs(refOrg) : componentDirs(refOrg);
|
|
268
|
+
// Either authored form, plus the manifest-only folder an app may be.
|
|
269
|
+
const hits = dirs.filter(({ dir }) => componentHit(dir, lower) || !!defPath(join(dir, lower), "manifest"));
|
|
270
|
+
// The marketplace wins a bare name (an org may never shadow it), which is the
|
|
271
|
+
// precedence resolveComponentDirs applies; two orgs and no marketplace copy is
|
|
272
|
+
// ambiguous, and guessing here would hide an item from its owner.
|
|
273
|
+
if (hits.some((h) => h.org === undefined)) return undefined;
|
|
274
|
+
return hits.length === 1 ? (hits[0].org as string | undefined) : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
213
277
|
/** The dirs a component ref may actually load from. Qualified refs pass through
|
|
214
278
|
* untouched. A bare ref narrows to the ONE home that carries the name: the CONTEXT
|
|
215
279
|
* org first when given (the org of the definition/app doing the asking — a cloned
|