@mulmoclaude/core 3.7.0 → 3.11.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/dist/collection/core/itemId.d.ts +28 -0
- package/dist/collection/firestore.cjs +9 -1
- package/dist/collection/firestore.cjs.map +1 -1
- package/dist/collection/firestore.js +10 -2
- package/dist/collection/firestore.js.map +1 -1
- package/dist/collection/index.cjs +55 -66
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -1
- package/dist/collection/index.js +3 -15
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +19 -19
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/firestoreDocs.d.ts +18 -0
- package/dist/collection/server/host.d.ts +29 -0
- package/dist/collection/server/index.cjs +70 -62
- package/dist/collection/server/index.d.ts +2 -3
- package/dist/collection/server/index.js +4 -4
- package/dist/collection/server/io.d.ts +5 -3
- package/dist/collection/server/manageTool.d.ts +0 -4
- package/dist/collection/server/publishManifest.d.ts +1 -0
- package/dist/collection/server/publishProject.d.ts +171 -2
- package/dist/collection-watchers/index.cjs +82 -38
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +67 -23
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{store-_61sO8K8.js → discovery-B9zdNkgW.js} +2525 -2354
- package/dist/discovery-B9zdNkgW.js.map +1 -0
- package/dist/{store-5_P_NsGa.cjs → discovery-BsXiDZBR.cjs} +2545 -2362
- package/dist/discovery-BsXiDZBR.cjs.map +1 -0
- package/dist/feeds/index.cjs +4 -4
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +14 -14
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +4 -4
- package/dist/firestore/listen.d.ts +4 -0
- package/dist/google/index.cjs +16 -16
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +2 -2
- package/dist/{ingestTypes-CEi-Ot7F.js → ingestTypes-B312BwXg.js} +2 -2
- package/dist/{ingestTypes-CEi-Ot7F.js.map → ingestTypes-B312BwXg.js.map} +1 -1
- package/dist/{ingestTypes-iaiq33Ph.cjs → ingestTypes-B4XfrUoq.cjs} +3 -3
- package/dist/{ingestTypes-iaiq33Ph.cjs.map → ingestTypes-B4XfrUoq.cjs.map} +1 -1
- package/dist/{calendarGrid-CQ8MVSRb.js → itemId-DfTm08jm.js} +34 -2
- package/dist/itemId-DfTm08jm.js.map +1 -0
- package/dist/{calendarGrid-DGILaVxI.cjs → itemId-Dzrr3vs5.cjs} +45 -1
- package/dist/itemId-Dzrr3vs5.cjs.map +1 -0
- package/dist/listen-D5av7GzB.js +22 -0
- package/dist/listen-D5av7GzB.js.map +1 -0
- package/dist/listen-UZA68Upt.cjs +45 -0
- package/dist/listen-UZA68Upt.cjs.map +1 -0
- package/dist/{promptSafety-CpiME8pj.js → promptSafety-CSdyp7RF.js} +2 -2
- package/dist/{promptSafety-CpiME8pj.js.map → promptSafety-CSdyp7RF.js.map} +1 -1
- package/dist/{promptSafety-NNGViiCr.cjs → promptSafety-CWqYq-NS.cjs} +8 -8
- package/dist/{promptSafety-NNGViiCr.cjs.map → promptSafety-CWqYq-NS.cjs.map} +1 -1
- package/dist/remote-host/server/hostRunner.d.ts +2 -4
- package/dist/remote-host/server/index.cjs +4 -19
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +1 -16
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{server-CWZyg8fn.cjs → server-X_magqUa.cjs} +620 -734
- package/dist/server-X_magqUa.cjs.map +1 -0
- package/dist/{server-B48Jyxcj.js → server-zcM1ViCE.js} +418 -568
- package/dist/server-zcM1ViCE.js.map +1 -0
- package/dist/whisper/index.cjs +1 -1
- package/dist/whisper/index.js +1 -1
- package/package.json +1 -1
- package/dist/calendarGrid-CQ8MVSRb.js.map +0 -1
- package/dist/calendarGrid-DGILaVxI.cjs.map +0 -1
- package/dist/collection/core/shortHexId.d.ts +0 -8
- package/dist/collection/server/publish.d.ts +0 -56
- package/dist/server-B48Jyxcj.js.map +0 -1
- package/dist/server-CWZyg8fn.cjs.map +0 -1
- package/dist/store-5_P_NsGa.cjs.map +0 -1
- package/dist/store-_61sO8K8.js.map +0 -1
|
@@ -2,9 +2,9 @@ const require_rolldown_runtime = require("./rolldown-runtime-VH7oDXx4.cjs");
|
|
|
2
2
|
const require_dist = require("./dist-CsgSfWwR.cjs");
|
|
3
3
|
const require_root = require("./root-rPH6FGDT.cjs");
|
|
4
4
|
const require_relPath = require("./relPath-CTAjGdCL.cjs");
|
|
5
|
-
const
|
|
6
|
-
const require_promptSafety = require("./promptSafety-
|
|
7
|
-
const
|
|
5
|
+
const require_itemId = require("./itemId-Dzrr3vs5.cjs");
|
|
6
|
+
const require_promptSafety = require("./promptSafety-CWqYq-NS.cjs");
|
|
7
|
+
const require_discovery = require("./discovery-BsXiDZBR.cjs");
|
|
8
8
|
const require_feeds_paths = require("./feeds/paths.cjs");
|
|
9
9
|
const require_skill_bridge_index = require("./skill-bridge/index.cjs");
|
|
10
10
|
let node_path = require("node:path");
|
|
@@ -12,13 +12,11 @@ node_path = require_rolldown_runtime.__toESM(node_path, 1);
|
|
|
12
12
|
let node_crypto = require("node:crypto");
|
|
13
13
|
let node_fs_promises = require("node:fs/promises");
|
|
14
14
|
let zod = require("zod");
|
|
15
|
-
let node_child_process = require("node:child_process");
|
|
16
|
-
let node_util = require("node:util");
|
|
17
15
|
//#region src/collection/server/publishManifest.ts
|
|
18
16
|
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
19
17
|
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
20
18
|
* cannot be a way around it. */
|
|
21
|
-
var NameZ = zod.z.string().refine(
|
|
19
|
+
var NameZ = zod.z.string().refine(require_itemId.isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
|
|
22
20
|
/** An address on the roster. Not validated as an email beyond "has an @":
|
|
23
21
|
* the rules compare it to `request.auth.token.email` verbatim, so any
|
|
24
22
|
* narrowing here would refuse addresses Firebase itself accepts. */
|
|
@@ -126,6 +124,34 @@ var PublicZ = zod.z.object({
|
|
|
126
124
|
read: zod.z.array(NameZ).optional(),
|
|
127
125
|
submit: zod.z.record(NameZ, SubmitZ).optional()
|
|
128
126
|
}).strict();
|
|
127
|
+
/** The URL name an app is handed out under: `https://<host>/{slug}`.
|
|
128
|
+
*
|
|
129
|
+
* A SEPARATE name from the `aid`, and that separation is the point (design
|
|
130
|
+
* D2b). `apps/{aid}` is a shelf every user of the deployment shares and the
|
|
131
|
+
* rules' `allow create` asks only that you name yourself owner — so a
|
|
132
|
+
* memorable aid is first-come-first-served, cannot be checked for
|
|
133
|
+
* availability, and frees up again when an app is deleted. The aid is
|
|
134
|
+
* therefore a UUID, and the thing people can fight over is moved to the name
|
|
135
|
+
* that costs nothing to change.
|
|
136
|
+
*
|
|
137
|
+
* Declared here rather than kept beside `app.json` because a RESERVATION has
|
|
138
|
+
* to travel with the repository: `appSlugs/{slug}` is unreadable until the app
|
|
139
|
+
* is published (`allow read: if resource.data.published == true`), so nothing
|
|
140
|
+
* can recover which slug an app holds by asking Firestore. A second file to
|
|
141
|
+
* keep in step with the declaration is the alternative, and it is the kind of
|
|
142
|
+
* pair that goes out of step silently.
|
|
143
|
+
*
|
|
144
|
+
* The shape is stricter than `NameZ` on purpose: it is BOTH a URL path
|
|
145
|
+
* segment people read aloud and a Firestore document id. Lowercase
|
|
146
|
+
* alphanumerics separated by single hyphens covers both without a case rule
|
|
147
|
+
* that would make two slugs collide in one place and not the other.
|
|
148
|
+
*
|
|
149
|
+
* Which slug an app ended up with is the HOST's business — a wanted slug can
|
|
150
|
+
* be taken, and the host writes the one it reserved back here. Nothing in this
|
|
151
|
+
* package reads the key; it is declared so that writing it back does not make
|
|
152
|
+
* the file unparseable. */
|
|
153
|
+
var SLUG_SHAPE = "must be lowercase letters, digits and single hyphens, and must not start or end with one (e.g. sakura-hair)";
|
|
154
|
+
var SlugZ = zod.z.string().trim().max(64).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, SLUG_SHAPE).refine((slug) => !slug.includes("--"), SLUG_SHAPE);
|
|
129
155
|
/** The whole authored declaration.
|
|
130
156
|
*
|
|
131
157
|
* `owner` is accepted but is NOT the published value — publish stamps the
|
|
@@ -137,6 +163,8 @@ var PublicZ = zod.z.object({
|
|
|
137
163
|
var AuthoredAppZ = zod.z.object({
|
|
138
164
|
aid: NameZ,
|
|
139
165
|
name: zod.z.string().trim().min(1).optional(),
|
|
166
|
+
/** The wanted (or reserved) URL name — see {@link SlugZ}. */
|
|
167
|
+
slug: SlugZ.optional(),
|
|
140
168
|
/** Per-worktree app id (design D6, implementation order 7). Accepted so a
|
|
141
169
|
* repository already carrying it parses; nothing reads it yet. */
|
|
142
170
|
aidEnv: zod.z.string().trim().min(1).optional(),
|
|
@@ -154,7 +182,7 @@ var AuthoredAppZ = zod.z.object({
|
|
|
154
182
|
* once — publish is a manual step, and a parser that stops at the first key
|
|
155
183
|
* makes it N round trips. */
|
|
156
184
|
function parseAuthoredApp(raw) {
|
|
157
|
-
const manifest =
|
|
185
|
+
const manifest = require_discovery.parseAppManifest(raw);
|
|
158
186
|
if (!manifest.ok) return {
|
|
159
187
|
ok: false,
|
|
160
188
|
problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
|
|
@@ -265,6 +293,7 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
265
293
|
publishedAt: stamp.publishedAt,
|
|
266
294
|
publishedBy: stamp.email,
|
|
267
295
|
publishedCommit: stamp.commit,
|
|
296
|
+
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
268
297
|
previousPublished: previousOf(existing)
|
|
269
298
|
});
|
|
270
299
|
const config = {
|
|
@@ -283,6 +312,102 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
283
312
|
config
|
|
284
313
|
};
|
|
285
314
|
}
|
|
315
|
+
/** Keys on the app document that publish owns: what is PUBLIC right now, and
|
|
316
|
+
* the rule-facing configuration anonymous access is judged against. Deploy
|
|
317
|
+
* carries them through from the existing document and never authors them. */
|
|
318
|
+
var PUBLISH_OWNED_KEYS = [
|
|
319
|
+
"public",
|
|
320
|
+
"collections",
|
|
321
|
+
"participantRead",
|
|
322
|
+
"publishedAt",
|
|
323
|
+
"publishedBy",
|
|
324
|
+
"publishedCommit",
|
|
325
|
+
"publishedDirty",
|
|
326
|
+
"previousPublished"
|
|
327
|
+
];
|
|
328
|
+
var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
|
|
329
|
+
function projectDeploy(authored, schemas, stamp, existing) {
|
|
330
|
+
const { app } = projectApp(authored, schemas, stamp, existing);
|
|
331
|
+
const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
|
|
332
|
+
for (const key of PUBLISH_OWNED_KEYS) {
|
|
333
|
+
const live = existing?.[key];
|
|
334
|
+
if (live !== void 0) deployed[key] = live;
|
|
335
|
+
}
|
|
336
|
+
deployed.deployedAt = stamp.publishedAt;
|
|
337
|
+
deployed.deployedBy = stamp.email;
|
|
338
|
+
if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
|
|
339
|
+
return {
|
|
340
|
+
app: deployed,
|
|
341
|
+
staging: schemas.map(({ cid, schema }) => ({
|
|
342
|
+
cid,
|
|
343
|
+
doc: stagedDoc(schema, stamp, authored, cid)
|
|
344
|
+
}))
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/** One staged schema document, carrying this cid's rule-facing configuration
|
|
348
|
+
* alongside the schema so publish can promote them together. */
|
|
349
|
+
function stagedDoc(schema, stamp, authored, cid) {
|
|
350
|
+
const doc = {
|
|
351
|
+
publishedSchema: schema,
|
|
352
|
+
deployedAt: stamp.publishedAt,
|
|
353
|
+
deployedBy: stamp.email
|
|
354
|
+
};
|
|
355
|
+
const config = authored.collections?.[cid];
|
|
356
|
+
if (config !== void 0) doc.config = config;
|
|
357
|
+
if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
|
|
358
|
+
if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
|
|
359
|
+
return doc;
|
|
360
|
+
}
|
|
361
|
+
/** The rule-facing configuration to promote, read from the STAGED documents
|
|
362
|
+
* rather than from the manifest as it reads right now.
|
|
363
|
+
*
|
|
364
|
+
* Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
|
|
365
|
+
* the promoted schema is A's while the authorization behaviour is B's, a
|
|
366
|
+
* combination nobody exercised through `/staging/{aid}`.
|
|
367
|
+
*
|
|
368
|
+
* (`public` is deliberately NOT part of this: it is not staged, because it is
|
|
369
|
+
* the decision being made AT publish rather than something under test.) */
|
|
370
|
+
function stagedRuleConfig(staged) {
|
|
371
|
+
const entries = [];
|
|
372
|
+
const participantRead = [];
|
|
373
|
+
for (const entry of staged) {
|
|
374
|
+
const { config } = entry.doc;
|
|
375
|
+
if (config !== void 0) entries.push([entry.cid, config]);
|
|
376
|
+
if (entry.doc.participantRead === true) participantRead.push(entry.cid);
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
|
|
380
|
+
participantRead: participantRead.length > 0 ? participantRead : void 0
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function projectPublish(authored, staged, stamp, existing) {
|
|
384
|
+
const { app, config } = projectApp(authored, [], stamp, existing);
|
|
385
|
+
const staging = stagedRuleConfig(staged);
|
|
386
|
+
app.collections = staging.collections;
|
|
387
|
+
app.participantRead = staging.participantRead;
|
|
388
|
+
const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
|
|
389
|
+
for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
|
|
390
|
+
const publicBlock = app.public;
|
|
391
|
+
return {
|
|
392
|
+
app: published,
|
|
393
|
+
config,
|
|
394
|
+
public: require_dist.isRecord(publicBlock) ? publicBlock : void 0
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
|
|
398
|
+
*
|
|
399
|
+
* The stamp answers "which version is PUBLIC right now, and who made it so",
|
|
400
|
+
* so it is written by the operation that changes the answer — publish — not
|
|
401
|
+
* carried over from the deploy that staged it. */
|
|
402
|
+
function promoteSchema(staged, stamp) {
|
|
403
|
+
const doc = {
|
|
404
|
+
publishedSchema: staged.publishedSchema,
|
|
405
|
+
publishedAt: stamp.publishedAt,
|
|
406
|
+
publishedBy: stamp.email
|
|
407
|
+
};
|
|
408
|
+
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
409
|
+
return doc;
|
|
410
|
+
}
|
|
286
411
|
/** One published schema document. Written key by key rather than through
|
|
287
412
|
* `compact`, so the declared type is the type — an optional commit is the
|
|
288
413
|
* only variable part. */
|
|
@@ -298,8 +423,31 @@ function schemaDoc(schema, stamp) {
|
|
|
298
423
|
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
299
424
|
* collection path plus a document id, and the app document's id is the aid. */
|
|
300
425
|
var APPS_COLLECTION = "apps";
|
|
301
|
-
/** The collection (schema) documents' parent path
|
|
426
|
+
/** The collection (schema) documents' parent path — what the PUBLIC page
|
|
427
|
+
* reads, written only by publish (promotion). */
|
|
302
428
|
var appSchemasPath = (aid) => `apps/${aid}/collections`;
|
|
429
|
+
/** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
|
|
430
|
+
*
|
|
431
|
+
* A TOP-LEVEL collection, not a field on the app: the public page resolves a
|
|
432
|
+
* slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
|
|
433
|
+
* has to be claimable atomically (create-if-absent) so two apps cannot hold
|
|
434
|
+
* the same URL.
|
|
435
|
+
*
|
|
436
|
+
* `published` is what makes the reservation invisible until publish. The slug
|
|
437
|
+
* is human-readable, so a readable reservation would let anyone guess the URL
|
|
438
|
+
* and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
|
|
439
|
+
* `allow read: if resource.data.published == true`, which needs no `get()` and
|
|
440
|
+
* so costs nothing against the rules' expression budget. */
|
|
441
|
+
var APP_SLUGS_COLLECTION = "appSlugs";
|
|
442
|
+
var appSlugDoc = (aid, published) => ({
|
|
443
|
+
aid,
|
|
444
|
+
published
|
|
445
|
+
});
|
|
446
|
+
/** The staged schema documents' parent path — what `/staging/{aid}` reads,
|
|
447
|
+
* written by deploy. A separate DOCUMENT rather than a field beside
|
|
448
|
+
* `publishedSchema`, because the rules cannot hide a field: anything inside a
|
|
449
|
+
* document the public page may read is public. */
|
|
450
|
+
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
303
451
|
/** The public-config documents' parent path. */
|
|
304
452
|
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
305
453
|
//#endregion
|
|
@@ -525,532 +673,34 @@ function publishProblems(app, collections, publisherEmail) {
|
|
|
525
673
|
...primaryKeyProblems(app, collections)
|
|
526
674
|
];
|
|
527
675
|
}
|
|
528
|
-
/** A public submission must be
|
|
676
|
+
/** A public submission must NOT be allowed to name its own primary key.
|
|
677
|
+
*
|
|
678
|
+
* The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
|
|
679
|
+
* value of a field — nothing compares `request.resource.data[primaryKey]`
|
|
680
|
+
* with the path being written. So a submit path that accepts the primary key
|
|
681
|
+
* as a `createField` lets a submitter write at their one permitted document
|
|
682
|
+
* id while CLAIMING another record's identity, or a duplicate.
|
|
529
683
|
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
* `createFields` omits the primary key writes rows that Firestore accepts and
|
|
537
|
-
* every reader rejects: `validateRecordObject` fails them, the collection
|
|
538
|
-
* renders empty-ish, and the next publish's own pre-check reports them as
|
|
539
|
-
* broken records the publisher never wrote.
|
|
684
|
+
* It is refused rather than tolerated because there is nothing for the field
|
|
685
|
+
* to do: `firestoreStore` takes a shared record's identity from the document
|
|
686
|
+
* id and overwrites the field on read, so a submitted value is either equal
|
|
687
|
+
* to the id (noise) or a lie (silently discarded). Publishing a form field
|
|
688
|
+
* whose value is thrown away is worse than not having it — the author will
|
|
689
|
+
* believe submitters choose their ids.
|
|
540
690
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
691
|
+
* This is the second answer to the same question. The first was the reverse —
|
|
692
|
+
* REQUIRE the key, because a record without one was rejected by every reader
|
|
693
|
+
* — and it was right about the symptom and wrong about the cure: the identity
|
|
694
|
+
* belongs to the id the rules can pin, not to a field they cannot. */
|
|
544
695
|
function primaryKeyProblems(app, collections) {
|
|
545
696
|
const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
|
|
546
697
|
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
547
698
|
const primaryKey = primaryKeyOf.get(cid);
|
|
548
|
-
if (primaryKey === void 0 || submit.createFields.includes(primaryKey)) return [];
|
|
549
|
-
return [`public.submit.${cid}.createFields must include "${primaryKey}", the schema's primaryKey:
|
|
699
|
+
if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
|
|
700
|
+
return [`public.submit.${cid}.createFields must NOT include "${primaryKey}", the schema's primaryKey: the rules can pin the document id but not the value of a field, so a submitter could write at their own id while claiming another record's. A shared record's identity is its document id — the store fills the field from it, and a submitted value is either the same thing or a lie that is thrown away.`];
|
|
550
701
|
});
|
|
551
702
|
}
|
|
552
703
|
//#endregion
|
|
553
|
-
//#region src/collection/core/recordZ.ts
|
|
554
|
-
/** The emptiness rule shared by `required` and the "only check present
|
|
555
|
-
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
556
|
-
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
557
|
-
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
558
|
-
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
559
|
-
function enforcedProblem(key, spec, value) {
|
|
560
|
-
const empty = isEmptyValue(value);
|
|
561
|
-
if (spec.required && empty) return `missing required field '${key}'`;
|
|
562
|
-
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
563
|
-
return null;
|
|
564
|
-
}
|
|
565
|
-
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
566
|
-
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
567
|
-
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
568
|
-
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
569
|
-
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
570
|
-
* which the day view can't place). `string`-backed types accept anything
|
|
571
|
-
* stringifiable; `ref` existence is out of scope. */
|
|
572
|
-
function strictTypeProblem(key, spec, value) {
|
|
573
|
-
switch (spec.type) {
|
|
574
|
-
case "number":
|
|
575
|
-
case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
576
|
-
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
577
|
-
case "date": return require_calendarGrid.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
578
|
-
case "datetime": return require_calendarGrid.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
579
|
-
default: return null;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
583
|
-
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
584
|
-
* First row problem wins, prefixed with the row number so the fix is
|
|
585
|
-
* locatable. */
|
|
586
|
-
function strictTableProblem(key, spec, value) {
|
|
587
|
-
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
588
|
-
for (let index = 0; index < value.length; index++) {
|
|
589
|
-
const row = value[index];
|
|
590
|
-
if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
591
|
-
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
592
|
-
const subValue = row[subKey];
|
|
593
|
-
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
594
|
-
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
return null;
|
|
598
|
-
}
|
|
599
|
-
/** First problem for one field's stored value under `tier`, or null.
|
|
600
|
-
* Enforced checks always run (and their messages never vary by tier — the
|
|
601
|
-
* scan and the write gate must agree on them); strict adds the per-type
|
|
602
|
-
* layer on present values only. */
|
|
603
|
-
function recordFieldProblem(key, spec, value, tier) {
|
|
604
|
-
const enforced = enforcedProblem(key, spec, value);
|
|
605
|
-
if (enforced || tier === "enforced") return enforced;
|
|
606
|
-
if (isEmptyValue(value)) return null;
|
|
607
|
-
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
608
|
-
return strictTypeProblem(key, spec, value);
|
|
609
|
-
}
|
|
610
|
-
var compiled = /* @__PURE__ */ new WeakMap();
|
|
611
|
-
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
612
|
-
* Loose object: unknown keys are allowed and any declared key may be
|
|
613
|
-
* absent (records are user files, not parse-and-rewrite targets —
|
|
614
|
-
* callers validate, they never persist the parse output). The checks run
|
|
615
|
-
* as ONE object-level refine iterating fields in declaration order —
|
|
616
|
-
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
617
|
-
* must still reach the required check", and the single loop keeps the
|
|
618
|
-
* first reported issue identical to the historical first-problem-wins
|
|
619
|
-
* contract. */
|
|
620
|
-
function compileRecordZ(schema, tier) {
|
|
621
|
-
const cached = compiled.get(schema)?.[tier];
|
|
622
|
-
if (cached) return cached;
|
|
623
|
-
const stored = Object.entries(schema.fields).filter(([, spec]) => !require_calendarGrid.COMPUTED_TYPES.has(spec.type));
|
|
624
|
-
const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
|
|
625
|
-
for (const [key, spec] of stored) {
|
|
626
|
-
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
627
|
-
if (problem) ctx.addIssue({
|
|
628
|
-
code: "custom",
|
|
629
|
-
message: problem,
|
|
630
|
-
path: [key]
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
});
|
|
634
|
-
const entry = compiled.get(schema) ?? {};
|
|
635
|
-
entry[tier] = validator;
|
|
636
|
-
compiled.set(schema, entry);
|
|
637
|
-
return validator;
|
|
638
|
-
}
|
|
639
|
-
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
640
|
-
* issue per record keeps the report short and the fix obvious (the
|
|
641
|
-
* historical contract of `validateRecordObject`). */
|
|
642
|
-
function firstRecordProblem(record, schema, tier) {
|
|
643
|
-
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
644
|
-
if (result.success) return null;
|
|
645
|
-
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
646
|
-
}
|
|
647
|
-
//#endregion
|
|
648
|
-
//#region src/collection/server/validate.ts
|
|
649
|
-
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
650
|
-
* because a caller that REPORTS a count has to know the count is a floor —
|
|
651
|
-
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
652
|
-
var MAX_RECORD_ISSUES = 25;
|
|
653
|
-
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
654
|
-
* at all.
|
|
655
|
-
*
|
|
656
|
-
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
657
|
-
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
658
|
-
* direction that matters: `publish` lets the user override invalid records,
|
|
659
|
-
* and overriding this one would mean publishing without ever having looked. */
|
|
660
|
-
var STORE_UNREADABLE = "(store)";
|
|
661
|
-
var MAX_ISSUES = 25;
|
|
662
|
-
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
663
|
-
* ones that won't load or violate the schema. An empty list means every
|
|
664
|
-
* record is fine. */
|
|
665
|
-
/** List entries under the data dir, guarding realpath containment (against a
|
|
666
|
-
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
667
|
-
* missing dir as empty while surfacing real I/O faults. */
|
|
668
|
-
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
669
|
-
if (!require_store.isContainedInRoot(dataDir, workspaceRoot)) {
|
|
670
|
-
require_store.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
671
|
-
return [];
|
|
672
|
-
}
|
|
673
|
-
try {
|
|
674
|
-
return await (0, node_fs_promises.readdir)(dataDir);
|
|
675
|
-
} catch (err) {
|
|
676
|
-
if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
677
|
-
throw err;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
async function validateCollectionRecords(collection, opts = {}) {
|
|
681
|
-
if (collection.schema.dataSource !== void 0) return [];
|
|
682
|
-
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
683
|
-
const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
|
|
684
|
-
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
685
|
-
const issues = [];
|
|
686
|
-
for (const name of entries.sort()) {
|
|
687
|
-
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
688
|
-
if (issues.length >= MAX_ISSUES) break;
|
|
689
|
-
const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
|
|
690
|
-
if (issue) issues.push(issue);
|
|
691
|
-
}
|
|
692
|
-
return issues;
|
|
693
|
-
}
|
|
694
|
-
/** Store-backed twin of the file scan: list every record through the
|
|
695
|
-
* collection's store and lint it with the same "strict" report-only tier.
|
|
696
|
-
* A row the store can't even parse is invisible here (the store skips
|
|
697
|
-
* it), so the read/parse classifications of the file scan don't apply —
|
|
698
|
-
* schema violations are what this catches. `file` carries the record id
|
|
699
|
-
* (there is no per-record filename). */
|
|
700
|
-
async function validateStoreRecords(collection, opts) {
|
|
701
|
-
let items;
|
|
702
|
-
try {
|
|
703
|
-
items = await require_store.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
704
|
-
} catch (err) {
|
|
705
|
-
return [{
|
|
706
|
-
file: STORE_UNREADABLE,
|
|
707
|
-
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
708
|
-
}];
|
|
709
|
-
}
|
|
710
|
-
const issues = [];
|
|
711
|
-
for (const item of items) {
|
|
712
|
-
if (issues.length >= MAX_ISSUES) break;
|
|
713
|
-
const itemId = require_calendarGrid.fieldText(item[collection.schema.primaryKey]);
|
|
714
|
-
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
715
|
-
if (problem) issues.push({
|
|
716
|
-
file: itemId,
|
|
717
|
-
problem
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
return issues;
|
|
721
|
-
}
|
|
722
|
-
async function readRecordText(fullPath, name) {
|
|
723
|
-
try {
|
|
724
|
-
if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
|
|
725
|
-
file: name,
|
|
726
|
-
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
727
|
-
};
|
|
728
|
-
return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
|
|
729
|
-
} catch {
|
|
730
|
-
return {
|
|
731
|
-
file: name,
|
|
732
|
-
problem: "could not be read — skipped, won't appear"
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
737
|
-
* schema violation, or null when it's fine. */
|
|
738
|
-
async function inspectRecord(fullPath, name, schema) {
|
|
739
|
-
const read = await readRecordText(fullPath, name);
|
|
740
|
-
if ("problem" in read) return read;
|
|
741
|
-
let parsed;
|
|
742
|
-
try {
|
|
743
|
-
parsed = JSON.parse(read.raw);
|
|
744
|
-
} catch (err) {
|
|
745
|
-
return {
|
|
746
|
-
file: name,
|
|
747
|
-
problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
|
|
748
|
-
};
|
|
749
|
-
}
|
|
750
|
-
if (!require_dist.isRecord(parsed)) return {
|
|
751
|
-
file: name,
|
|
752
|
-
problem: "not a JSON object — skipped, won't appear"
|
|
753
|
-
};
|
|
754
|
-
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
755
|
-
return problem ? {
|
|
756
|
-
file: name,
|
|
757
|
-
problem
|
|
758
|
-
} : null;
|
|
759
|
-
}
|
|
760
|
-
/** What a non-string primary key actually is, for the error message. Names the
|
|
761
|
-
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
762
|
-
* nothing about what is wrong. */
|
|
763
|
-
function describeIdType(value) {
|
|
764
|
-
if (value === null) return "null";
|
|
765
|
-
if (value === void 0) return "missing";
|
|
766
|
-
if (Array.isArray(value)) return "an array";
|
|
767
|
-
return `a ${typeof value}`;
|
|
768
|
-
}
|
|
769
|
-
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
770
|
-
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
771
|
-
* tiers), or null when it's fine. One issue per record keeps the report
|
|
772
|
-
* short and the fix obvious. Pure + exported so write paths
|
|
773
|
-
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
774
|
-
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
775
|
-
* would be) stored under. The default `"enforced"` tier keeps every
|
|
776
|
-
* write gate on the historical three checks; only pass `"strict"` from
|
|
777
|
-
* report-only surfaces. */
|
|
778
|
-
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
779
|
-
const idValue = record[schema.primaryKey];
|
|
780
|
-
if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
781
|
-
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
782
|
-
return firstRecordProblem(record, schema, tier);
|
|
783
|
-
}
|
|
784
|
-
//#endregion
|
|
785
|
-
//#region src/collection/server/publish.ts
|
|
786
|
-
var execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
|
|
787
|
-
/** How many broken records to name before summarising. A publish that would
|
|
788
|
-
* break a thousand rows is answered by the count and a sample; dumping all of
|
|
789
|
-
* them buries the number, which is the part the decision turns on. */
|
|
790
|
-
var MAX_LISTED_ISSUES = 10;
|
|
791
|
-
/** `git rev-parse HEAD` plus a dirty check, or nothing.
|
|
792
|
-
*
|
|
793
|
-
* A missing git, a repository with no commits and a non-repository are all
|
|
794
|
-
* the same answer here — no commit — because the stamp is attribution, not a
|
|
795
|
-
* requirement. What is NOT acceptable is a stamp that lies, which is why the
|
|
796
|
-
* dirty flag exists: publishing from a modified tree records a commit that
|
|
797
|
-
* does not describe what was published, and the flag is the only thing that
|
|
798
|
-
* would ever tell a reader so. */
|
|
799
|
-
async function gitStamp(root) {
|
|
800
|
-
try {
|
|
801
|
-
const { stdout } = await execFileAsync("git", [
|
|
802
|
-
"-C",
|
|
803
|
-
root,
|
|
804
|
-
"rev-parse",
|
|
805
|
-
"HEAD"
|
|
806
|
-
]);
|
|
807
|
-
const commit = stdout.trim();
|
|
808
|
-
const { stdout: status } = await execFileAsync("git", [
|
|
809
|
-
"-C",
|
|
810
|
-
root,
|
|
811
|
-
"status",
|
|
812
|
-
"--porcelain"
|
|
813
|
-
]);
|
|
814
|
-
return {
|
|
815
|
-
commit: commit.length > 0 ? commit : void 0,
|
|
816
|
-
dirty: status.trim().length > 0
|
|
817
|
-
};
|
|
818
|
-
} catch {
|
|
819
|
-
return {};
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
/** The shared collections of THIS REPOSITORY, by cid.
|
|
823
|
-
*
|
|
824
|
-
* `userSkillsDir: null` — not a test convenience, a boundary. Discovery
|
|
825
|
-
* resolves every schema it finds against the WORKSPACE root, user-scope
|
|
826
|
-
* included, so a globally installed skill under `~/.claude/skills` carrying
|
|
827
|
-
* `storage.type: "firestore"` picks up whichever repository's `aid` it
|
|
828
|
-
* happens to be discovered from. Left in, publish would write that schema
|
|
829
|
-
* into this app — and into every other app the same user publishes, since the
|
|
830
|
-
* skill is installed once per machine and the repositories are not.
|
|
831
|
-
*
|
|
832
|
-
* An app is a REPOSITORY (design D1): its collections are the ones committed
|
|
833
|
-
* beside its `app.json`, which is what makes a clone resolve the same
|
|
834
|
-
* collections and an invitation a matter of authorization rather than
|
|
835
|
-
* discovery. A schema that is not in the repository has no claim on a cid
|
|
836
|
-
* there. And because a view is HTML, publishing one is not a tidiness
|
|
837
|
-
* question: it is the machine's own skills reaching every member's browser.
|
|
838
|
-
*
|
|
839
|
-
* The consequence is deliberate: a cid named in `app.json` that exists only
|
|
840
|
-
* in user scope is now an unknown cid, and publish says so by name instead of
|
|
841
|
-
* quietly publishing a schema from outside the repository. */
|
|
842
|
-
async function sharedCollections(opts, root) {
|
|
843
|
-
return (await require_store.discoverCollections({
|
|
844
|
-
...opts,
|
|
845
|
-
workspaceRoot: root,
|
|
846
|
-
userSkillsDir: null
|
|
847
|
-
})).filter((collection) => collection.appId !== void 0);
|
|
848
|
-
}
|
|
849
|
-
/** Existing records that would not satisfy the schema about to be published.
|
|
850
|
-
*
|
|
851
|
-
* Read from FIRESTORE, not from disk: a shared collection's records live in
|
|
852
|
-
* the app, and the question this answers is "what does the live data look
|
|
853
|
-
* like under the new schema" — which is the migration question. Reported as
|
|
854
|
-
* a refusal the publisher can override, because a breaking change is
|
|
855
|
-
* sometimes exactly what is intended and the point is that it is a decision
|
|
856
|
-
* rather than a discovery. */
|
|
857
|
-
async function recordProblems(collections, opts) {
|
|
858
|
-
const lines = [];
|
|
859
|
-
const unreadable = [];
|
|
860
|
-
let records = 0;
|
|
861
|
-
let cappedAnywhere = false;
|
|
862
|
-
for (const collection of collections) {
|
|
863
|
-
const issues = await validateCollectionRecords(collection, opts);
|
|
864
|
-
if (issues.length === 0) continue;
|
|
865
|
-
const unread = issues.filter((issue) => issue.file === STORE_UNREADABLE);
|
|
866
|
-
if (unread.length > 0) {
|
|
867
|
-
unreadable.push(`${collection.slug}: ${unread.map((issue) => issue.problem).join("; ")}`);
|
|
868
|
-
continue;
|
|
869
|
-
}
|
|
870
|
-
records += issues.length;
|
|
871
|
-
const capped = issues.length >= 25;
|
|
872
|
-
cappedAnywhere = cappedAnywhere || capped;
|
|
873
|
-
const count = capped ? `at least ${issues.length}` : String(issues.length);
|
|
874
|
-
const plural = issues.length === 1 ? "" : "s";
|
|
875
|
-
const note = capped ? " (the scan stops there)" : "";
|
|
876
|
-
lines.push(`${collection.slug}: ${count} existing record${plural} would not satisfy the schema about to be published${note}`);
|
|
877
|
-
for (const issue of issues.slice(0, MAX_LISTED_ISSUES)) lines.push(` - ${issue.file}: ${issue.problem}`);
|
|
878
|
-
if (issues.length > MAX_LISTED_ISSUES) lines.push(` - … and ${issues.length - MAX_LISTED_ISSUES} more`);
|
|
879
|
-
}
|
|
880
|
-
return {
|
|
881
|
-
lines,
|
|
882
|
-
records,
|
|
883
|
-
capped: cappedAnywhere,
|
|
884
|
-
unreadable
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
function schemasOf(collections) {
|
|
888
|
-
return collections.map((collection) => ({
|
|
889
|
-
cid: collection.slug,
|
|
890
|
-
schema: collection.schema
|
|
891
|
-
})).sort((left, right) => left.cid < right.cid ? -1 : left.cid > right.cid ? 1 : 0);
|
|
892
|
-
}
|
|
893
|
-
/** Read and parse `<root>/app.json`'s full declaration. */
|
|
894
|
-
async function readAuthored(root) {
|
|
895
|
-
let raw;
|
|
896
|
-
try {
|
|
897
|
-
raw = await (0, node_fs_promises.readFile)(node_path.default.join(root, require_store.APP_MANIFEST_FILE), "utf-8");
|
|
898
|
-
} catch (err) {
|
|
899
|
-
return {
|
|
900
|
-
ok: false,
|
|
901
|
-
problems: [`cannot read ${node_path.default.join(root, require_store.APP_MANIFEST_FILE)}: ${String(err)}`]
|
|
902
|
-
};
|
|
903
|
-
}
|
|
904
|
-
return parseAuthoredApp(raw);
|
|
905
|
-
}
|
|
906
|
-
/** Everything wrong with the declaration itself, publisher included. */
|
|
907
|
-
function declarationProblems(app, collections, handle) {
|
|
908
|
-
const problems = publishProblems(app, collections.map((collection) => ({
|
|
909
|
-
cid: collection.slug,
|
|
910
|
-
primaryKey: collection.schema.primaryKey
|
|
911
|
-
})), handle.email);
|
|
912
|
-
if (app.owner !== void 0 && app.owner !== handle.uid) problems.push(`app.json declares owner "${app.owner}", which is not your uid (${handle.uid}). \`owner\` is stamped by publish and carried forward unchanged afterwards — remove it from app.json rather than maintaining it by hand.`);
|
|
913
|
-
return problems;
|
|
914
|
-
}
|
|
915
|
-
/** Put the three kinds of document, in the order the rules require, and turn a
|
|
916
|
-
* rejected write into the result type instead of letting it escape.
|
|
917
|
-
*
|
|
918
|
-
* Returns null when everything was written; a failure result otherwise.
|
|
919
|
-
*
|
|
920
|
-
* A raw rejection here would reach the agent as a tool crash rather than the
|
|
921
|
-
* actionable text this tool promises. But "actionable" is a strong claim for
|
|
922
|
-
* a half-finished publish, so the message ENUMERATES what landed rather than
|
|
923
|
-
* summarising it: the order is app → every schema → config, and a summary
|
|
924
|
-
* written for one failure point is wrong at the others. Saying "the roster
|
|
925
|
-
* and configuration are live" after a SCHEMA write failed names a config
|
|
926
|
-
* document this publish never wrote — which still holds whatever the last
|
|
927
|
-
* publish left, and is exactly the state the caller is trying to repair. */
|
|
928
|
-
async function writeDocuments(handle, aid, published) {
|
|
929
|
-
const steps = [
|
|
930
|
-
{
|
|
931
|
-
what: `the app document (apps/${aid})`,
|
|
932
|
-
run: () => handle.docs.set(APPS_COLLECTION, aid, published.app)
|
|
933
|
-
},
|
|
934
|
-
...published.schemas.map(({ cid, doc }) => ({
|
|
935
|
-
what: `the published schema for '${cid}'`,
|
|
936
|
-
run: () => handle.docs.set(appSchemasPath(aid), cid, doc)
|
|
937
|
-
})),
|
|
938
|
-
{
|
|
939
|
-
what: `the public config document (apps/${aid}/config/${PUBLIC_CONFIG_DOC})`,
|
|
940
|
-
run: () => handle.docs.set(appConfigPath(aid), PUBLIC_CONFIG_DOC, published.config)
|
|
941
|
-
}
|
|
942
|
-
];
|
|
943
|
-
const landed = [];
|
|
944
|
-
for (const [index, step] of steps.entries()) try {
|
|
945
|
-
await step.run();
|
|
946
|
-
landed.push(step.what);
|
|
947
|
-
} catch (err) {
|
|
948
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
949
|
-
return {
|
|
950
|
-
ok: false,
|
|
951
|
-
partial: index > 0,
|
|
952
|
-
problems: [`publish failed while writing ${step.what}: ${reason}`, ...partialState(landed, [step.what, ...steps.slice(index + 1).map((rest) => rest.what)])]
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
return null;
|
|
956
|
-
}
|
|
957
|
-
/** What is live and what is not, listed rather than summarised.
|
|
958
|
-
*
|
|
959
|
-
* Two facts, and both matter for the repair: a document this publish wrote is
|
|
960
|
-
* live NOW, and a document it did not write still holds what the LAST publish
|
|
961
|
-
* left — which is not the same as being absent, and not the same as matching
|
|
962
|
-
* the declaration that was just half-applied. */
|
|
963
|
-
function partialState(landed, notWritten) {
|
|
964
|
-
const repair = "Publishing again is the repair: the write is idempotent, and it re-does every step, including the ones that did land.";
|
|
965
|
-
if (landed.length === 0) return [`Nothing was written. ${repair}`];
|
|
966
|
-
return [
|
|
967
|
-
`Written by this publish, and live now: ${landed.join("; ")}.`,
|
|
968
|
-
`NOT written: ${notWritten.join("; ")} — ${notWritten.length === 1 ? "it still holds" : "they still hold"} whatever the previous publish left.`,
|
|
969
|
-
repair
|
|
970
|
-
];
|
|
971
|
-
}
|
|
972
|
-
/** Publish this repository's declaration to its app.
|
|
973
|
-
*
|
|
974
|
-
* Everything variable is a parameter or comes from the host binding, so the
|
|
975
|
-
* whole path is exercisable against an in-memory `FirestoreDocs` with no
|
|
976
|
-
* network and no API key — which is the only way the conversion table gets
|
|
977
|
-
* tested as a table. */
|
|
978
|
-
async function publishApp(opts = {}) {
|
|
979
|
-
const root = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
|
|
980
|
-
const handle = require_store.firestoreHandle();
|
|
981
|
-
if (!handle) return {
|
|
982
|
-
ok: false,
|
|
983
|
-
partial: false,
|
|
984
|
-
problems: ["publish needs a signed-in Firestore session: connect remote-host first. Publishing writes the app's roster and configuration as the app's owner, which is an authenticated write."]
|
|
985
|
-
};
|
|
986
|
-
const authored = await readAuthored(root);
|
|
987
|
-
if (!authored.ok) return {
|
|
988
|
-
...authored,
|
|
989
|
-
partial: false
|
|
990
|
-
};
|
|
991
|
-
const collections = await sharedCollections(opts, root);
|
|
992
|
-
const problems = declarationProblems(authored.app, collections, handle);
|
|
993
|
-
if (problems.length > 0) return {
|
|
994
|
-
ok: false,
|
|
995
|
-
partial: false,
|
|
996
|
-
problems
|
|
997
|
-
};
|
|
998
|
-
const issues = await recordProblems(collections, {
|
|
999
|
-
...opts,
|
|
1000
|
-
workspaceRoot: root
|
|
1001
|
-
});
|
|
1002
|
-
if (issues.unreadable.length > 0) return {
|
|
1003
|
-
ok: false,
|
|
1004
|
-
partial: false,
|
|
1005
|
-
problems: [...issues.unreadable, "publish stopped: the live records could not be read, so nothing checked whether the schemas about to be published still fit them. This is not something `confirm` overrides — confirming means accepting a known breakage, and here there is no reading at all. Fix the access (or the connection) and publish again."]
|
|
1006
|
-
};
|
|
1007
|
-
if (issues.records > 0 && opts.confirm !== true) return {
|
|
1008
|
-
ok: false,
|
|
1009
|
-
partial: false,
|
|
1010
|
-
problems: [...issues.lines, "publish stopped: these records are live and members are reading them. Migrate them first, or re-run with confirm to publish the schema anyway and repair the records afterwards."]
|
|
1011
|
-
};
|
|
1012
|
-
return writePublished(authored.app, collections, handle, opts, root, issues);
|
|
1013
|
-
}
|
|
1014
|
-
/** The write half: stamp, project, and put the documents in the order the
|
|
1015
|
-
* rules require. Split from the gate above so neither half hides the other —
|
|
1016
|
-
* everything up to here can refuse, and nothing from here on does. */
|
|
1017
|
-
async function writePublished(authored, collections, handle, opts, root, issues) {
|
|
1018
|
-
const { aid } = authored;
|
|
1019
|
-
let existing;
|
|
1020
|
-
try {
|
|
1021
|
-
existing = await handle.docs.get(APPS_COLLECTION, aid);
|
|
1022
|
-
} catch (err) {
|
|
1023
|
-
return {
|
|
1024
|
-
ok: false,
|
|
1025
|
-
partial: false,
|
|
1026
|
-
problems: [`publish failed while reading the current app document (apps/${aid}): ${err instanceof Error ? err.message : String(err)}`, "Nothing was written. Publishing again is safe — this read only decides whether the app is created or updated."]
|
|
1027
|
-
};
|
|
1028
|
-
}
|
|
1029
|
-
const stampSource = await (opts.resolveCommit ?? gitStamp)(root);
|
|
1030
|
-
const stamp = {
|
|
1031
|
-
uid: handle.uid,
|
|
1032
|
-
email: handle.email,
|
|
1033
|
-
publishedAt: (opts.now ?? Date.now)(),
|
|
1034
|
-
commit: stampSource.commit
|
|
1035
|
-
};
|
|
1036
|
-
const existingApp = require_dist.isRecord(existing) ? existing : null;
|
|
1037
|
-
const published = projectApp(authored, schemasOf(collections), stamp, existingApp);
|
|
1038
|
-
if (stampSource.dirty === true) published.app.publishedDirty = true;
|
|
1039
|
-
const written = await writeDocuments(handle, aid, published);
|
|
1040
|
-
if (written !== null) return written;
|
|
1041
|
-
return {
|
|
1042
|
-
ok: true,
|
|
1043
|
-
aid,
|
|
1044
|
-
cids: published.schemas.map((entry) => entry.cid),
|
|
1045
|
-
created: existingApp === null,
|
|
1046
|
-
commit: stamp.commit,
|
|
1047
|
-
dirty: stampSource.dirty === true,
|
|
1048
|
-
recordIssues: issues.records,
|
|
1049
|
-
recordIssuesCapped: issues.capped,
|
|
1050
|
-
published
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
//#endregion
|
|
1054
704
|
//#region src/collection/server/skillAssets.ts
|
|
1055
705
|
/** Read a collection's custom-view HTML, path-safely. `viewFile` is a
|
|
1056
706
|
* schema-validated `views/*.html` path, resolved with realpath containment.
|
|
@@ -1075,13 +725,13 @@ async function readCustomViewHtml(collection, viewFile, opts = {}) {
|
|
|
1075
725
|
* `readCustomViewHtml`. Used by both `readCustomViewHtml` and
|
|
1076
726
|
* `readCustomViewI18n` so the two stay in lockstep. */
|
|
1077
727
|
async function readSourceAwareFile(collection, relPath, opts) {
|
|
1078
|
-
const safeSlug =
|
|
728
|
+
const safeSlug = require_discovery.safeSlugName(collection.slug);
|
|
1079
729
|
if (safeSlug === null) return null;
|
|
1080
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1081
|
-
const staging = collection.source === "project" ?
|
|
730
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
731
|
+
const staging = collection.source === "project" ? require_discovery.stagingSkillDir(workspaceRoot, safeSlug) : null;
|
|
1082
732
|
const bases = staging === null ? [collection.skillDir] : [staging, collection.skillDir];
|
|
1083
733
|
for (const base of bases) {
|
|
1084
|
-
const resolved =
|
|
734
|
+
const resolved = require_discovery.resolveTemplatePath(base, relPath);
|
|
1085
735
|
if (resolved === null) continue;
|
|
1086
736
|
try {
|
|
1087
737
|
return await (0, node_fs_promises.readFile)(resolved, "utf-8");
|
|
@@ -1139,9 +789,9 @@ async function readCustomViewI18n(collection, i18nFile, locale, opts = {}) {
|
|
|
1139
789
|
* the file contents, or null when the path escapes the skill dir, the
|
|
1140
790
|
* resolved target isn't a regular file, or the read fails. */
|
|
1141
791
|
async function readSkillTemplate(skillDir, templateRelPath) {
|
|
1142
|
-
const resolved =
|
|
792
|
+
const resolved = require_discovery.resolveTemplatePath(skillDir, templateRelPath);
|
|
1143
793
|
if (resolved === null) return null;
|
|
1144
|
-
if (!await
|
|
794
|
+
if (!await require_discovery.isRegularFile(resolved)) return null;
|
|
1145
795
|
try {
|
|
1146
796
|
return await (0, node_fs_promises.readFile)(resolved, "utf-8");
|
|
1147
797
|
} catch {
|
|
@@ -1283,11 +933,11 @@ function absentReferencedColumns(rows, query) {
|
|
|
1283
933
|
/** Run a validated query over enriched collection rows. */
|
|
1284
934
|
async function runQueryOverRows(rows, query) {
|
|
1285
935
|
if (rows.length === 0) return emptyCollectionResult(query);
|
|
1286
|
-
await (0, node_fs_promises.mkdir)(
|
|
936
|
+
await (0, node_fs_promises.mkdir)(require_discovery.cacheDir(), {
|
|
1287
937
|
recursive: true,
|
|
1288
938
|
mode: 448
|
|
1289
939
|
});
|
|
1290
|
-
const jsonlPath = node_path.default.join(
|
|
940
|
+
const jsonlPath = node_path.default.join(require_discovery.cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
|
|
1291
941
|
try {
|
|
1292
942
|
const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
|
|
1293
943
|
const handle = await (0, node_fs_promises.open)(jsonlPath, "wx", 384);
|
|
@@ -1303,115 +953,347 @@ async function runQueryOverRows(rows, query) {
|
|
|
1303
953
|
} finally {
|
|
1304
954
|
await handle.close();
|
|
1305
955
|
}
|
|
1306
|
-
const { sql, params } =
|
|
1307
|
-
return (await
|
|
956
|
+
const { sql, params } = require_discovery.compileJsonlQuery(query);
|
|
957
|
+
return (await require_discovery.queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, require_discovery.normalizeCsvValue(value)])));
|
|
1308
958
|
} finally {
|
|
1309
959
|
await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
|
|
1310
|
-
if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT")
|
|
960
|
+
if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_discovery.log.warn("collections", "temp JSONL cleanup failed", {
|
|
1311
961
|
path: jsonlPath,
|
|
1312
962
|
error: String(err)
|
|
1313
963
|
});
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region src/collection/server/derive.ts
|
|
969
|
+
async function loadTarget(slug, opts) {
|
|
970
|
+
const target = await require_discovery.loadCollection(slug, opts);
|
|
971
|
+
if (!target) return null;
|
|
972
|
+
const items = await require_discovery.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
|
|
973
|
+
const byId = {};
|
|
974
|
+
for (const item of items) {
|
|
975
|
+
const itemId = item[target.schema.primaryKey];
|
|
976
|
+
if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
schema: target.schema,
|
|
980
|
+
byId
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
/** Load every ref/embed target and backlink source collection once.
|
|
984
|
+
* Unknown / unloadable targets are simply absent — downstream derefs
|
|
985
|
+
* resolve to null (em-dash) and backlinks to an empty row set, the
|
|
986
|
+
* same fail-soft the UI renders. */
|
|
987
|
+
async function loadLinkedTargets(schema, opts) {
|
|
988
|
+
const slugs = [.../* @__PURE__ */ new Set([
|
|
989
|
+
...require_promptSafety.uniqueRefTargets(schema),
|
|
990
|
+
...require_promptSafety.uniqueEmbedTargets(schema),
|
|
991
|
+
...require_promptSafety.uniqueBacklinkSources(schema)
|
|
992
|
+
])];
|
|
993
|
+
const loaded = {};
|
|
994
|
+
for (const slug of slugs) {
|
|
995
|
+
const target = await loadTarget(slug, opts);
|
|
996
|
+
if (target) loaded[slug] = target;
|
|
997
|
+
}
|
|
998
|
+
return loaded;
|
|
999
|
+
}
|
|
1000
|
+
function toRefRecords(linked) {
|
|
1001
|
+
return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
|
|
1002
|
+
}
|
|
1003
|
+
/** The matching source rows for one `backlinks` field, projected to the
|
|
1004
|
+
* source primaryKey + `display` columns — so getItems on a
|
|
1005
|
+
* heavily-referenced record stays a summary, not a dump of the source
|
|
1006
|
+
* collection. Missing source ⇒ [] (fail-soft). The rows come from the
|
|
1007
|
+
* DERIVED source records (`byId`), so `display`/`filter` on a derived
|
|
1008
|
+
* source column (an invoice `total`) works. */
|
|
1009
|
+
function projectBacklinks(field, schema, enriched, linked) {
|
|
1010
|
+
const source = linked[field.from];
|
|
1011
|
+
if (!source) return [];
|
|
1012
|
+
const selfId = require_itemId.fieldText(enriched[schema.primaryKey]);
|
|
1013
|
+
return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
|
|
1014
|
+
}
|
|
1015
|
+
/** Project the computed (never-stored) field kinds onto one derived
|
|
1016
|
+
* record: `toggle` → boolean off its enum, `embed` → the target record
|
|
1017
|
+
* (fixed `id` or per-record `idField`), or null when missing,
|
|
1018
|
+
* `backlinks` → the matching source rows (see `projectBacklinks`). */
|
|
1019
|
+
function projectComputed(schema, enriched, linked) {
|
|
1020
|
+
for (const [key, field] of Object.entries(schema.fields)) {
|
|
1021
|
+
if (field.type === "toggle" && field.field) enriched[key] = require_itemId.fieldText(enriched[field.field]) === field.onValue;
|
|
1022
|
+
if (field.type === "embed" && field.to) {
|
|
1023
|
+
const targetId = require_itemId.embedTargetId(field, enriched);
|
|
1024
|
+
const target = require_promptSafety.ownProp(linked, field.to);
|
|
1025
|
+
enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
|
|
1026
|
+
}
|
|
1027
|
+
if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
|
|
1028
|
+
}
|
|
1029
|
+
return enriched;
|
|
1030
|
+
}
|
|
1031
|
+
/** Resolve every rollup field onto a COPY of the record, BEFORE the
|
|
1032
|
+
* formula pass — a `derived` formula may reference rollup values as
|
|
1033
|
+
* plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
|
|
1034
|
+
* machinery as backlinks, collapsed to a number: an unresolvable source
|
|
1035
|
+
* is null (a formula reading it fails soft to em-dash); an empty match
|
|
1036
|
+
* set is a real 0. Returns `record` unchanged when the schema declares
|
|
1037
|
+
* no rollups. */
|
|
1038
|
+
function projectRollups(schema, record, linked) {
|
|
1039
|
+
let out = record;
|
|
1040
|
+
for (const [key, field] of Object.entries(schema.fields)) {
|
|
1041
|
+
if (field.type !== "rollup") continue;
|
|
1042
|
+
if (out === record) out = { ...record };
|
|
1043
|
+
const source = linked[field.from];
|
|
1044
|
+
const selfId = require_itemId.fieldText(record[schema.primaryKey]);
|
|
1045
|
+
out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
|
|
1046
|
+
}
|
|
1047
|
+
return out;
|
|
1048
|
+
}
|
|
1049
|
+
/** Enrich records with every host-computed field: derived formulas
|
|
1050
|
+
* evaluated (cross-collection derefs included), toggles projected,
|
|
1051
|
+
* embeds resolved. Loads each linked collection ONCE per call. Input
|
|
1052
|
+
* records are not mutated. */
|
|
1053
|
+
async function enrichItems(collection, items, opts = {}) {
|
|
1054
|
+
const { schema } = collection;
|
|
1055
|
+
const linked = await loadLinkedTargets(schema, opts);
|
|
1056
|
+
const refRecords = toRefRecords(linked);
|
|
1057
|
+
return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
|
|
1058
|
+
}
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/collection/server/queryRunner.ts
|
|
1061
|
+
async function runCollectionQuery(collection, query, opts = {}) {
|
|
1062
|
+
const store = require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
|
|
1063
|
+
if (store.query) return store.query(query);
|
|
1064
|
+
return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/collection/core/recordZ.ts
|
|
1068
|
+
/** The emptiness rule shared by `required` and the "only check present
|
|
1069
|
+
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
1070
|
+
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
1071
|
+
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
1072
|
+
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
1073
|
+
function enforcedProblem(key, spec, value) {
|
|
1074
|
+
const empty = isEmptyValue(value);
|
|
1075
|
+
if (spec.required && empty) return `missing required field '${key}'`;
|
|
1076
|
+
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
1080
|
+
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
1081
|
+
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
1082
|
+
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
1083
|
+
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
1084
|
+
* which the day view can't place). `string`-backed types accept anything
|
|
1085
|
+
* stringifiable; `ref` existence is out of scope. */
|
|
1086
|
+
function strictTypeProblem(key, spec, value) {
|
|
1087
|
+
switch (spec.type) {
|
|
1088
|
+
case "number":
|
|
1089
|
+
case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
1090
|
+
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
1091
|
+
case "date": return require_itemId.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
1092
|
+
case "datetime": return require_itemId.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
1093
|
+
default: return null;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
1097
|
+
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
1098
|
+
* First row problem wins, prefixed with the row number so the fix is
|
|
1099
|
+
* locatable. */
|
|
1100
|
+
function strictTableProblem(key, spec, value) {
|
|
1101
|
+
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
1102
|
+
for (let index = 0; index < value.length; index++) {
|
|
1103
|
+
const row = value[index];
|
|
1104
|
+
if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
1105
|
+
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
1106
|
+
const subValue = row[subKey];
|
|
1107
|
+
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
1108
|
+
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
/** First problem for one field's stored value under `tier`, or null.
|
|
1114
|
+
* Enforced checks always run (and their messages never vary by tier — the
|
|
1115
|
+
* scan and the write gate must agree on them); strict adds the per-type
|
|
1116
|
+
* layer on present values only. */
|
|
1117
|
+
function recordFieldProblem(key, spec, value, tier) {
|
|
1118
|
+
const enforced = enforcedProblem(key, spec, value);
|
|
1119
|
+
if (enforced || tier === "enforced") return enforced;
|
|
1120
|
+
if (isEmptyValue(value)) return null;
|
|
1121
|
+
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
1122
|
+
return strictTypeProblem(key, spec, value);
|
|
1123
|
+
}
|
|
1124
|
+
var compiled = /* @__PURE__ */ new WeakMap();
|
|
1125
|
+
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
1126
|
+
* Loose object: unknown keys are allowed and any declared key may be
|
|
1127
|
+
* absent (records are user files, not parse-and-rewrite targets —
|
|
1128
|
+
* callers validate, they never persist the parse output). The checks run
|
|
1129
|
+
* as ONE object-level refine iterating fields in declaration order —
|
|
1130
|
+
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
1131
|
+
* must still reach the required check", and the single loop keeps the
|
|
1132
|
+
* first reported issue identical to the historical first-problem-wins
|
|
1133
|
+
* contract. */
|
|
1134
|
+
function compileRecordZ(schema, tier) {
|
|
1135
|
+
const cached = compiled.get(schema)?.[tier];
|
|
1136
|
+
if (cached) return cached;
|
|
1137
|
+
const stored = Object.entries(schema.fields).filter(([, spec]) => !require_itemId.COMPUTED_TYPES.has(spec.type));
|
|
1138
|
+
const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
|
|
1139
|
+
for (const [key, spec] of stored) {
|
|
1140
|
+
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
1141
|
+
if (problem) ctx.addIssue({
|
|
1142
|
+
code: "custom",
|
|
1143
|
+
message: problem,
|
|
1144
|
+
path: [key]
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
const entry = compiled.get(schema) ?? {};
|
|
1149
|
+
entry[tier] = validator;
|
|
1150
|
+
compiled.set(schema, entry);
|
|
1151
|
+
return validator;
|
|
1152
|
+
}
|
|
1153
|
+
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
1154
|
+
* issue per record keeps the report short and the fix obvious (the
|
|
1155
|
+
* historical contract of `validateRecordObject`). */
|
|
1156
|
+
function firstRecordProblem(record, schema, tier) {
|
|
1157
|
+
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
1158
|
+
if (result.success) return null;
|
|
1159
|
+
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
1316
1160
|
}
|
|
1317
1161
|
//#endregion
|
|
1318
|
-
//#region src/collection/server/
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1162
|
+
//#region src/collection/server/validate.ts
|
|
1163
|
+
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
1164
|
+
* because a caller that REPORTS a count has to know the count is a floor —
|
|
1165
|
+
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
1166
|
+
var MAX_RECORD_ISSUES = 25;
|
|
1167
|
+
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
1168
|
+
* at all.
|
|
1169
|
+
*
|
|
1170
|
+
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
1171
|
+
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
1172
|
+
* direction that matters: `publish` lets the user override invalid records,
|
|
1173
|
+
* and overriding this one would mean publishing without ever having looked. */
|
|
1174
|
+
var STORE_UNREADABLE = "(store)";
|
|
1175
|
+
var MAX_ISSUES = 25;
|
|
1176
|
+
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
1177
|
+
* ones that won't load or violate the schema. An empty list means every
|
|
1178
|
+
* record is fine. */
|
|
1179
|
+
/** List entries under the data dir, guarding realpath containment (against a
|
|
1180
|
+
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
1181
|
+
* missing dir as empty while surfacing real I/O faults. */
|
|
1182
|
+
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
1183
|
+
if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) {
|
|
1184
|
+
require_discovery.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
1185
|
+
return [];
|
|
1327
1186
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
/** Load every ref/embed target and backlink source collection once.
|
|
1334
|
-
* Unknown / unloadable targets are simply absent — downstream derefs
|
|
1335
|
-
* resolve to null (em-dash) and backlinks to an empty row set, the
|
|
1336
|
-
* same fail-soft the UI renders. */
|
|
1337
|
-
async function loadLinkedTargets(schema, opts) {
|
|
1338
|
-
const slugs = [.../* @__PURE__ */ new Set([
|
|
1339
|
-
...require_promptSafety.uniqueRefTargets(schema),
|
|
1340
|
-
...require_promptSafety.uniqueEmbedTargets(schema),
|
|
1341
|
-
...require_promptSafety.uniqueBacklinkSources(schema)
|
|
1342
|
-
])];
|
|
1343
|
-
const loaded = {};
|
|
1344
|
-
for (const slug of slugs) {
|
|
1345
|
-
const target = await loadTarget(slug, opts);
|
|
1346
|
-
if (target) loaded[slug] = target;
|
|
1187
|
+
try {
|
|
1188
|
+
return await (0, node_fs_promises.readdir)(dataDir);
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
1191
|
+
throw err;
|
|
1347
1192
|
}
|
|
1348
|
-
return loaded;
|
|
1349
1193
|
}
|
|
1350
|
-
function
|
|
1351
|
-
|
|
1194
|
+
async function validateCollectionRecords(collection, opts = {}) {
|
|
1195
|
+
if (collection.schema.dataSource !== void 0) return [];
|
|
1196
|
+
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
1197
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
1198
|
+
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
1199
|
+
const issues = [];
|
|
1200
|
+
for (const name of entries.sort()) {
|
|
1201
|
+
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
1202
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
1203
|
+
const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
|
|
1204
|
+
if (issue) issues.push(issue);
|
|
1205
|
+
}
|
|
1206
|
+
return issues;
|
|
1352
1207
|
}
|
|
1353
|
-
/**
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
function
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1208
|
+
/** Store-backed twin of the file scan: list every record through the
|
|
1209
|
+
* collection's store and lint it with the same "strict" report-only tier.
|
|
1210
|
+
* A row the store can't even parse is invisible here (the store skips
|
|
1211
|
+
* it), so the read/parse classifications of the file scan don't apply —
|
|
1212
|
+
* schema violations are what this catches. `file` carries the record id
|
|
1213
|
+
* (there is no per-record filename). */
|
|
1214
|
+
async function validateStoreRecords(collection, opts) {
|
|
1215
|
+
let items;
|
|
1216
|
+
try {
|
|
1217
|
+
items = await require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
return [{
|
|
1220
|
+
file: STORE_UNREADABLE,
|
|
1221
|
+
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
1222
|
+
}];
|
|
1223
|
+
}
|
|
1224
|
+
const issues = [];
|
|
1225
|
+
for (const item of items) {
|
|
1226
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
1227
|
+
const itemId = require_itemId.fieldText(item[collection.schema.primaryKey]);
|
|
1228
|
+
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
1229
|
+
if (problem) issues.push({
|
|
1230
|
+
file: itemId,
|
|
1231
|
+
problem
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
return issues;
|
|
1364
1235
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
}
|
|
1377
|
-
if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
|
|
1236
|
+
async function readRecordText(fullPath, name) {
|
|
1237
|
+
try {
|
|
1238
|
+
if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
|
|
1239
|
+
file: name,
|
|
1240
|
+
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
1241
|
+
};
|
|
1242
|
+
return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
|
|
1243
|
+
} catch {
|
|
1244
|
+
return {
|
|
1245
|
+
file: name,
|
|
1246
|
+
problem: "could not be read — skipped, won't appear"
|
|
1247
|
+
};
|
|
1378
1248
|
}
|
|
1379
|
-
return enriched;
|
|
1380
1249
|
}
|
|
1381
|
-
/**
|
|
1382
|
-
*
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
const selfId = require_calendarGrid.fieldText(record[schema.primaryKey]);
|
|
1395
|
-
out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
|
|
1250
|
+
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
1251
|
+
* schema violation, or null when it's fine. */
|
|
1252
|
+
async function inspectRecord(fullPath, name, schema) {
|
|
1253
|
+
const read = await readRecordText(fullPath, name);
|
|
1254
|
+
if ("problem" in read) return read;
|
|
1255
|
+
let parsed;
|
|
1256
|
+
try {
|
|
1257
|
+
parsed = JSON.parse(read.raw);
|
|
1258
|
+
} catch (err) {
|
|
1259
|
+
return {
|
|
1260
|
+
file: name,
|
|
1261
|
+
problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
|
|
1262
|
+
};
|
|
1396
1263
|
}
|
|
1397
|
-
return
|
|
1264
|
+
if (!require_dist.isRecord(parsed)) return {
|
|
1265
|
+
file: name,
|
|
1266
|
+
problem: "not a JSON object — skipped, won't appear"
|
|
1267
|
+
};
|
|
1268
|
+
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
1269
|
+
return problem ? {
|
|
1270
|
+
file: name,
|
|
1271
|
+
problem
|
|
1272
|
+
} : null;
|
|
1398
1273
|
}
|
|
1399
|
-
/**
|
|
1400
|
-
*
|
|
1401
|
-
*
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
|
|
1274
|
+
/** What a non-string primary key actually is, for the error message. Names the
|
|
1275
|
+
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
1276
|
+
* nothing about what is wrong. */
|
|
1277
|
+
function describeIdType(value) {
|
|
1278
|
+
if (value === null) return "null";
|
|
1279
|
+
if (value === void 0) return "missing";
|
|
1280
|
+
if (Array.isArray(value)) return "an array";
|
|
1281
|
+
return `a ${typeof value}`;
|
|
1408
1282
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1283
|
+
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
1284
|
+
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
1285
|
+
* tiers), or null when it's fine. One issue per record keeps the report
|
|
1286
|
+
* short and the fix obvious. Pure + exported so write paths
|
|
1287
|
+
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
1288
|
+
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
1289
|
+
* would be) stored under. The default `"enforced"` tier keeps every
|
|
1290
|
+
* write gate on the historical three checks; only pass `"strict"` from
|
|
1291
|
+
* report-only surfaces. */
|
|
1292
|
+
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
1293
|
+
const idValue = record[schema.primaryKey];
|
|
1294
|
+
if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
1295
|
+
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
1296
|
+
return firstRecordProblem(record, schema, tier);
|
|
1415
1297
|
}
|
|
1416
1298
|
//#endregion
|
|
1417
1299
|
//#region src/collection/server/mutate.ts
|
|
@@ -1438,7 +1320,7 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
|
|
|
1438
1320
|
status: "invalid-params",
|
|
1439
1321
|
problem: paramProblem
|
|
1440
1322
|
};
|
|
1441
|
-
const store =
|
|
1323
|
+
const store = require_discovery.storeFor(collection, opts);
|
|
1442
1324
|
if (!store.write) return {
|
|
1443
1325
|
ok: false,
|
|
1444
1326
|
status: "write-refused",
|
|
@@ -1457,11 +1339,11 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
|
|
|
1457
1339
|
};
|
|
1458
1340
|
const stored = Object.entries(existing).filter(([key]) => {
|
|
1459
1341
|
const spec = collection.schema.fields[key];
|
|
1460
|
-
return !spec || !
|
|
1342
|
+
return !spec || !require_itemId.COMPUTED_TYPES.has(spec.type);
|
|
1461
1343
|
});
|
|
1462
1344
|
const merged = {
|
|
1463
1345
|
...Object.fromEntries(stored),
|
|
1464
|
-
...
|
|
1346
|
+
...require_discovery.resolveMutateSet(action.set, params)
|
|
1465
1347
|
};
|
|
1466
1348
|
const invalid = validateRecordObject(merged, itemId, collection.schema);
|
|
1467
1349
|
if (invalid) return {
|
|
@@ -1519,10 +1401,10 @@ function schemaRelations(schema) {
|
|
|
1519
1401
|
* Fail-soft: a missing dir or a dataDir escaping the workspace via
|
|
1520
1402
|
* symlink counts 0. */
|
|
1521
1403
|
async function countRecordFiles(dataDir, workspaceRoot) {
|
|
1522
|
-
if (!
|
|
1404
|
+
if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) return 0;
|
|
1523
1405
|
try {
|
|
1524
1406
|
const names = (await (0, node_fs_promises.readdir)(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
|
|
1525
|
-
return (await Promise.all(names.map((name) =>
|
|
1407
|
+
return (await Promise.all(names.map((name) => require_discovery.isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
|
|
1526
1408
|
} catch {
|
|
1527
1409
|
return 0;
|
|
1528
1410
|
}
|
|
@@ -1534,10 +1416,10 @@ async function countRecordFiles(dataDir, workspaceRoot) {
|
|
|
1534
1416
|
* `countRecordFiles` (an unreadable file / missing engine must not
|
|
1535
1417
|
* break the whole ontology). */
|
|
1536
1418
|
async function countRecords(collection, workspaceRoot) {
|
|
1537
|
-
if (
|
|
1538
|
-
return (await
|
|
1419
|
+
if (require_itemId.storageKindFor(collection.schema) !== "file") try {
|
|
1420
|
+
return (await require_discovery.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
|
|
1539
1421
|
} catch (err) {
|
|
1540
|
-
if (
|
|
1422
|
+
if (require_discovery.isBackendUnavailable(err)) return null;
|
|
1541
1423
|
return 0;
|
|
1542
1424
|
}
|
|
1543
1425
|
return countRecordFiles(collection.dataDir, workspaceRoot);
|
|
@@ -1558,8 +1440,8 @@ async function toOntologyEntry(collection, workspaceRoot) {
|
|
|
1558
1440
|
* discovery's order), each with its outbound relations and a
|
|
1559
1441
|
* readdir-cheap record count — fine to call on demand. */
|
|
1560
1442
|
async function buildWorkspaceOntology(opts = {}) {
|
|
1561
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1562
|
-
const collections = await
|
|
1443
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
1444
|
+
const collections = await require_discovery.discoverCollections(opts);
|
|
1563
1445
|
return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
|
|
1564
1446
|
}
|
|
1565
1447
|
//#endregion
|
|
@@ -1579,7 +1461,7 @@ function buildRecordsById(items, primaryKey) {
|
|
|
1579
1461
|
* ties pick a different record — and thus a different icon — between
|
|
1580
1462
|
* reconciles. A stable id sort pins one answer. */
|
|
1581
1463
|
function sortByPrimaryKey(items, primaryKey) {
|
|
1582
|
-
return [...items].sort((left, right) =>
|
|
1464
|
+
return [...items].sort((left, right) => require_itemId.fieldText(left[primaryKey]).localeCompare(require_itemId.fieldText(right[primaryKey])));
|
|
1583
1465
|
}
|
|
1584
1466
|
/** Compute the effective launcher icon for `collection`: its static
|
|
1585
1467
|
* `schema.icon` when it declares no `dynamicIcon`, else the icon
|
|
@@ -1593,15 +1475,15 @@ async function computeCollectionIcon(collection, opts = {}) {
|
|
|
1593
1475
|
const spec = schema.dynamicIcon;
|
|
1594
1476
|
if (!spec) return schema.icon;
|
|
1595
1477
|
try {
|
|
1596
|
-
const source = await
|
|
1478
|
+
const source = await require_discovery.loadCollection(spec.source.collection, opts);
|
|
1597
1479
|
if (!source) return spec.fallback ?? schema.icon;
|
|
1598
|
-
const ordered = sortByPrimaryKey(await
|
|
1480
|
+
const ordered = sortByPrimaryKey(await require_discovery.storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
|
|
1599
1481
|
const orderBy = spec.source.orderBy ?? require_promptSafety.firstDateField(source.schema);
|
|
1600
1482
|
const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
|
|
1601
1483
|
const record = require_promptSafety.selectDynamicRecord(ordered, spec.source, orderBy, recordsById);
|
|
1602
1484
|
return require_promptSafety.resolveIcon(record, spec, schema.icon, recordsById);
|
|
1603
1485
|
} catch (err) {
|
|
1604
|
-
|
|
1486
|
+
require_discovery.log.warn("collections", "dynamic icon compute failed, falling back", {
|
|
1605
1487
|
slug: collection.slug,
|
|
1606
1488
|
source: spec.source.collection,
|
|
1607
1489
|
error: String(err)
|
|
@@ -1716,7 +1598,7 @@ function successorId(sourceId, next) {
|
|
|
1716
1598
|
function matchesWhen(when, schema, item) {
|
|
1717
1599
|
if (when) {
|
|
1718
1600
|
const raw = item[when.field];
|
|
1719
|
-
const text =
|
|
1601
|
+
const text = require_itemId.fieldTextOrNull(raw);
|
|
1720
1602
|
return text !== null && when.in.includes(text);
|
|
1721
1603
|
}
|
|
1722
1604
|
return require_promptSafety.itemIsDone(schema, item);
|
|
@@ -1728,10 +1610,10 @@ function matchesWhen(when, schema, item) {
|
|
|
1728
1610
|
* Discovery rejects a map that doesn't cover the enum's values, so null
|
|
1729
1611
|
* here means a record that predates a map/enum edit. */
|
|
1730
1612
|
function resolveEvery(every, sourceItem) {
|
|
1731
|
-
if (!
|
|
1613
|
+
if (!require_itemId.isFieldDrivenEvery(every)) return every;
|
|
1732
1614
|
const raw = sourceItem[every.fromField];
|
|
1733
1615
|
if (raw === void 0 || raw === null || raw === "") return null;
|
|
1734
|
-
const key =
|
|
1616
|
+
const key = require_itemId.fieldTextOrNull(raw);
|
|
1735
1617
|
if (key === null || !Object.hasOwn(every.map, key)) return null;
|
|
1736
1618
|
return every.map[key] ?? null;
|
|
1737
1619
|
}
|
|
@@ -1763,15 +1645,15 @@ function computeSuccessor(schema, sourceItem, sourceId) {
|
|
|
1763
1645
|
* predates a map/enum edit — discovery rejects this statically otherwise). */
|
|
1764
1646
|
function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
|
|
1765
1647
|
if (parseCivil(sourceItem[triggerField]) === null) {
|
|
1766
|
-
|
|
1648
|
+
require_discovery.log.warn("collections", "spawn skipped: source trigger date unparseable", {
|
|
1767
1649
|
slug,
|
|
1768
1650
|
sourceId,
|
|
1769
1651
|
triggerField
|
|
1770
1652
|
});
|
|
1771
1653
|
return;
|
|
1772
1654
|
}
|
|
1773
|
-
const fromField =
|
|
1774
|
-
|
|
1655
|
+
const fromField = require_itemId.isFieldDrivenEvery(every) ? every.fromField : void 0;
|
|
1656
|
+
require_discovery.log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
|
|
1775
1657
|
slug,
|
|
1776
1658
|
sourceId,
|
|
1777
1659
|
fromField,
|
|
@@ -1794,7 +1676,7 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
|
|
|
1794
1676
|
return;
|
|
1795
1677
|
}
|
|
1796
1678
|
if (matchesWhen(spawn.when, schema, computed.record)) {
|
|
1797
|
-
|
|
1679
|
+
require_discovery.log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
|
|
1798
1680
|
slug,
|
|
1799
1681
|
sourceId,
|
|
1800
1682
|
successorId: computed.id
|
|
@@ -1802,28 +1684,28 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
|
|
|
1802
1684
|
return;
|
|
1803
1685
|
}
|
|
1804
1686
|
try {
|
|
1805
|
-
const { write } =
|
|
1687
|
+
const { write } = require_discovery.storeFor(collection, ioOpts);
|
|
1806
1688
|
if (!write) {
|
|
1807
|
-
|
|
1689
|
+
require_discovery.log.warn("collections", "spawn skipped: collection store is read-only", {
|
|
1808
1690
|
slug,
|
|
1809
1691
|
sourceId
|
|
1810
1692
|
});
|
|
1811
1693
|
return;
|
|
1812
1694
|
}
|
|
1813
1695
|
const result = await write(computed.id, computed.record, { refuseOverwrite: true });
|
|
1814
|
-
if (result.kind === "ok")
|
|
1696
|
+
if (result.kind === "ok") require_discovery.log.info("collections", "spawned successor", {
|
|
1815
1697
|
slug,
|
|
1816
1698
|
sourceId,
|
|
1817
1699
|
successorId: computed.id
|
|
1818
1700
|
});
|
|
1819
|
-
else if (result.kind !== "conflict")
|
|
1701
|
+
else if (result.kind !== "conflict") require_discovery.log.warn("collections", "spawn write failed", {
|
|
1820
1702
|
slug,
|
|
1821
1703
|
sourceId,
|
|
1822
1704
|
successorId: computed.id,
|
|
1823
1705
|
kind: result.kind
|
|
1824
1706
|
});
|
|
1825
1707
|
} catch (err) {
|
|
1826
|
-
|
|
1708
|
+
require_discovery.log.warn("collections", "spawn write threw", {
|
|
1827
1709
|
slug,
|
|
1828
1710
|
sourceId,
|
|
1829
1711
|
successorId: computed.id,
|
|
@@ -1862,7 +1744,7 @@ function todayStamp() {
|
|
|
1862
1744
|
/** Every directory the delete will touch must resolve under the
|
|
1863
1745
|
* workspace root — guards against a symlinked ancestor escaping it. */
|
|
1864
1746
|
function deleteTargets(collection, workspaceRoot) {
|
|
1865
|
-
const staging =
|
|
1747
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1866
1748
|
return [
|
|
1867
1749
|
...staging === null ? [] : [staging],
|
|
1868
1750
|
collection.skillDir,
|
|
@@ -1974,12 +1856,12 @@ function recordLocationLabel(schema) {
|
|
|
1974
1856
|
}
|
|
1975
1857
|
/** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
|
|
1976
1858
|
async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
1977
|
-
const staging =
|
|
1859
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1978
1860
|
const skillSrc = staging !== null && await pathExists(staging) ? staging : collection.skillDir;
|
|
1979
1861
|
await (0, node_fs_promises.cp)(skillSrc, node_path.default.join(archiveDir, "skill"), { recursive: true });
|
|
1980
1862
|
if (await pathExists(collection.dataDir)) await (0, node_fs_promises.cp)(collection.dataDir, node_path.default.join(archiveDir, "records"), { recursive: true });
|
|
1981
1863
|
if (collection.storageFile !== void 0 && await pathExists(collection.storageFile)) {
|
|
1982
|
-
const checkpointed = await
|
|
1864
|
+
const checkpointed = await require_discovery.checkpointSqliteDatabase(collection.storageFile);
|
|
1983
1865
|
await (0, node_fs_promises.cp)(collection.storageFile, node_path.default.join(archiveDir, node_path.default.basename(collection.storageFile)));
|
|
1984
1866
|
if (!checkpointed) for (const suffix of [
|
|
1985
1867
|
"-wal",
|
|
@@ -1996,7 +1878,7 @@ async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
|
1996
1878
|
* is a no-op; the now-empty data parent (`data/<slug>/` after its
|
|
1997
1879
|
* `items/` is gone) is swept too, but only when empty. */
|
|
1998
1880
|
async function removeLocations(collection, workspaceRoot) {
|
|
1999
|
-
const staging =
|
|
1881
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
|
|
2000
1882
|
if (staging !== null) await (0, node_fs_promises.rm)(staging, {
|
|
2001
1883
|
recursive: true,
|
|
2002
1884
|
force: true
|
|
@@ -2020,24 +1902,24 @@ async function removeLocations(collection, workspaceRoot) {
|
|
|
2020
1902
|
}
|
|
2021
1903
|
async function deleteCollection(collection, opts = {}) {
|
|
2022
1904
|
const { slug } = collection;
|
|
2023
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1905
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
2024
1906
|
if (collection.source === "user") return {
|
|
2025
1907
|
kind: "user-scope",
|
|
2026
1908
|
slug
|
|
2027
1909
|
};
|
|
2028
|
-
if (
|
|
1910
|
+
if (require_discovery.isPresetSlug(slug)) return {
|
|
2029
1911
|
kind: "preset",
|
|
2030
1912
|
slug
|
|
2031
1913
|
};
|
|
2032
1914
|
if (collection.schema.storage?.type === "firestore") {
|
|
2033
|
-
|
|
1915
|
+
require_discovery.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
|
|
2034
1916
|
return {
|
|
2035
1917
|
kind: "unsupported-backend",
|
|
2036
1918
|
slug
|
|
2037
1919
|
};
|
|
2038
1920
|
}
|
|
2039
1921
|
if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
|
|
2040
|
-
|
|
1922
|
+
require_discovery.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
|
|
2041
1923
|
slug,
|
|
2042
1924
|
dataDir: collection.dataDir
|
|
2043
1925
|
});
|
|
@@ -2046,19 +1928,19 @@ async function deleteCollection(collection, opts = {}) {
|
|
|
2046
1928
|
slug
|
|
2047
1929
|
};
|
|
2048
1930
|
}
|
|
2049
|
-
if (deleteTargets(collection, workspaceRoot).some((target) => !
|
|
2050
|
-
|
|
1931
|
+
if (deleteTargets(collection, workspaceRoot).some((target) => !require_discovery.isContainedInRoot(target, workspaceRoot))) {
|
|
1932
|
+
require_discovery.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
|
|
2051
1933
|
return {
|
|
2052
1934
|
kind: "path-escape",
|
|
2053
1935
|
slug
|
|
2054
1936
|
};
|
|
2055
1937
|
}
|
|
2056
|
-
const archiveRel = node_path.default.join(
|
|
1938
|
+
const archiveRel = node_path.default.join(require_discovery.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
|
|
2057
1939
|
const archiveDir$1 = node_path.default.join(workspaceRoot, archiveRel);
|
|
2058
1940
|
await (0, node_fs_promises.mkdir)(archiveDir$1, { recursive: true });
|
|
2059
1941
|
await writeArchive(collection, archiveDir$1, workspaceRoot);
|
|
2060
1942
|
await removeLocations(collection, workspaceRoot);
|
|
2061
|
-
|
|
1943
|
+
require_discovery.log.info("collections", "collection deleted + archived", {
|
|
2062
1944
|
slug,
|
|
2063
1945
|
archive: archiveRel
|
|
2064
1946
|
});
|
|
@@ -2088,7 +1970,7 @@ async function fileExists(target) {
|
|
|
2088
1970
|
* deletes agree on both layouts. */
|
|
2089
1971
|
async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
2090
1972
|
if (collection.source !== "project") return collection.skillDir;
|
|
2091
|
-
const staging =
|
|
1973
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
|
|
2092
1974
|
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) return staging;
|
|
2093
1975
|
return collection.skillDir;
|
|
2094
1976
|
}
|
|
@@ -2098,11 +1980,11 @@ async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
|
2098
1980
|
* project collection (no staging mirror) doesn't have an empty staging tree
|
|
2099
1981
|
* materialised by a side effect of the delete. */
|
|
2100
1982
|
async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
|
|
2101
|
-
const active = node_path.default.join(collection.skillDir,
|
|
1983
|
+
const active = node_path.default.join(collection.skillDir, require_discovery.SCHEMA_FILE);
|
|
2102
1984
|
if (collection.source !== "project") return [active];
|
|
2103
|
-
const staging =
|
|
1985
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
|
|
2104
1986
|
const targets = [];
|
|
2105
|
-
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging,
|
|
1987
|
+
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_discovery.SCHEMA_FILE));
|
|
2106
1988
|
targets.push(active);
|
|
2107
1989
|
return targets;
|
|
2108
1990
|
}
|
|
@@ -2121,7 +2003,7 @@ async function unlinkIfPresent(target) {
|
|
|
2121
2003
|
* preserved verbatim. */
|
|
2122
2004
|
async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
|
|
2123
2005
|
const base = await canonicalBase(collection, workspaceRoot, safeSlug);
|
|
2124
|
-
const canonical = node_path.default.join(base,
|
|
2006
|
+
const canonical = node_path.default.join(base, require_discovery.SCHEMA_FILE);
|
|
2125
2007
|
const parsed = JSON.parse(await (0, node_fs_promises.readFile)(canonical, "utf-8"));
|
|
2126
2008
|
const next = require_dist.isRecord(parsed) && require_dist.isUnknownArray(parsed.views) ? {
|
|
2127
2009
|
...parsed,
|
|
@@ -2135,8 +2017,8 @@ async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug
|
|
|
2135
2017
|
* refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
|
|
2136
2018
|
async function deleteCustomView(collection, viewId, opts = {}) {
|
|
2137
2019
|
if (collection.source === "user") return { kind: "user-scope" };
|
|
2138
|
-
if (
|
|
2139
|
-
const safeSlug =
|
|
2020
|
+
if (require_discovery.isPresetSlug(collection.slug)) return { kind: "preset" };
|
|
2021
|
+
const safeSlug = require_discovery.safeSlugName(collection.slug);
|
|
2140
2022
|
if (safeSlug === null) return {
|
|
2141
2023
|
kind: "unsafe-path",
|
|
2142
2024
|
viewId
|
|
@@ -2147,8 +2029,8 @@ async function deleteCustomView(collection, viewId, opts = {}) {
|
|
|
2147
2029
|
kind: "not-found",
|
|
2148
2030
|
viewId
|
|
2149
2031
|
};
|
|
2150
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
2151
|
-
const htmlPath =
|
|
2032
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
2033
|
+
const htmlPath = require_discovery.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
|
|
2152
2034
|
if (htmlPath === null) return {
|
|
2153
2035
|
kind: "unsafe-path",
|
|
2154
2036
|
viewId
|
|
@@ -2375,7 +2257,7 @@ var SCHEMA_DOCS_PROBE_SLUG = "_";
|
|
|
2375
2257
|
/** Resolve the workspace root the same way every collections call does:
|
|
2376
2258
|
* the injected override (tests) or the configured collection host. */
|
|
2377
2259
|
function resolveBase(deps) {
|
|
2378
|
-
return deps.workspaceRoot ??
|
|
2260
|
+
return deps.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
2379
2261
|
}
|
|
2380
2262
|
/** Where a collection skill is authored in THIS root, and therefore both which
|
|
2381
2263
|
* authoring guide `schemaDocs` serves and where `getSchema` / `putSchema`
|
|
@@ -2389,7 +2271,7 @@ function resolveBase(deps) {
|
|
|
2389
2271
|
* from there, so the tool would silently contradict its own documentation.
|
|
2390
2272
|
* Staged requires BOTH to agree; anything else is direct. */
|
|
2391
2273
|
function authoringTarget(deps, slug) {
|
|
2392
|
-
const stagingDir = deps.stagedSkillAuthoring === false ? null :
|
|
2274
|
+
const stagingDir = deps.stagedSkillAuthoring === false ? null : require_discovery.stagingSkillDir(resolveBase(deps), slug);
|
|
2393
2275
|
return {
|
|
2394
2276
|
variant: stagingDir === null ? "direct" : "staged",
|
|
2395
2277
|
stagingDir
|
|
@@ -2432,7 +2314,7 @@ async function recordIssuesWarning(collection, deps) {
|
|
|
2432
2314
|
return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
|
|
2433
2315
|
}
|
|
2434
2316
|
async function loadRequestedItems(collection, ids, deps) {
|
|
2435
|
-
const store =
|
|
2317
|
+
const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2436
2318
|
if (!ids) return {
|
|
2437
2319
|
items: await store.list(),
|
|
2438
2320
|
missing: []
|
|
@@ -2441,7 +2323,7 @@ async function loadRequestedItems(collection, ids, deps) {
|
|
|
2441
2323
|
const missing = [];
|
|
2442
2324
|
for (const recordId of ids) {
|
|
2443
2325
|
const item = await store.read(recordId).catch((err) => {
|
|
2444
|
-
if (
|
|
2326
|
+
if (require_discovery.isBackendUnavailable(err)) throw err;
|
|
2445
2327
|
return null;
|
|
2446
2328
|
});
|
|
2447
2329
|
if (item) items.push(item);
|
|
@@ -2472,7 +2354,7 @@ async function handleGetItems(collection, args, deps) {
|
|
|
2472
2354
|
function computedKeyProblem(record, schema) {
|
|
2473
2355
|
for (const key of Object.keys(record)) {
|
|
2474
2356
|
const spec = schema.fields[key];
|
|
2475
|
-
if (!spec || !
|
|
2357
|
+
if (!spec || !require_itemId.COMPUTED_TYPES.has(spec.type)) continue;
|
|
2476
2358
|
if (spec.type === "toggle" && spec.field) return `'${key}' is a toggle projection — write the enum field '${spec.field}' instead`;
|
|
2477
2359
|
return `'${key}' is ${{
|
|
2478
2360
|
derived: "derived",
|
|
@@ -2506,13 +2388,13 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
2506
2388
|
try {
|
|
2507
2389
|
existing = await store.read(itemId);
|
|
2508
2390
|
} catch (err) {
|
|
2509
|
-
if (
|
|
2391
|
+
if (require_discovery.isBackendUnavailable(err)) throw err;
|
|
2510
2392
|
return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
|
|
2511
2393
|
}
|
|
2512
2394
|
if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
|
|
2513
2395
|
const stored = Object.entries(existing).filter(([key]) => {
|
|
2514
2396
|
const spec = collection.schema.fields[key];
|
|
2515
|
-
return !spec || !
|
|
2397
|
+
return !spec || !require_itemId.COMPUTED_TYPES.has(spec.type);
|
|
2516
2398
|
});
|
|
2517
2399
|
return {
|
|
2518
2400
|
...Object.fromEntries(stored),
|
|
@@ -2521,7 +2403,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
2521
2403
|
}
|
|
2522
2404
|
async function putOneItem(collection, store, write, record, mode, deps) {
|
|
2523
2405
|
const { schema } = collection;
|
|
2524
|
-
const itemId =
|
|
2406
|
+
const itemId = require_discovery.resolveCreateItemId(schema, record);
|
|
2525
2407
|
const reject = (about, problem) => ({ rejected: {
|
|
2526
2408
|
id: require_promptSafety.defangForPrompt(about),
|
|
2527
2409
|
problem: require_promptSafety.defangForPrompt(problem)
|
|
@@ -2552,7 +2434,7 @@ async function putOneItem(collection, store, write, record, mode, deps) {
|
|
|
2552
2434
|
* `runCollectionQuery`, shared with the desktop custom view's `/query`
|
|
2553
2435
|
* route so the two surfaces can never drift. */
|
|
2554
2436
|
async function handleQueryItems(collection, queryArg, deps) {
|
|
2555
|
-
const parsed =
|
|
2437
|
+
const parsed = require_discovery.CollectionQueryZ.safeParse(queryArg);
|
|
2556
2438
|
if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${require_promptSafety.defangForPrompt(issue.message)}`).join("\n")}`;
|
|
2557
2439
|
const rows = await runCollectionQuery(collection, parsed.data, { workspaceRoot: deps.workspaceRoot });
|
|
2558
2440
|
return JSON.stringify({
|
|
@@ -2562,9 +2444,9 @@ async function handleQueryItems(collection, queryArg, deps) {
|
|
|
2562
2444
|
});
|
|
2563
2445
|
}
|
|
2564
2446
|
async function handlePutItems(collection, args, deps) {
|
|
2565
|
-
const store =
|
|
2447
|
+
const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2566
2448
|
const { write } = store;
|
|
2567
|
-
if (!write) return `manageCollection: ${
|
|
2449
|
+
if (!write) return `manageCollection: ${require_discovery.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
|
|
2568
2450
|
const written = [];
|
|
2569
2451
|
const rejected = [];
|
|
2570
2452
|
for (const record of args.items) {
|
|
@@ -2583,8 +2465,8 @@ async function handlePutItems(collection, args, deps) {
|
|
|
2583
2465
|
* absent `delete` IS the refusal), and the same per-id result shape so a
|
|
2584
2466
|
* partially-bad batch reports per id instead of failing whole. */
|
|
2585
2467
|
async function handleDeleteItems(collection, ids, deps) {
|
|
2586
|
-
const { delete: removeItem } =
|
|
2587
|
-
if (!removeItem) return `manageCollection: ${
|
|
2468
|
+
const { delete: removeItem } = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2469
|
+
if (!removeItem) return `manageCollection: ${require_discovery.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
|
|
2588
2470
|
const deleted = [];
|
|
2589
2471
|
const rejected = [];
|
|
2590
2472
|
for (const itemId of ids) {
|
|
@@ -2653,32 +2535,6 @@ async function handleGetOntology(deps) {
|
|
|
2653
2535
|
collections
|
|
2654
2536
|
});
|
|
2655
2537
|
}
|
|
2656
|
-
/** Publish this repository's `app.json` + shared schemas to its Firestore app.
|
|
2657
|
-
*
|
|
2658
|
-
* Named as a whole-app action because it IS one: publish takes the repository
|
|
2659
|
-
* as its unit (one roster, one public config, every shared collection), so it
|
|
2660
|
-
* carries no `slug` and refusing one is not a limitation to work around.
|
|
2661
|
-
*
|
|
2662
|
-
* The reply is prose rather than a status code on purpose. This is the one
|
|
2663
|
-
* operation in the collection surface that changes what every member sees the
|
|
2664
|
-
* moment it lands, and the two things the caller has to relay — what will
|
|
2665
|
-
* break, and that `confirm` is how it proceeds anyway — are sentences, not
|
|
2666
|
-
* fields. */
|
|
2667
|
-
async function handlePublishApp(deps, confirm) {
|
|
2668
|
-
const result = await publishApp({
|
|
2669
|
-
...deps,
|
|
2670
|
-
confirm
|
|
2671
|
-
});
|
|
2672
|
-
if (!result.ok) {
|
|
2673
|
-
const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
|
|
2674
|
-
return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
|
|
2675
|
-
}
|
|
2676
|
-
const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
|
|
2677
|
-
const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
|
|
2678
|
-
const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
|
|
2679
|
-
const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
|
|
2680
|
-
return `${result.created ? "Created" : "Updated"} app '${result.aid}' and published ${result.cids.length} collection(s): ${result.cids.join(", ")}. Signed ${stamp}. The previous app document is kept in \`previousPublished\` for rollback.${forced}`;
|
|
2681
|
-
}
|
|
2682
2538
|
/** Return the collection-authoring reference (`collection-skills.md`),
|
|
2683
2539
|
* rendered by `renderSchemaDocs` — the full doc overflows the agent's
|
|
2684
2540
|
* per-result limit, so the default reply is the core guide + a table of
|
|
@@ -2699,7 +2555,7 @@ async function handleSchemaDocs(deps, topic) {
|
|
|
2699
2555
|
* fallback for user-scope skills that have no staging copy. Raw text —
|
|
2700
2556
|
* not the parsed schema — so the agent edits the true on-disk source. */
|
|
2701
2557
|
async function handleGetSchema(slug, deps) {
|
|
2702
|
-
const collection = await
|
|
2558
|
+
const collection = await require_discovery.loadCollection(slug, deps);
|
|
2703
2559
|
if (!collection) return unknownCollection(slug);
|
|
2704
2560
|
const { stagingDir } = authoringTarget(deps, collection.slug);
|
|
2705
2561
|
const candidates = [...stagingDir === null ? [] : [node_path.default.join(stagingDir, SCHEMA_FILE)], node_path.default.join(collection.skillDir, SCHEMA_FILE)];
|
|
@@ -2761,11 +2617,11 @@ async function writeAndMirrorSchema(slug, skillDir, schema, deps) {
|
|
|
2761
2617
|
* gate doesn't apply. Returns a one-line reason, or null when the schema
|
|
2762
2618
|
* would be accepted. */
|
|
2763
2619
|
function schemaDiscoveryGate(schema, base) {
|
|
2764
|
-
const primaryField =
|
|
2620
|
+
const primaryField = require_discovery.resolvePrimaryField(schema.fields, schema.primaryKey);
|
|
2765
2621
|
if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
|
|
2766
2622
|
if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
|
|
2767
|
-
if (schema.dataPath !== void 0 &&
|
|
2768
|
-
if (schema.dataSource !== void 0 &&
|
|
2623
|
+
if (schema.dataPath !== void 0 && require_discovery.resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
|
|
2624
|
+
if (schema.dataSource !== void 0 && require_discovery.resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
|
|
2769
2625
|
return null;
|
|
2770
2626
|
}
|
|
2771
2627
|
/** Validate a schema against CollectionSchemaZ and, on success, persist it.
|
|
@@ -2773,11 +2629,11 @@ function schemaDiscoveryGate(schema, base) {
|
|
|
2773
2629
|
* the root's authoring dir (the normal create flow), not through here. */
|
|
2774
2630
|
async function handlePutSchema(slug, schemaArg, deps) {
|
|
2775
2631
|
if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
|
|
2776
|
-
const collection = await
|
|
2632
|
+
const collection = await require_discovery.loadCollection(slug, deps);
|
|
2777
2633
|
if (!collection) return `manageCollection: unknown collection '${require_promptSafety.defangForPrompt(slug)}' — create it by writing SKILL.md + ${SCHEMA_FILE} under ${authoringDirLabel(deps, require_promptSafety.defangForPrompt(slug))}, then edit it here.`;
|
|
2778
2634
|
const refusal = schemaEditRefusal(collection, slug, deps);
|
|
2779
2635
|
if (refusal) return refusal;
|
|
2780
|
-
const parsed =
|
|
2636
|
+
const parsed = require_discovery.CollectionSchemaZ.safeParse(schemaArg);
|
|
2781
2637
|
if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
|
|
2782
2638
|
const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
|
|
2783
2639
|
if (gate) return `manageCollection: schema rejected — ${gate} (call schemaDocs for the field reference). It passes basic validation but discovery would skip it, hiding the collection.`;
|
|
@@ -2787,7 +2643,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
2787
2643
|
written: true
|
|
2788
2644
|
});
|
|
2789
2645
|
}
|
|
2790
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done.
|
|
2646
|
+
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
2791
2647
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
2792
2648
|
async function dispatchGetItems(collection, args, deps) {
|
|
2793
2649
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -2824,7 +2680,7 @@ async function manageCollectionHandler(deps, args) {
|
|
|
2824
2680
|
try {
|
|
2825
2681
|
return await dispatchManageCollection(deps, args);
|
|
2826
2682
|
} catch (err) {
|
|
2827
|
-
if (
|
|
2683
|
+
if (require_discovery.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
|
|
2828
2684
|
throw err;
|
|
2829
2685
|
}
|
|
2830
2686
|
}
|
|
@@ -2832,19 +2688,18 @@ async function dispatchManageCollection(deps, args) {
|
|
|
2832
2688
|
const action = typeof args.action === "string" ? args.action : "";
|
|
2833
2689
|
if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
|
|
2834
2690
|
if (action === "getOntology") return handleGetOntology(deps);
|
|
2835
|
-
if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
|
|
2836
2691
|
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
2837
2692
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
2838
2693
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
2839
2694
|
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
2840
|
-
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\",
|
|
2841
|
-
const collection = await
|
|
2695
|
+
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
2696
|
+
const collection = await require_discovery.loadCollection(slug, deps);
|
|
2842
2697
|
if (!collection) return unknownCollection(slug);
|
|
2843
2698
|
return dispatchRecordAction(action, collection, args, deps);
|
|
2844
2699
|
}
|
|
2845
2700
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
2846
2701
|
name: "manageCollection",
|
|
2847
|
-
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json.
|
|
2702
|
+
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
2848
2703
|
inputSchema: {
|
|
2849
2704
|
type: "object",
|
|
2850
2705
|
properties: {
|
|
@@ -2858,8 +2713,7 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2858
2713
|
"getOntology",
|
|
2859
2714
|
"schemaDocs",
|
|
2860
2715
|
"getSchema",
|
|
2861
|
-
"putSchema"
|
|
2862
|
-
"publishApp"
|
|
2716
|
+
"putSchema"
|
|
2863
2717
|
],
|
|
2864
2718
|
description: "What to do."
|
|
2865
2719
|
},
|
|
@@ -2895,10 +2749,6 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2895
2749
|
type: "object",
|
|
2896
2750
|
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
2897
2751
|
},
|
|
2898
|
-
confirm: {
|
|
2899
|
-
type: "boolean",
|
|
2900
|
-
description: "publishApp: publish even though existing records fail the schemas being published. Omit it first — the refusal lists what would break, and that list is the thing to show the user before asking."
|
|
2901
|
-
},
|
|
2902
2752
|
topic: {
|
|
2903
2753
|
type: "string",
|
|
2904
2754
|
description: "schemaDocs: fetch one section of the reference by heading (case-insensitive substring — e.g. \"field types\", \"kanban\", \"calendar\", \"dataSource\"). Omit for the core authoring guide plus a table of contents of every section; \"all\" returns the full document (large — it can exceed your tool-result limit)."
|
|
@@ -2932,6 +2782,12 @@ Object.defineProperty(exports, "APP_ROLES", {
|
|
|
2932
2782
|
return APP_ROLES;
|
|
2933
2783
|
}
|
|
2934
2784
|
});
|
|
2785
|
+
Object.defineProperty(exports, "APP_SLUGS_COLLECTION", {
|
|
2786
|
+
enumerable: true,
|
|
2787
|
+
get: function() {
|
|
2788
|
+
return APP_SLUGS_COLLECTION;
|
|
2789
|
+
}
|
|
2790
|
+
});
|
|
2935
2791
|
Object.defineProperty(exports, "AuthoredAppZ", {
|
|
2936
2792
|
enumerable: true,
|
|
2937
2793
|
get: function() {
|
|
@@ -2992,6 +2848,18 @@ Object.defineProperty(exports, "appSchemasPath", {
|
|
|
2992
2848
|
return appSchemasPath;
|
|
2993
2849
|
}
|
|
2994
2850
|
});
|
|
2851
|
+
Object.defineProperty(exports, "appSlugDoc", {
|
|
2852
|
+
enumerable: true,
|
|
2853
|
+
get: function() {
|
|
2854
|
+
return appSlugDoc;
|
|
2855
|
+
}
|
|
2856
|
+
});
|
|
2857
|
+
Object.defineProperty(exports, "appStagingPath", {
|
|
2858
|
+
enumerable: true,
|
|
2859
|
+
get: function() {
|
|
2860
|
+
return appStagingPath;
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2995
2863
|
Object.defineProperty(exports, "applyMutateAction", {
|
|
2996
2864
|
enumerable: true,
|
|
2997
2865
|
get: function() {
|
|
@@ -3118,16 +2986,28 @@ Object.defineProperty(exports, "projectApp", {
|
|
|
3118
2986
|
return projectApp;
|
|
3119
2987
|
}
|
|
3120
2988
|
});
|
|
3121
|
-
Object.defineProperty(exports, "
|
|
2989
|
+
Object.defineProperty(exports, "projectDeploy", {
|
|
3122
2990
|
enumerable: true,
|
|
3123
2991
|
get: function() {
|
|
3124
|
-
return
|
|
2992
|
+
return projectDeploy;
|
|
2993
|
+
}
|
|
2994
|
+
});
|
|
2995
|
+
Object.defineProperty(exports, "projectPublish", {
|
|
2996
|
+
enumerable: true,
|
|
2997
|
+
get: function() {
|
|
2998
|
+
return projectPublish;
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
3001
|
+
Object.defineProperty(exports, "promoteSchema", {
|
|
3002
|
+
enumerable: true,
|
|
3003
|
+
get: function() {
|
|
3004
|
+
return promoteSchema;
|
|
3125
3005
|
}
|
|
3126
3006
|
});
|
|
3127
|
-
Object.defineProperty(exports, "
|
|
3007
|
+
Object.defineProperty(exports, "promptPathsFor", {
|
|
3128
3008
|
enumerable: true,
|
|
3129
3009
|
get: function() {
|
|
3130
|
-
return
|
|
3010
|
+
return promptPathsFor;
|
|
3131
3011
|
}
|
|
3132
3012
|
});
|
|
3133
3013
|
Object.defineProperty(exports, "publishProblems", {
|
|
@@ -3184,6 +3064,12 @@ Object.defineProperty(exports, "schemaRelations", {
|
|
|
3184
3064
|
return schemaRelations;
|
|
3185
3065
|
}
|
|
3186
3066
|
});
|
|
3067
|
+
Object.defineProperty(exports, "stagedRuleConfig", {
|
|
3068
|
+
enumerable: true,
|
|
3069
|
+
get: function() {
|
|
3070
|
+
return stagedRuleConfig;
|
|
3071
|
+
}
|
|
3072
|
+
});
|
|
3187
3073
|
Object.defineProperty(exports, "successorId", {
|
|
3188
3074
|
enumerable: true,
|
|
3189
3075
|
get: function() {
|
|
@@ -3203,4 +3089,4 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
3203
3089
|
}
|
|
3204
3090
|
});
|
|
3205
3091
|
|
|
3206
|
-
//# sourceMappingURL=server-
|
|
3092
|
+
//# sourceMappingURL=server-X_magqUa.cjs.map
|