@north-light/api 0.3.589 → 0.3.591
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/dist/api/client.d.ts +4 -1
- package/dist/api/client.js +2 -1
- package/dist/api/contract.d.ts +1 -1
- package/dist/api/contract.js +1 -1
- package/dist/api/dto/docs.d.ts +5 -0
- package/dist/api/dto/settings.d.ts +21 -0
- package/dist/build-root.d.ts +2 -0
- package/dist/build-root.js +6 -1
- package/dist/commands/pkg/app-installs.d.ts +53 -0
- package/dist/commands/pkg/app-installs.js +125 -0
- package/dist/commands/pkg/market-inspect.js +4 -0
- package/dist/commands/pkg/market-manage.js +6 -0
- package/dist/commands/pkg/plugin-inspect.js +6 -2
- package/dist/commands/pkg/plugin-manage.js +15 -0
- package/dist/core/canvas/schema-version.d.ts +1 -1
- package/dist/core/canvas/schema-version.js +1 -1
- package/dist/core/config.d.ts +1 -0
- package/dist/core/config.js +10 -0
- package/dist/core/graph/names.js +7 -6
- package/dist/core/graph/package-docs.d.ts +6 -15
- package/dist/core/graph/package-docs.js +33 -83
- package/dist/core/graph/package-publish.d.ts +60 -0
- package/dist/core/graph/package-publish.js +158 -0
- package/dist/core/graph/package-reconcile.d.ts +20 -1
- package/dist/core/graph/package-reconcile.js +83 -15
- package/dist/core/import/export/dump.js +12 -36
- package/dist/core/installed-plugins.js +5 -7
- package/dist/core/packages/eligibility.d.ts +16 -0
- package/dist/core/packages/eligibility.js +34 -0
- package/dist/core/packages/launch-kinds.d.ts +6 -0
- package/dist/core/packages/launch-kinds.js +20 -0
- package/dist/core/packages/source-url.d.ts +3 -0
- package/dist/core/packages/source-url.js +17 -0
- package/dist/core/permissions/ceiling.d.ts +9 -2
- package/dist/core/permissions/ceiling.js +23 -9
- package/dist/core/permissions/evaluator.js +1 -1
- package/dist/core/providers/bundles.js +2 -3
- package/dist/core/runtime/launch-target.d.ts +6 -2
- package/dist/core/runtime/launch-target.js +8 -4
- package/dist/core/runtime/launch.d.ts +8 -1
- package/dist/core/runtime/launch.js +13 -6
- package/dist/core/runtime/spawn.js +5 -5
- package/dist/core/runtime/warm-pool.js +1 -0
- package/dist/core/scoped-state/legacy-profiles.js +1 -1
- package/dist/core/scoped-state/person-rows.js +38 -35
- package/dist/core/scoped-state/person-store.d.ts +23 -0
- package/dist/core/scoped-state/person-store.js +5 -1
- package/package.json +1 -1
- package/dist/core/graph/package-kinds.d.ts +0 -22
- package/dist/core/graph/package-kinds.js +0 -49
- package/dist/core/scoped-state/materialized-plugins.d.ts +0 -20
- package/dist/core/scoped-state/materialized-plugins.js +0 -105
|
@@ -5,102 +5,44 @@
|
|
|
5
5
|
* A plugin is an app: its documents belong to the app `<plugin>` (identity `app:<plugin>`,
|
|
6
6
|
* the one `ownerForGrantee` gives), and a document with a `source_path` is shipped. */
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { CrtrError } from '../errors.js';
|
|
9
|
-
import { emitEvent } from '../events/emit.js';
|
|
10
|
-
import { quarantineBrokenPlugins } from '../plugin-quarantine.js';
|
|
11
|
-
import { listInstalledPlugins } from '../resolver.js';
|
|
12
|
-
import { builtinMemoryRoot, pluginMemoryDir } from '../scope.js';
|
|
13
8
|
import { documentFields } from '../../migrations/004-canvas-documents/fields.js';
|
|
14
|
-
import { put as putBody } from './bodies.js';
|
|
9
|
+
import { get as getBody, put as putBody } from './bodies.js';
|
|
15
10
|
import { applyDocumentFields, deleteDocument, documentRecord, heads, importDocument, linksIn, moveDocument } from './documents.js';
|
|
16
11
|
import { deleteShippedReader, writeShippedReader } from '../permissions/facts.js';
|
|
17
12
|
import { setLinks } from './edges.js';
|
|
18
13
|
import { publish } from './events.js';
|
|
19
|
-
import {
|
|
20
|
-
import { packageFiles } from './package-files.js';
|
|
21
|
-
import { ensureOwner, getById, setObjectKind } from './objects.js';
|
|
22
|
-
import { checkoutsForCwd } from './repo-sync/repos.js';
|
|
14
|
+
import { getById, setObjectKind } from './objects.js';
|
|
23
15
|
import { rulesOf } from './rules.js';
|
|
24
|
-
/** The
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// One damaged plugin directory (a 0-byte manifest from an interrupted write) is set aside, never fatal.
|
|
39
|
-
// Only the real installed set is swept; a caller passing its own sources has no plugins directory here.
|
|
40
|
-
if (explicitSources === undefined) {
|
|
41
|
-
report.quarantined = quarantineBrokenPlugins();
|
|
42
|
-
for (const q of report.quarantined) {
|
|
43
|
-
emitEvent({ level: 'error', event: 'daemon.plugins.quarantined', fields: { plugin: q.name, file: q.file, reason: q.reason, ...(q.movedTo !== undefined ? { moved_to: q.movedTo } : {}) } });
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
const sources = explicitSources ?? packageSources();
|
|
47
|
-
const reposFn = async (cwd) => await checkoutsForCwd(db, cwd);
|
|
48
|
-
const reconciled = new Set();
|
|
49
|
-
const converged = [];
|
|
50
|
-
const names = new Set();
|
|
51
|
-
for (const source of sources) {
|
|
52
|
-
let owner;
|
|
53
|
-
try {
|
|
54
|
-
if (names.has(source.plugin))
|
|
55
|
-
throw new CrtrError('conflict', `plugin "${source.plugin}" is already provided by another package`);
|
|
56
|
-
names.add(source.plugin);
|
|
57
|
-
owner = await ensureOwner(db, 'app', 'app', `app:${source.plugin}`, source.plugin);
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
if (!(error instanceof CrtrError) || error.code !== 'conflict')
|
|
61
|
-
throw error;
|
|
62
|
-
report.conflicts.push({ plugin: source.plugin, message: error.message });
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
reconciled.add(owner.id);
|
|
66
|
-
const { files, invalid } = packageFiles(source.dir);
|
|
67
|
-
report.invalid.push(...invalid);
|
|
68
|
-
const seen = new Set();
|
|
69
|
-
for (const file of files) {
|
|
70
|
-
const id = await convergeFile(db, owner, file, report, reposFn);
|
|
71
|
-
seen.add(id);
|
|
72
|
-
converged.push({ owner, id, body: file.body });
|
|
73
|
-
}
|
|
74
|
-
await deleteMissing(db, owner, seen, report, reposFn);
|
|
16
|
+
/** The live shipped document `name` (`<name>`, `<app>/<name>` or `app:<app>/<name>`) names for a package published by `owner`: the package's own documents
|
|
17
|
+
* first, then every other app's, `crtr` last. It looks across all published packages, whoever installed them: whether a reader may follow the edge is the
|
|
18
|
+
* reader's permission check when the link is rendered, not a property of the publisher. */
|
|
19
|
+
async function shippedNamed(db, owner, name) {
|
|
20
|
+
const typed = /^(?:app:)?([^/:]+)\/(.+)$/.exec(name);
|
|
21
|
+
const candidates = [{ app: null, doc: name }];
|
|
22
|
+
if (typed !== null)
|
|
23
|
+
candidates.push({ app: typed[1], doc: typed[2] });
|
|
24
|
+
for (const { app, doc } of candidates) {
|
|
25
|
+
const row = await db.prepare(`SELECT o.id FROM objects o JOIN documents d ON d.object = o.id AND d.source_path IS NOT NULL JOIN objects a ON a.id = o.owner AND a.type = 'app'
|
|
26
|
+
WHERE o.type = 'document' AND o.deleted_at IS NULL AND o.home_machine IS NULL AND o.name = ? AND (?::text IS NULL OR a.name = ?)
|
|
27
|
+
ORDER BY (o.owner = ?) DESC, a.name = 'crtr', a.name LIMIT 1`).get(doc, app, app, owner.id);
|
|
28
|
+
if (row !== undefined)
|
|
29
|
+
return await getById(db, row.id) ?? undefined;
|
|
75
30
|
}
|
|
76
|
-
|
|
77
|
-
// only its shipped documents are deleted.
|
|
78
|
-
const stale = await db.prepare(`SELECT DISTINCT o.owner AS id FROM objects o JOIN documents d ON d.object = o.id
|
|
79
|
-
WHERE o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL`).all();
|
|
80
|
-
for (const { id } of stale) {
|
|
81
|
-
if (!reconciled.has(id))
|
|
82
|
-
await deleteMissing(db, (await getById(db, id)), new Set(), report, reposFn);
|
|
83
|
-
}
|
|
84
|
-
// Links resolve only after every package's documents are in place, so a link to a document
|
|
85
|
-
// another package ships, or one reconciled later in this pass, still attaches.
|
|
86
|
-
for (const { owner, id, body } of converged)
|
|
87
|
-
await deriveLinks(db, owner, (await getById(db, id)), body);
|
|
88
|
-
return report;
|
|
31
|
+
return undefined;
|
|
89
32
|
}
|
|
90
33
|
/** Record a package document's `links-to` edges the way a runtime document gets them, so delivery
|
|
91
|
-
* attaches the link previews. Each `[[name]]`
|
|
92
|
-
*
|
|
34
|
+
* attaches the link previews. Each `[[name]]` links only when it names a shipped document of any
|
|
35
|
+
* published package (builtin, provider or plugin): a package never links to a user's or a
|
|
93
36
|
* runtime document that happens to share a bare name. A name that resolves to nothing stays text.
|
|
94
37
|
* Edges are rewritten only when they differ, so an unchanged pass writes nothing. */
|
|
95
38
|
export async function deriveLinks(db, owner, doc, body) {
|
|
96
39
|
if (doc.deleted_at !== null)
|
|
97
40
|
return;
|
|
98
|
-
const reader = { class: 'app', app: owner, scopes: [] };
|
|
99
41
|
const links = [];
|
|
100
42
|
for (const name of linksIn(body)) {
|
|
101
|
-
const hit = await
|
|
102
|
-
if (
|
|
103
|
-
links.push({ written_as: name, dst: hit
|
|
43
|
+
const hit = await shippedNamed(db, owner, name);
|
|
44
|
+
if (hit !== undefined && hit.id !== doc.id)
|
|
45
|
+
links.push({ written_as: name, dst: hit });
|
|
104
46
|
}
|
|
105
47
|
const key = (rows) => JSON.stringify(rows.map((r) => [r.dst, r.written_as]).sort());
|
|
106
48
|
const current = await db.prepare(`SELECT dst, written_as FROM edges WHERE type = 'links-to' AND src = ?`)
|
|
@@ -109,9 +51,17 @@ export async function deriveLinks(db, owner, doc, body) {
|
|
|
109
51
|
return;
|
|
110
52
|
await setLinks(db, doc, links);
|
|
111
53
|
}
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
54
|
+
/** Re-derive the links of every live shipped document of every app. A package published before the one its documents name has no edge to it yet;
|
|
55
|
+
* publishing the missing one calls this, so the edges do not depend on which package arrived first. */
|
|
56
|
+
export async function relinkAllShipped(db) {
|
|
57
|
+
const docs = await db.prepare(`SELECT o.id, o.owner FROM objects o JOIN documents d ON d.object = o.id
|
|
58
|
+
WHERE o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL ORDER BY o.id`).all();
|
|
59
|
+
for (const { id, owner } of docs) {
|
|
60
|
+
const doc = (await getById(db, id));
|
|
61
|
+
const [head] = await heads(db, doc);
|
|
62
|
+
if (head !== undefined)
|
|
63
|
+
await deriveLinks(db, (await getById(db, owner)), doc, await getBody(db, head.body_hash));
|
|
64
|
+
}
|
|
115
65
|
}
|
|
116
66
|
export async function convergeFile(db, owner, file, report, reposFn) {
|
|
117
67
|
const f = documentFields(file.frontmatter, file.body, { missingPrivacy: 'public' });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { KindConfig, ObjectKindDeclaration } from '../../types.js';
|
|
2
|
+
import { type PackageReconcileReport } from './package-docs.js';
|
|
3
|
+
import { type PackageFile } from './package-files.js';
|
|
4
|
+
import type { CanvasDb } from '../canvas/db.js';
|
|
5
|
+
/** What one app's package folder holds right now, read before the publishing transaction opens. */
|
|
6
|
+
export interface PackageSnapshot {
|
|
7
|
+
app: string;
|
|
8
|
+
origin: 'builtin' | 'provider' | 'plugin';
|
|
9
|
+
/** crtr release (builtin), bundle generation (provider), manifest version (plugin) */
|
|
10
|
+
version: string;
|
|
11
|
+
/** the directory's revision for a directory package; null for builtin and for a Mac's local package */
|
|
12
|
+
revision: number | null;
|
|
13
|
+
/** normalized marketplace URL of a plugin package; null otherwise */
|
|
14
|
+
source: string | null;
|
|
15
|
+
/** the manifest's launch `kinds` (persona kinds); null when it declares none */
|
|
16
|
+
launchKinds: Record<string, Partial<KindConfig>> | null;
|
|
17
|
+
/** the manifest's custom-object kinds, keyed by kind name; null when it declares none */
|
|
18
|
+
objectKinds: Record<string, ObjectKindDeclaration> | null;
|
|
19
|
+
digest: string;
|
|
20
|
+
/** files with `path` already package-relative, prefixed by the app: `<app>/<relative path>` */
|
|
21
|
+
files: PackageFile[];
|
|
22
|
+
invalid: Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
export interface PublishReport extends PackageReconcileReport {
|
|
28
|
+
/** the row's release is newer than this snapshot: nothing was written */
|
|
29
|
+
superseded: boolean;
|
|
30
|
+
/** what declaring the snapshot's object kinds did; empty when the bytes were already published */
|
|
31
|
+
kinds: {
|
|
32
|
+
applied: Array<{
|
|
33
|
+
kind: string;
|
|
34
|
+
outcome: 'inserted' | 'updated' | 'unchanged';
|
|
35
|
+
}>;
|
|
36
|
+
refused: Array<{
|
|
37
|
+
kind: string;
|
|
38
|
+
message: string;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The package folder `dir` as a snapshot of app `app`. Throws when the folder or any directory in it cannot be read. The digest covers the
|
|
43
|
+
* path and raw bytes of every markdown file, invalid ones included, so removing or changing an invalid file changes it. */
|
|
44
|
+
export declare function readPackageSnapshot(dir: string, meta: Pick<PackageSnapshot, 'app' | 'origin' | 'version'> & Partial<Pick<PackageSnapshot, 'revision' | 'source' | 'launchKinds' | 'objectKinds'>>): PackageSnapshot;
|
|
45
|
+
/** JSON with every object's keys sorted at every depth. */
|
|
46
|
+
export declare function canonicalJson(value: unknown): string;
|
|
47
|
+
/** App `crtr` as this process's release ships it. */
|
|
48
|
+
export declare function readBuiltinSnapshot(): PackageSnapshot;
|
|
49
|
+
/** Publish one snapshot inside the caller's write transaction. Serialized per app on the publication row. A `capture` is called after the lock is
|
|
50
|
+
* taken, so a Mac reads its disk with no older read able to commit after it. Returns what changed. */
|
|
51
|
+
export declare function publish(db: CanvasDb, input: PackageSnapshot | {
|
|
52
|
+
app: string;
|
|
53
|
+
origin: PackageSnapshot['origin'];
|
|
54
|
+
capture: () => PackageSnapshot;
|
|
55
|
+
}, publishedBy: string): Promise<PublishReport>;
|
|
56
|
+
/** A package folder that does not exist ships nothing: a plugin without a `memory/` folder. */
|
|
57
|
+
export declare function emptyPackageSnapshot(meta: Pick<PackageSnapshot, 'app' | 'origin' | 'version'> & Partial<Pick<PackageSnapshot, 'launchKinds' | 'objectKinds'>>): PackageSnapshot;
|
|
58
|
+
/** Take a package down: soft-delete the app's shipped documents, undeclare its manifest kinds, mark the row withdrawn. Nothing else of the
|
|
59
|
+
* app (its ordinary documents, its kind rows) is touched. The caller decides the package is no longer wanted. */
|
|
60
|
+
export declare function withdraw(db: CanvasDb, app: string): Promise<PublishReport>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/** The package publisher, for the one package a release owns: app `crtr`, from `builtinMemoryRoot()`. A cloud role publishes it at every
|
|
2
|
+
* daemon start and touches nothing else: no other app's documents, no manifest kind, no `crtr_app_` token, no plugin directory.
|
|
3
|
+
* One `package_publications` row per published app is the lock a publisher takes and the release it compares against, so two pods
|
|
4
|
+
* of different releases (a rolling deploy) never leave older text over newer text. The Mac pass (`reconcilePackages`) publishes through the same function. */
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, relative, sep } from 'node:path';
|
|
8
|
+
import { compareReleaseVersions } from '../../api/runtime-version.js';
|
|
9
|
+
import { upsertKind } from './custom.js';
|
|
10
|
+
import { crtrVersion } from '../../shared/crtr-version.js';
|
|
11
|
+
import { emitEvent } from '../events/emit.js';
|
|
12
|
+
import { builtinMemoryRoot } from '../scope.js';
|
|
13
|
+
import { deleteMissing, convergeFile, relinkAllShipped } from './package-docs.js';
|
|
14
|
+
import { packageFiles } from './package-files.js';
|
|
15
|
+
import { ensureOwner } from './objects.js';
|
|
16
|
+
import { checkoutsForCwd } from './repo-sync/repos.js';
|
|
17
|
+
/** Every markdown file the package reader considers, as sorted `[package-relative path, raw bytes]`. Unlike `walkFiles`, a directory that
|
|
18
|
+
* cannot be read throws: a partial or empty read must never look like a package that dropped its documents. */
|
|
19
|
+
function strictPackageBytes(dir) {
|
|
20
|
+
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
21
|
+
throw new Error(`package folder is missing or not a directory: ${dir}`);
|
|
22
|
+
const out = [];
|
|
23
|
+
const stack = [dir];
|
|
24
|
+
while (stack.length > 0) {
|
|
25
|
+
const current = stack.pop();
|
|
26
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
27
|
+
const full = join(current, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
if (!entry.name.startsWith('.'))
|
|
30
|
+
stack.push(full);
|
|
31
|
+
}
|
|
32
|
+
else if (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'SKILL.md')
|
|
33
|
+
out.push([relative(dir, full).split(sep).join('/'), readFileSync(full)]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out.sort((a, b) => a[0] < b[0] ? -1 : 1);
|
|
37
|
+
}
|
|
38
|
+
/** The package folder `dir` as a snapshot of app `app`. Throws when the folder or any directory in it cannot be read. The digest covers the
|
|
39
|
+
* path and raw bytes of every markdown file, invalid ones included, so removing or changing an invalid file changes it. */
|
|
40
|
+
export function readPackageSnapshot(dir, meta) {
|
|
41
|
+
const bytes = strictPackageBytes(dir);
|
|
42
|
+
const { files, invalid } = packageFiles(dir);
|
|
43
|
+
const shipped = files.map((f) => ({ ...f, path: `${meta.app}/${relative(dir, f.path).split(sep).join('/')}` }));
|
|
44
|
+
const hash = createHash('sha256');
|
|
45
|
+
for (const [rel, data] of bytes)
|
|
46
|
+
hash.update(rel).update('\0').update(data).update('\0');
|
|
47
|
+
const shippedInvalid = invalid.map((i) => ({ ...i, path: `${meta.app}/${relative(dir, i.path).split(sep).join('/')}` }));
|
|
48
|
+
return snapshotOf({ revision: null, source: null, launchKinds: null, objectKinds: null, ...meta }, shipped, shippedInvalid, hash);
|
|
49
|
+
}
|
|
50
|
+
/** JSON with every object's keys sorted at every depth. */
|
|
51
|
+
export function canonicalJson(value) {
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
54
|
+
if (value !== null && typeof value === 'object') {
|
|
55
|
+
const record = value;
|
|
56
|
+
return `{${Object.keys(record).sort().filter((k) => record[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(record[k])}`).join(',')}}`;
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(value) ?? 'null';
|
|
59
|
+
}
|
|
60
|
+
/** Close a digest over the package's kinds and build the snapshot. */
|
|
61
|
+
function snapshotOf(meta, files, invalid, hash) {
|
|
62
|
+
hash.update(canonicalJson({ launchKinds: meta.launchKinds, objectKinds: meta.objectKinds, source: meta.source }));
|
|
63
|
+
return { ...meta, digest: hash.digest('hex'), files, invalid };
|
|
64
|
+
}
|
|
65
|
+
/** App `crtr` as this process's release ships it. */
|
|
66
|
+
export function readBuiltinSnapshot() {
|
|
67
|
+
return readPackageSnapshot(builtinMemoryRoot(), { app: 'crtr', origin: 'builtin', version: crtrVersion() });
|
|
68
|
+
}
|
|
69
|
+
/** Whether `snapshot` is older than the published row: a builtin by release, a directory package by its revision. A Mac's local package
|
|
70
|
+
* has no order of its own; it is captured under the row lock, so the last commit carries the newest bytes. */
|
|
71
|
+
function olderThanPublished(snapshot, row) {
|
|
72
|
+
if (snapshot.revision !== null)
|
|
73
|
+
return row.revision !== null && snapshot.revision < Number(row.revision);
|
|
74
|
+
if (snapshot.origin === 'builtin')
|
|
75
|
+
return compareReleaseVersions(snapshot.version, row.version) < 0;
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/** Publish one snapshot inside the caller's write transaction. Serialized per app on the publication row. A `capture` is called after the lock is
|
|
79
|
+
* taken, so a Mac reads its disk with no older read able to commit after it. Returns what changed. */
|
|
80
|
+
export async function publish(db, input, publishedBy) {
|
|
81
|
+
const { app, origin } = input;
|
|
82
|
+
const owner = await ensureOwner(db, 'app', 'app', `app:${app}`, app);
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
await db.prepare(`INSERT INTO package_publications (app, origin, version, digest, published_at, published_by)
|
|
85
|
+
VALUES (?, ?, '', '', ?, ?) ON CONFLICT (app) DO NOTHING`).run(owner.id, origin, now, publishedBy);
|
|
86
|
+
const row = await db.prepare('SELECT version, digest, revision, withdrawn_at FROM package_publications WHERE app = ? FOR UPDATE').get(owner.id);
|
|
87
|
+
const snapshot = 'capture' in input ? input.capture() : input;
|
|
88
|
+
const report = { created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [...snapshot.invalid], quarantined: [], superseded: false, kinds: { applied: [], refused: [] } };
|
|
89
|
+
if (olderThanPublished(snapshot, row)) {
|
|
90
|
+
report.superseded = true;
|
|
91
|
+
emitEvent({ level: 'warn', event: 'daemon.packages.superseded', fields: { app: snapshot.app, snapshot_version: snapshot.version, published_version: row.version, snapshot_revision: snapshot.revision ?? 'none', published_revision: row.revision === null ? 'none' : Number(row.revision) } });
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
94
|
+
const sameBytes = row.digest === snapshot.digest && row.withdrawn_at === null;
|
|
95
|
+
if (!sameBytes) {
|
|
96
|
+
const reposFn = async (cwd) => await checkoutsForCwd(db, cwd);
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const file of snapshot.files)
|
|
99
|
+
seen.add(await convergeFile(db, owner, file, report, reposFn));
|
|
100
|
+
// An invalid file keeps its prior document: its name is not known, so a snapshot with any invalid file removes nothing.
|
|
101
|
+
if (snapshot.invalid.length === 0)
|
|
102
|
+
await deleteMissing(db, owner, seen, report, reposFn);
|
|
103
|
+
report.kinds = await convergeKinds(db, owner, snapshot.objectKinds);
|
|
104
|
+
// A package published before the one its documents name has no edge to it yet, so any change revisits every shipped document.
|
|
105
|
+
if (report.created + report.updated + report.deleted > 0)
|
|
106
|
+
await relinkAllShipped(db);
|
|
107
|
+
}
|
|
108
|
+
else if (row.version === snapshot.version && String(row.revision ?? '') === String(snapshot.revision ?? '')) {
|
|
109
|
+
return report;
|
|
110
|
+
}
|
|
111
|
+
await db.prepare(`UPDATE package_publications SET origin = ?, version = ?, revision = ?, source = ?, launch_kinds_json = ?, digest = ?, published_at = ?, published_by = ?, withdrawn_at = NULL
|
|
112
|
+
WHERE app = ?`).run(snapshot.origin, snapshot.version, snapshot.revision, snapshot.source, snapshot.launchKinds === null ? null : canonicalJson(snapshot.launchKinds), snapshot.digest, now, publishedBy, owner.id);
|
|
113
|
+
return report;
|
|
114
|
+
}
|
|
115
|
+
/** A package folder that does not exist ships nothing: a plugin without a `memory/` folder. */
|
|
116
|
+
export function emptyPackageSnapshot(meta) {
|
|
117
|
+
return snapshotOf({ revision: null, source: null, launchKinds: null, objectKinds: null, ...meta }, [], [], createHash('sha256'));
|
|
118
|
+
}
|
|
119
|
+
/** Declare the snapshot's object kinds under the app and undeclare the app's manifest kinds it no longer lists. A kind another owner holds is
|
|
120
|
+
* left alone (its document text still publishes). A kind row is never deleted. */
|
|
121
|
+
async function convergeKinds(db, owner, kinds) {
|
|
122
|
+
const report = { applied: [], refused: [] };
|
|
123
|
+
const declared = new Set();
|
|
124
|
+
for (const [name, decl] of Object.entries(kinds ?? {})) {
|
|
125
|
+
try {
|
|
126
|
+
const result = await upsertKind(db, { name, owner: owner, description: decl.description, events: decl.events,
|
|
127
|
+
read: decl.read === undefined ? null : { command: decl.read.command }, source: 'manifest' });
|
|
128
|
+
if (result.outcome !== 'skipped') {
|
|
129
|
+
declared.add(name);
|
|
130
|
+
report.applied.push({ kind: name, outcome: result.outcome });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (error.code !== 'kind_owned')
|
|
135
|
+
throw error;
|
|
136
|
+
report.refused.push({ kind: name, message: error.message });
|
|
137
|
+
emitEvent({ level: 'warn', event: 'daemon.packages.kind_owned', fields: { app: owner.id, kind: name } });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const rows = await db.prepare("SELECT name FROM object_kinds WHERE owner = ? AND source = 'manifest' AND declared = 1").all(owner.id);
|
|
141
|
+
for (const { name } of rows)
|
|
142
|
+
if (!declared.has(name))
|
|
143
|
+
await db.prepare('UPDATE object_kinds SET declared = 0 WHERE name = ?').run(name);
|
|
144
|
+
return report;
|
|
145
|
+
}
|
|
146
|
+
/** Take a package down: soft-delete the app's shipped documents, undeclare its manifest kinds, mark the row withdrawn. Nothing else of the
|
|
147
|
+
* app (its ordinary documents, its kind rows) is touched. The caller decides the package is no longer wanted. */
|
|
148
|
+
export async function withdraw(db, app) {
|
|
149
|
+
const report = { created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [], quarantined: [], superseded: false, kinds: { applied: [], refused: [] } };
|
|
150
|
+
const owner = await ensureOwner(db, 'app', 'app', `app:${app}`, app);
|
|
151
|
+
const row = await db.prepare('SELECT withdrawn_at FROM package_publications WHERE app = ? FOR UPDATE').get(owner.id);
|
|
152
|
+
if (row === undefined || row.withdrawn_at !== null)
|
|
153
|
+
return report;
|
|
154
|
+
await deleteMissing(db, owner, new Set(), report, async (cwd) => await checkoutsForCwd(db, cwd));
|
|
155
|
+
await convergeKinds(db, owner, null);
|
|
156
|
+
await db.prepare("UPDATE package_publications SET withdrawn_at = ?, digest = '' WHERE app = ?").run(new Date().toISOString(), owner.id);
|
|
157
|
+
return report;
|
|
158
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type KindsReport } from './package-kinds.js';
|
|
2
1
|
import { type PackageReconcileReport } from './package-docs.js';
|
|
3
2
|
import type { CanvasDb } from '../canvas/db.js';
|
|
4
3
|
export interface TokensReport {
|
|
@@ -10,6 +9,26 @@ export interface TokensReport {
|
|
|
10
9
|
/** plugins whose `crtr_app_` token was removed because they no longer qualify */
|
|
11
10
|
revoked: string[];
|
|
12
11
|
}
|
|
12
|
+
export interface KindsReport {
|
|
13
|
+
/** every kind a valid plugin declares that now has its row, and what upserting it did */
|
|
14
|
+
applied: Array<{
|
|
15
|
+
plugin: string;
|
|
16
|
+
kind: string;
|
|
17
|
+
outcome: 'inserted' | 'updated' | 'unchanged';
|
|
18
|
+
}>;
|
|
19
|
+
/** kinds another owner holds; the plugin's other kinds still applied */
|
|
20
|
+
refused: Array<{
|
|
21
|
+
plugin: string;
|
|
22
|
+
kind: string;
|
|
23
|
+
code: 'kind_owned';
|
|
24
|
+
message: string;
|
|
25
|
+
}>;
|
|
26
|
+
/** enabled plugins whose manifest or commands are invalid (no kind update, no token); a provider bundle's kinds reconcile without a token, and plugins whose app handle another owner holds */
|
|
27
|
+
invalid: Array<{
|
|
28
|
+
plugin: string;
|
|
29
|
+
error: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
13
32
|
export interface PackagesReport extends PackageReconcileReport {
|
|
14
33
|
kinds: KindsReport;
|
|
15
34
|
tokens: TokensReport;
|
|
@@ -1,31 +1,99 @@
|
|
|
1
|
-
/** The
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
1
|
+
/** The Mac's package pass: the builtin corpus and every enabled local plugin or provider bundle are published through the publisher (each package
|
|
2
|
+
* captured from disk under its publication row's lock), a package whose app is no longer enabled is withdrawn, then plugin app tokens are
|
|
3
|
+
* minted and revoked. Runs in the caller's write transaction. The token writes go to the secrets store and are idempotent, so a rolled-back
|
|
4
|
+
* pass re-converges on the next one. A cloud role never runs this: it sees no person's plugins (it publishes `crtr` and the directory's packages). */
|
|
5
|
+
import { statSync } from 'node:fs';
|
|
6
|
+
import { CrtrError } from '../errors.js';
|
|
7
|
+
import { emitEvent } from '../events/emit.js';
|
|
8
|
+
import { invalidAppPlugins, appPlugins, kindPlugins } from '../plugin-app.js';
|
|
9
|
+
import { quarantineBrokenPlugins } from '../plugin-quarantine.js';
|
|
10
|
+
import { listInstalledPlugins } from '../resolver.js';
|
|
11
|
+
import { pluginMemoryDir } from '../scope.js';
|
|
5
12
|
import { ensurePluginAppToken, listScopedApiTokens, revokePluginAppTokens } from '../secrets.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
13
|
+
import { deleteMissing } from './package-docs.js';
|
|
14
|
+
import { emptyPackageSnapshot, publish, readBuiltinSnapshot, readPackageSnapshot, withdraw } from './package-publish.js';
|
|
15
|
+
import { getById } from './objects.js';
|
|
16
|
+
import { checkoutsForCwd } from './repo-sync/repos.js';
|
|
17
|
+
const PUBLISHED_BY = 'mac';
|
|
18
|
+
function isDirectory(path) {
|
|
19
|
+
return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
20
|
+
}
|
|
8
21
|
export async function reconcilePackages(db) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
const report = {
|
|
23
|
+
created: 0, updated: 0, deleted: 0, conflicts: [], invalid: [], quarantined: quarantineBrokenPlugins(),
|
|
24
|
+
kinds: { applied: [], refused: [], invalid: invalidAppPlugins().map((i) => ({ plugin: i.plugin.name, error: i.error })) },
|
|
25
|
+
tokens: { granted: [], revoked: [] },
|
|
26
|
+
};
|
|
27
|
+
for (const q of report.quarantined) {
|
|
28
|
+
emitEvent({ level: 'error', event: 'daemon.plugins.quarantined', fields: { plugin: q.name, file: q.file, reason: q.reason, ...(q.movedTo !== undefined ? { moved_to: q.movedTo } : {}) } });
|
|
29
|
+
}
|
|
30
|
+
const validKinds = new Set(kindPlugins().map((p) => p.name));
|
|
31
|
+
const published = new Set(['crtr']);
|
|
32
|
+
const add = (r, plugin) => {
|
|
33
|
+
report.created += r.created;
|
|
34
|
+
report.updated += r.updated;
|
|
35
|
+
report.deleted += r.deleted;
|
|
36
|
+
report.invalid.push(...r.invalid);
|
|
37
|
+
for (const k of r.kinds.applied)
|
|
38
|
+
report.kinds.applied.push({ plugin, kind: k.kind, outcome: k.outcome });
|
|
39
|
+
for (const k of r.kinds.refused)
|
|
40
|
+
report.kinds.refused.push({ plugin, kind: k.kind, code: 'kind_owned', message: k.message });
|
|
41
|
+
};
|
|
42
|
+
add(await publish(db, { app: 'crtr', origin: 'builtin', capture: () => readBuiltinSnapshot() }, PUBLISHED_BY), 'crtr');
|
|
43
|
+
for (const plugin of listInstalledPlugins('user').filter((p) => p.enabled)) {
|
|
44
|
+
const origin = plugin.manifest.provider === undefined ? 'plugin' : 'provider';
|
|
45
|
+
const dir = pluginMemoryDir(plugin);
|
|
46
|
+
const kinds = plugin.manifest.kinds !== undefined && Object.keys(plugin.manifest.kinds).length > 0 ? plugin.manifest.kinds : null;
|
|
47
|
+
const meta = { app: plugin.name, origin, version: plugin.manifest.version ?? '', launchKinds: kinds, objectKinds: validKinds.has(plugin.name) ? plugin.manifest.object_kinds ?? null : null };
|
|
48
|
+
try {
|
|
49
|
+
if (published.has(plugin.name))
|
|
50
|
+
throw new CrtrError('conflict', `plugin "${plugin.name}" is already provided by another package`);
|
|
51
|
+
published.add(plugin.name);
|
|
52
|
+
add(await publish(db, { app: plugin.name, origin, capture: () => isDirectory(dir) ? readPackageSnapshot(dir, meta) : emptyPackageSnapshot(meta) }, PUBLISHED_BY), plugin.name);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (!(error instanceof CrtrError) || error.code !== 'conflict')
|
|
56
|
+
throw error;
|
|
57
|
+
report.conflicts.push({ plugin: plugin.name, message: error.message });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// A package no longer installed (or disabled) keeps its app and the app's other objects; only its shipped documents go and its row is withdrawn.
|
|
61
|
+
const gone = await db.prepare(`SELECT i.external_id FROM package_publications p JOIN identities i ON i.object = p.app AND i.scheme = 'app'
|
|
62
|
+
WHERE p.withdrawn_at IS NULL ORDER BY i.external_id`).all();
|
|
63
|
+
for (const { external_id } of gone) {
|
|
64
|
+
const app = external_id.replace(/^app:/, '');
|
|
65
|
+
if (published.has(app))
|
|
66
|
+
continue;
|
|
67
|
+
const w = await withdraw(db, app);
|
|
68
|
+
report.deleted += w.deleted;
|
|
69
|
+
}
|
|
70
|
+
// Shipped documents of an app that never had a publication row (written by a release before the publisher) are removed the same way.
|
|
71
|
+
const stale = await db.prepare(`SELECT DISTINCT o.owner AS id FROM objects o JOIN documents d ON d.object = o.id
|
|
72
|
+
WHERE o.type = 'document' AND o.deleted_at IS NULL AND d.source_path IS NOT NULL`).all();
|
|
73
|
+
for (const { id } of stale) {
|
|
74
|
+
const owner = (await getById(db, id));
|
|
75
|
+
const ext = await db.prepare(`SELECT external_id FROM identities WHERE object = ? AND scheme = 'app'`).get(id);
|
|
76
|
+
if (ext !== undefined && published.has(ext.external_id.replace(/^app:/, '')))
|
|
77
|
+
continue;
|
|
78
|
+
await deleteMissing(db, owner, new Set(), report, async (cwd) => await checkoutsForCwd(db, cwd));
|
|
79
|
+
}
|
|
12
80
|
const holders = new Set();
|
|
13
81
|
for (const plugin of appPlugins()) {
|
|
14
82
|
const scopes = plugin.manifest.scopes ?? [];
|
|
15
83
|
ensurePluginAppToken(plugin.name, scopes);
|
|
16
84
|
holders.add(plugin.name);
|
|
17
|
-
tokens.granted.push({ plugin: plugin.name, scopes: [...scopes] });
|
|
85
|
+
report.tokens.granted.push({ plugin: plugin.name, scopes: [...scopes] });
|
|
18
86
|
}
|
|
19
|
-
const
|
|
87
|
+
const staleTokens = new Set();
|
|
20
88
|
for (const entry of listScopedApiTokens('user')) {
|
|
21
89
|
if (!entry.token.startsWith('crtr_app_') || !entry.grantee.startsWith('app:'))
|
|
22
90
|
continue;
|
|
23
91
|
const plugin = entry.grantee.slice('app:'.length);
|
|
24
92
|
if (!holders.has(plugin))
|
|
25
|
-
|
|
93
|
+
staleTokens.add(plugin);
|
|
26
94
|
}
|
|
27
|
-
for (const plugin of
|
|
95
|
+
for (const plugin of staleTokens)
|
|
28
96
|
if (revokePluginAppTokens(plugin))
|
|
29
|
-
tokens.revoked.push(plugin);
|
|
30
|
-
return
|
|
97
|
+
report.tokens.revoked.push(plugin);
|
|
98
|
+
return report;
|
|
31
99
|
}
|
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { openDb, withCanvasWrite } from '../../canvas/db.js';
|
|
7
|
-
import { hashOf
|
|
7
|
+
import { hashOf } from '../../graph/bodies.js';
|
|
8
8
|
import { personIdAsync } from '../../canvas/person.js';
|
|
9
9
|
import { CANVAS_SCHEMA_VERSION } from '../../canvas/schema-version.js';
|
|
10
10
|
import { crtrVersion } from '../../../shared/crtr-version.js';
|
|
11
11
|
import { BODIES_TABLE, DUMPED_TABLES, RowFileWriter, SEQ_TABLES, bundleKeys, encodeRows, rowLine } from '../bundle/layout.js';
|
|
12
|
-
import { parseFrontmatterGeneric } from '../../frontmatter.js';
|
|
13
12
|
import { inlineMessageBody } from './inline.js';
|
|
14
13
|
/** A `bodies` part file holds about this much JSON (the encoded lines, before compression); a row that alone exceeds it is a part of its own. */
|
|
15
14
|
export const PART_JSON_BYTES = 16 * 1024 * 1024;
|
|
@@ -33,16 +32,6 @@ function readText(path) {
|
|
|
33
32
|
throw error;
|
|
34
33
|
}
|
|
35
34
|
}
|
|
36
|
-
function readFileOrNull(path) {
|
|
37
|
-
try {
|
|
38
|
-
return readFileSync(path, 'utf8');
|
|
39
|
-
}
|
|
40
|
-
catch (error) {
|
|
41
|
-
if (error.code === 'ENOENT')
|
|
42
|
-
return null;
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
35
|
function tooLarge(hash, bytes) {
|
|
47
36
|
return new Error(`revision text ${hash} is ${bytes} bytes; the import carries at most ${BODY_MAX_BYTES} bytes of text per body`);
|
|
48
37
|
}
|
|
@@ -55,7 +44,7 @@ function assertCanonical(row) {
|
|
|
55
44
|
}
|
|
56
45
|
/** Read the `bodies` rows of `hashes` (in that order) and write them as part files `<phase>-<n>`; returns the part names. Rows are read in pages of about `PAGE_TEXT_BYTES`
|
|
57
46
|
* of text (sizes first, by `octet_length`), each row is written as one line, and a part is closed when its encoded JSON reaches `PART_JSON_BYTES`. */
|
|
58
|
-
async function writeBodyParts(db, out, phase, hashes
|
|
47
|
+
async function writeBodyParts(db, out, phase, hashes) {
|
|
59
48
|
const dir = join(out, 'rows', BODIES_TABLE);
|
|
60
49
|
mkdirSync(dir, { recursive: true });
|
|
61
50
|
const parts = [];
|
|
@@ -102,12 +91,6 @@ async function writeBodyParts(db, out, phase, hashes, extra) {
|
|
|
102
91
|
await fetchPage();
|
|
103
92
|
i += SIZE_BATCH;
|
|
104
93
|
}
|
|
105
|
-
for (const row of extra) {
|
|
106
|
-
const bytes = Buffer.byteLength(row.text, 'utf8');
|
|
107
|
-
if (bytes > BODY_MAX_BYTES)
|
|
108
|
-
throw tooLarge(row.hash, bytes);
|
|
109
|
-
await add(row);
|
|
110
|
-
}
|
|
111
94
|
await close();
|
|
112
95
|
}
|
|
113
96
|
catch (error) {
|
|
@@ -122,7 +105,7 @@ const allHashes = async (db) => (await db.prepare('SELECT hash FROM bodies ORDER
|
|
|
122
105
|
export async function dumpBodiesPre(out) {
|
|
123
106
|
const db = await openDb();
|
|
124
107
|
const hashes = await allHashes(db);
|
|
125
|
-
const parts = await writeBodyParts(db, out, 'pre', hashes
|
|
108
|
+
const parts = await writeBodyParts(db, out, 'pre', hashes);
|
|
126
109
|
const pre = { hashes, parts, count: hashes.length, canonical: true };
|
|
127
110
|
writeFileSync(join(out, PRE_FILE), JSON.stringify(pre));
|
|
128
111
|
return { parts, count: hashes.length };
|
|
@@ -197,28 +180,21 @@ export async function dumpRows(out, kind) {
|
|
|
197
180
|
const environment = await db.prepare(`SELECT object FROM environments WHERE kind = 'machine' ORDER BY object LIMIT 1`).get();
|
|
198
181
|
const source = { installId, kind, person, release: crtrVersion(), schemaVersion: CANVAS_SCHEMA_VERSION, counts, ...(environment === undefined ? {} : { environmentObject: environment.object }) };
|
|
199
182
|
const requests = await db.prepare('SELECT request_id, request_dir, file FROM human_requests ORDER BY request_id').all();
|
|
200
|
-
// A shipped document's
|
|
201
|
-
|
|
183
|
+
// A shipped document's text is its head revision's `bodies` row, the same row every reader gets; `source_path` is provenance only and is never opened.
|
|
184
|
+
// The row travels with the table, so the bundle needs only the hash.
|
|
185
|
+
const shipped = await db.prepare(`SELECT d.object, r.body_hash FROM documents d JOIN revisions r ON r.document = d.object
|
|
186
|
+
WHERE d.source_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM revision_parents p WHERE p.parent = r.rev_id) ORDER BY d.object, r.number DESC, r.rev_id`).all();
|
|
202
187
|
const shippedBodies = {};
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (file === null)
|
|
207
|
-
continue;
|
|
208
|
-
const text = storable(parseFrontmatterGeneric(file).body);
|
|
209
|
-
const hash = hashOf(text);
|
|
210
|
-
shippedBodies[doc.object] = hash;
|
|
211
|
-
shippedRows.set(hash, { hash, text });
|
|
212
|
-
}
|
|
213
|
-
// `bodies` rows added since the pre-pause dump (all of them when there was none), and the shipped texts the pre-pause dump did not hold.
|
|
188
|
+
for (const doc of shipped)
|
|
189
|
+
shippedBodies[doc.object] ??= doc.body_hash;
|
|
190
|
+
// `bodies` rows added since the pre-pause dump (all of them when there was none); the shipped texts are among them.
|
|
214
191
|
const pre = readPre(out);
|
|
215
192
|
const had = new Set(pre.hashes);
|
|
216
193
|
const now = await allHashes(db);
|
|
217
194
|
assertPreCurrent(pre, new Set(now));
|
|
218
195
|
const fresh = now.filter((hash) => !had.has(hash));
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const bodyCount = pre.count + fresh.length + [...shippedRows.values()].filter((row) => !had.has(row.hash) && !added.has(row.hash)).length;
|
|
196
|
+
const pauseParts = await writeBodyParts(db, out, 'pause', fresh);
|
|
197
|
+
const bodyCount = pre.count + fresh.length;
|
|
222
198
|
counts[BODIES_TABLE] = bodyCount;
|
|
223
199
|
source.bodyParts = [...pre.parts, ...pauseParts];
|
|
224
200
|
const running = await db.prepare(`SELECT n.node_id, r.grantee, n.cwd, w.path AS worktree FROM nodes n JOIN node_rows r ON r.node_id = n.node_id LEFT JOIN worktrees w ON w.node_id = n.node_id AND w.state = 'open'
|