@mulmoclaude/core 3.7.0 → 3.10.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/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-B48Jyxcj.js → server-BNe8XMtu.js} +388 -568
- package/dist/server-BNe8XMtu.js.map +1 -0
- package/dist/{server-CWZyg8fn.cjs → server-CgkkWWBd.cjs} +590 -734
- package/dist/server-CgkkWWBd.cjs.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. */
|
|
@@ -154,7 +152,7 @@ var AuthoredAppZ = zod.z.object({
|
|
|
154
152
|
* once — publish is a manual step, and a parser that stops at the first key
|
|
155
153
|
* makes it N round trips. */
|
|
156
154
|
function parseAuthoredApp(raw) {
|
|
157
|
-
const manifest =
|
|
155
|
+
const manifest = require_discovery.parseAppManifest(raw);
|
|
158
156
|
if (!manifest.ok) return {
|
|
159
157
|
ok: false,
|
|
160
158
|
problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
|
|
@@ -265,6 +263,7 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
265
263
|
publishedAt: stamp.publishedAt,
|
|
266
264
|
publishedBy: stamp.email,
|
|
267
265
|
publishedCommit: stamp.commit,
|
|
266
|
+
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
268
267
|
previousPublished: previousOf(existing)
|
|
269
268
|
});
|
|
270
269
|
const config = {
|
|
@@ -283,6 +282,102 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
283
282
|
config
|
|
284
283
|
};
|
|
285
284
|
}
|
|
285
|
+
/** Keys on the app document that publish owns: what is PUBLIC right now, and
|
|
286
|
+
* the rule-facing configuration anonymous access is judged against. Deploy
|
|
287
|
+
* carries them through from the existing document and never authors them. */
|
|
288
|
+
var PUBLISH_OWNED_KEYS = [
|
|
289
|
+
"public",
|
|
290
|
+
"collections",
|
|
291
|
+
"participantRead",
|
|
292
|
+
"publishedAt",
|
|
293
|
+
"publishedBy",
|
|
294
|
+
"publishedCommit",
|
|
295
|
+
"publishedDirty",
|
|
296
|
+
"previousPublished"
|
|
297
|
+
];
|
|
298
|
+
var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
|
|
299
|
+
function projectDeploy(authored, schemas, stamp, existing) {
|
|
300
|
+
const { app } = projectApp(authored, schemas, stamp, existing);
|
|
301
|
+
const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
|
|
302
|
+
for (const key of PUBLISH_OWNED_KEYS) {
|
|
303
|
+
const live = existing?.[key];
|
|
304
|
+
if (live !== void 0) deployed[key] = live;
|
|
305
|
+
}
|
|
306
|
+
deployed.deployedAt = stamp.publishedAt;
|
|
307
|
+
deployed.deployedBy = stamp.email;
|
|
308
|
+
if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
|
|
309
|
+
return {
|
|
310
|
+
app: deployed,
|
|
311
|
+
staging: schemas.map(({ cid, schema }) => ({
|
|
312
|
+
cid,
|
|
313
|
+
doc: stagedDoc(schema, stamp, authored, cid)
|
|
314
|
+
}))
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** One staged schema document, carrying this cid's rule-facing configuration
|
|
318
|
+
* alongside the schema so publish can promote them together. */
|
|
319
|
+
function stagedDoc(schema, stamp, authored, cid) {
|
|
320
|
+
const doc = {
|
|
321
|
+
publishedSchema: schema,
|
|
322
|
+
deployedAt: stamp.publishedAt,
|
|
323
|
+
deployedBy: stamp.email
|
|
324
|
+
};
|
|
325
|
+
const config = authored.collections?.[cid];
|
|
326
|
+
if (config !== void 0) doc.config = config;
|
|
327
|
+
if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
|
|
328
|
+
if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
|
|
329
|
+
return doc;
|
|
330
|
+
}
|
|
331
|
+
/** The rule-facing configuration to promote, read from the STAGED documents
|
|
332
|
+
* rather than from the manifest as it reads right now.
|
|
333
|
+
*
|
|
334
|
+
* Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
|
|
335
|
+
* the promoted schema is A's while the authorization behaviour is B's, a
|
|
336
|
+
* combination nobody exercised through `/staging/{aid}`.
|
|
337
|
+
*
|
|
338
|
+
* (`public` is deliberately NOT part of this: it is not staged, because it is
|
|
339
|
+
* the decision being made AT publish rather than something under test.) */
|
|
340
|
+
function stagedRuleConfig(staged) {
|
|
341
|
+
const entries = [];
|
|
342
|
+
const participantRead = [];
|
|
343
|
+
for (const entry of staged) {
|
|
344
|
+
const { config } = entry.doc;
|
|
345
|
+
if (config !== void 0) entries.push([entry.cid, config]);
|
|
346
|
+
if (entry.doc.participantRead === true) participantRead.push(entry.cid);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
|
|
350
|
+
participantRead: participantRead.length > 0 ? participantRead : void 0
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function projectPublish(authored, staged, stamp, existing) {
|
|
354
|
+
const { app, config } = projectApp(authored, [], stamp, existing);
|
|
355
|
+
const staging = stagedRuleConfig(staged);
|
|
356
|
+
app.collections = staging.collections;
|
|
357
|
+
app.participantRead = staging.participantRead;
|
|
358
|
+
const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
|
|
359
|
+
for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
|
|
360
|
+
const publicBlock = app.public;
|
|
361
|
+
return {
|
|
362
|
+
app: published,
|
|
363
|
+
config,
|
|
364
|
+
public: require_dist.isRecord(publicBlock) ? publicBlock : void 0
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
/** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
|
|
368
|
+
*
|
|
369
|
+
* The stamp answers "which version is PUBLIC right now, and who made it so",
|
|
370
|
+
* so it is written by the operation that changes the answer — publish — not
|
|
371
|
+
* carried over from the deploy that staged it. */
|
|
372
|
+
function promoteSchema(staged, stamp) {
|
|
373
|
+
const doc = {
|
|
374
|
+
publishedSchema: staged.publishedSchema,
|
|
375
|
+
publishedAt: stamp.publishedAt,
|
|
376
|
+
publishedBy: stamp.email
|
|
377
|
+
};
|
|
378
|
+
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
379
|
+
return doc;
|
|
380
|
+
}
|
|
286
381
|
/** One published schema document. Written key by key rather than through
|
|
287
382
|
* `compact`, so the declared type is the type — an optional commit is the
|
|
288
383
|
* only variable part. */
|
|
@@ -298,8 +393,31 @@ function schemaDoc(schema, stamp) {
|
|
|
298
393
|
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
299
394
|
* collection path plus a document id, and the app document's id is the aid. */
|
|
300
395
|
var APPS_COLLECTION = "apps";
|
|
301
|
-
/** The collection (schema) documents' parent path
|
|
396
|
+
/** The collection (schema) documents' parent path — what the PUBLIC page
|
|
397
|
+
* reads, written only by publish (promotion). */
|
|
302
398
|
var appSchemasPath = (aid) => `apps/${aid}/collections`;
|
|
399
|
+
/** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
|
|
400
|
+
*
|
|
401
|
+
* A TOP-LEVEL collection, not a field on the app: the public page resolves a
|
|
402
|
+
* slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
|
|
403
|
+
* has to be claimable atomically (create-if-absent) so two apps cannot hold
|
|
404
|
+
* the same URL.
|
|
405
|
+
*
|
|
406
|
+
* `published` is what makes the reservation invisible until publish. The slug
|
|
407
|
+
* is human-readable, so a readable reservation would let anyone guess the URL
|
|
408
|
+
* and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
|
|
409
|
+
* `allow read: if resource.data.published == true`, which needs no `get()` and
|
|
410
|
+
* so costs nothing against the rules' expression budget. */
|
|
411
|
+
var APP_SLUGS_COLLECTION = "appSlugs";
|
|
412
|
+
var appSlugDoc = (aid, published) => ({
|
|
413
|
+
aid,
|
|
414
|
+
published
|
|
415
|
+
});
|
|
416
|
+
/** The staged schema documents' parent path — what `/staging/{aid}` reads,
|
|
417
|
+
* written by deploy. A separate DOCUMENT rather than a field beside
|
|
418
|
+
* `publishedSchema`, because the rules cannot hide a field: anything inside a
|
|
419
|
+
* document the public page may read is public. */
|
|
420
|
+
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
303
421
|
/** The public-config documents' parent path. */
|
|
304
422
|
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
305
423
|
//#endregion
|
|
@@ -525,530 +643,32 @@ function publishProblems(app, collections, publisherEmail) {
|
|
|
525
643
|
...primaryKeyProblems(app, collections)
|
|
526
644
|
];
|
|
527
645
|
}
|
|
528
|
-
/** A public submission must be
|
|
646
|
+
/** A public submission must NOT be allowed to name its own primary key.
|
|
647
|
+
*
|
|
648
|
+
* The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
|
|
649
|
+
* value of a field — nothing compares `request.resource.data[primaryKey]`
|
|
650
|
+
* with the path being written. So a submit path that accepts the primary key
|
|
651
|
+
* as a `createField` lets a submitter write at their one permitted document
|
|
652
|
+
* id while CLAIMING another record's identity, or a duplicate.
|
|
529
653
|
*
|
|
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.
|
|
654
|
+
* It is refused rather than tolerated because there is nothing for the field
|
|
655
|
+
* to do: `firestoreStore` takes a shared record's identity from the document
|
|
656
|
+
* id and overwrites the field on read, so a submitted value is either equal
|
|
657
|
+
* to the id (noise) or a lie (silently discarded). Publishing a form field
|
|
658
|
+
* whose value is thrown away is worse than not having it — the author will
|
|
659
|
+
* believe submitters choose their ids.
|
|
540
660
|
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
661
|
+
* This is the second answer to the same question. The first was the reverse —
|
|
662
|
+
* REQUIRE the key, because a record without one was rejected by every reader
|
|
663
|
+
* — and it was right about the symptom and wrong about the cure: the identity
|
|
664
|
+
* belongs to the id the rules can pin, not to a field they cannot. */
|
|
544
665
|
function primaryKeyProblems(app, collections) {
|
|
545
666
|
const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
|
|
546
667
|
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
547
668
|
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:
|
|
550
|
-
});
|
|
551
|
-
}
|
|
552
|
-
//#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
|
-
}
|
|
669
|
+
if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
|
|
670
|
+
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.`];
|
|
633
671
|
});
|
|
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
672
|
}
|
|
1053
673
|
//#endregion
|
|
1054
674
|
//#region src/collection/server/skillAssets.ts
|
|
@@ -1075,13 +695,13 @@ async function readCustomViewHtml(collection, viewFile, opts = {}) {
|
|
|
1075
695
|
* `readCustomViewHtml`. Used by both `readCustomViewHtml` and
|
|
1076
696
|
* `readCustomViewI18n` so the two stay in lockstep. */
|
|
1077
697
|
async function readSourceAwareFile(collection, relPath, opts) {
|
|
1078
|
-
const safeSlug =
|
|
698
|
+
const safeSlug = require_discovery.safeSlugName(collection.slug);
|
|
1079
699
|
if (safeSlug === null) return null;
|
|
1080
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1081
|
-
const staging = collection.source === "project" ?
|
|
700
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
701
|
+
const staging = collection.source === "project" ? require_discovery.stagingSkillDir(workspaceRoot, safeSlug) : null;
|
|
1082
702
|
const bases = staging === null ? [collection.skillDir] : [staging, collection.skillDir];
|
|
1083
703
|
for (const base of bases) {
|
|
1084
|
-
const resolved =
|
|
704
|
+
const resolved = require_discovery.resolveTemplatePath(base, relPath);
|
|
1085
705
|
if (resolved === null) continue;
|
|
1086
706
|
try {
|
|
1087
707
|
return await (0, node_fs_promises.readFile)(resolved, "utf-8");
|
|
@@ -1139,9 +759,9 @@ async function readCustomViewI18n(collection, i18nFile, locale, opts = {}) {
|
|
|
1139
759
|
* the file contents, or null when the path escapes the skill dir, the
|
|
1140
760
|
* resolved target isn't a regular file, or the read fails. */
|
|
1141
761
|
async function readSkillTemplate(skillDir, templateRelPath) {
|
|
1142
|
-
const resolved =
|
|
762
|
+
const resolved = require_discovery.resolveTemplatePath(skillDir, templateRelPath);
|
|
1143
763
|
if (resolved === null) return null;
|
|
1144
|
-
if (!await
|
|
764
|
+
if (!await require_discovery.isRegularFile(resolved)) return null;
|
|
1145
765
|
try {
|
|
1146
766
|
return await (0, node_fs_promises.readFile)(resolved, "utf-8");
|
|
1147
767
|
} catch {
|
|
@@ -1283,11 +903,11 @@ function absentReferencedColumns(rows, query) {
|
|
|
1283
903
|
/** Run a validated query over enriched collection rows. */
|
|
1284
904
|
async function runQueryOverRows(rows, query) {
|
|
1285
905
|
if (rows.length === 0) return emptyCollectionResult(query);
|
|
1286
|
-
await (0, node_fs_promises.mkdir)(
|
|
906
|
+
await (0, node_fs_promises.mkdir)(require_discovery.cacheDir(), {
|
|
1287
907
|
recursive: true,
|
|
1288
908
|
mode: 448
|
|
1289
909
|
});
|
|
1290
|
-
const jsonlPath = node_path.default.join(
|
|
910
|
+
const jsonlPath = node_path.default.join(require_discovery.cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
|
|
1291
911
|
try {
|
|
1292
912
|
const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
|
|
1293
913
|
const handle = await (0, node_fs_promises.open)(jsonlPath, "wx", 384);
|
|
@@ -1303,115 +923,347 @@ async function runQueryOverRows(rows, query) {
|
|
|
1303
923
|
} finally {
|
|
1304
924
|
await handle.close();
|
|
1305
925
|
}
|
|
1306
|
-
const { sql, params } =
|
|
1307
|
-
return (await
|
|
926
|
+
const { sql, params } = require_discovery.compileJsonlQuery(query);
|
|
927
|
+
return (await require_discovery.queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, require_discovery.normalizeCsvValue(value)])));
|
|
1308
928
|
} finally {
|
|
1309
929
|
await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
|
|
1310
|
-
if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT")
|
|
930
|
+
if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_discovery.log.warn("collections", "temp JSONL cleanup failed", {
|
|
1311
931
|
path: jsonlPath,
|
|
1312
932
|
error: String(err)
|
|
1313
933
|
});
|
|
1314
|
-
});
|
|
1315
|
-
}
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/collection/server/derive.ts
|
|
939
|
+
async function loadTarget(slug, opts) {
|
|
940
|
+
const target = await require_discovery.loadCollection(slug, opts);
|
|
941
|
+
if (!target) return null;
|
|
942
|
+
const items = await require_discovery.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
|
|
943
|
+
const byId = {};
|
|
944
|
+
for (const item of items) {
|
|
945
|
+
const itemId = item[target.schema.primaryKey];
|
|
946
|
+
if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
schema: target.schema,
|
|
950
|
+
byId
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** Load every ref/embed target and backlink source collection once.
|
|
954
|
+
* Unknown / unloadable targets are simply absent — downstream derefs
|
|
955
|
+
* resolve to null (em-dash) and backlinks to an empty row set, the
|
|
956
|
+
* same fail-soft the UI renders. */
|
|
957
|
+
async function loadLinkedTargets(schema, opts) {
|
|
958
|
+
const slugs = [.../* @__PURE__ */ new Set([
|
|
959
|
+
...require_promptSafety.uniqueRefTargets(schema),
|
|
960
|
+
...require_promptSafety.uniqueEmbedTargets(schema),
|
|
961
|
+
...require_promptSafety.uniqueBacklinkSources(schema)
|
|
962
|
+
])];
|
|
963
|
+
const loaded = {};
|
|
964
|
+
for (const slug of slugs) {
|
|
965
|
+
const target = await loadTarget(slug, opts);
|
|
966
|
+
if (target) loaded[slug] = target;
|
|
967
|
+
}
|
|
968
|
+
return loaded;
|
|
969
|
+
}
|
|
970
|
+
function toRefRecords(linked) {
|
|
971
|
+
return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
|
|
972
|
+
}
|
|
973
|
+
/** The matching source rows for one `backlinks` field, projected to the
|
|
974
|
+
* source primaryKey + `display` columns — so getItems on a
|
|
975
|
+
* heavily-referenced record stays a summary, not a dump of the source
|
|
976
|
+
* collection. Missing source ⇒ [] (fail-soft). The rows come from the
|
|
977
|
+
* DERIVED source records (`byId`), so `display`/`filter` on a derived
|
|
978
|
+
* source column (an invoice `total`) works. */
|
|
979
|
+
function projectBacklinks(field, schema, enriched, linked) {
|
|
980
|
+
const source = linked[field.from];
|
|
981
|
+
if (!source) return [];
|
|
982
|
+
const selfId = require_itemId.fieldText(enriched[schema.primaryKey]);
|
|
983
|
+
return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
|
|
984
|
+
}
|
|
985
|
+
/** Project the computed (never-stored) field kinds onto one derived
|
|
986
|
+
* record: `toggle` → boolean off its enum, `embed` → the target record
|
|
987
|
+
* (fixed `id` or per-record `idField`), or null when missing,
|
|
988
|
+
* `backlinks` → the matching source rows (see `projectBacklinks`). */
|
|
989
|
+
function projectComputed(schema, enriched, linked) {
|
|
990
|
+
for (const [key, field] of Object.entries(schema.fields)) {
|
|
991
|
+
if (field.type === "toggle" && field.field) enriched[key] = require_itemId.fieldText(enriched[field.field]) === field.onValue;
|
|
992
|
+
if (field.type === "embed" && field.to) {
|
|
993
|
+
const targetId = require_itemId.embedTargetId(field, enriched);
|
|
994
|
+
const target = require_promptSafety.ownProp(linked, field.to);
|
|
995
|
+
enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
|
|
996
|
+
}
|
|
997
|
+
if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
|
|
998
|
+
}
|
|
999
|
+
return enriched;
|
|
1000
|
+
}
|
|
1001
|
+
/** Resolve every rollup field onto a COPY of the record, BEFORE the
|
|
1002
|
+
* formula pass — a `derived` formula may reference rollup values as
|
|
1003
|
+
* plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
|
|
1004
|
+
* machinery as backlinks, collapsed to a number: an unresolvable source
|
|
1005
|
+
* is null (a formula reading it fails soft to em-dash); an empty match
|
|
1006
|
+
* set is a real 0. Returns `record` unchanged when the schema declares
|
|
1007
|
+
* no rollups. */
|
|
1008
|
+
function projectRollups(schema, record, linked) {
|
|
1009
|
+
let out = record;
|
|
1010
|
+
for (const [key, field] of Object.entries(schema.fields)) {
|
|
1011
|
+
if (field.type !== "rollup") continue;
|
|
1012
|
+
if (out === record) out = { ...record };
|
|
1013
|
+
const source = linked[field.from];
|
|
1014
|
+
const selfId = require_itemId.fieldText(record[schema.primaryKey]);
|
|
1015
|
+
out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
|
|
1016
|
+
}
|
|
1017
|
+
return out;
|
|
1018
|
+
}
|
|
1019
|
+
/** Enrich records with every host-computed field: derived formulas
|
|
1020
|
+
* evaluated (cross-collection derefs included), toggles projected,
|
|
1021
|
+
* embeds resolved. Loads each linked collection ONCE per call. Input
|
|
1022
|
+
* records are not mutated. */
|
|
1023
|
+
async function enrichItems(collection, items, opts = {}) {
|
|
1024
|
+
const { schema } = collection;
|
|
1025
|
+
const linked = await loadLinkedTargets(schema, opts);
|
|
1026
|
+
const refRecords = toRefRecords(linked);
|
|
1027
|
+
return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
1030
|
+
//#region src/collection/server/queryRunner.ts
|
|
1031
|
+
async function runCollectionQuery(collection, query, opts = {}) {
|
|
1032
|
+
const store = require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
|
|
1033
|
+
if (store.query) return store.query(query);
|
|
1034
|
+
return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
|
|
1035
|
+
}
|
|
1036
|
+
//#endregion
|
|
1037
|
+
//#region src/collection/core/recordZ.ts
|
|
1038
|
+
/** The emptiness rule shared by `required` and the "only check present
|
|
1039
|
+
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
1040
|
+
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
1041
|
+
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
1042
|
+
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
1043
|
+
function enforcedProblem(key, spec, value) {
|
|
1044
|
+
const empty = isEmptyValue(value);
|
|
1045
|
+
if (spec.required && empty) return `missing required field '${key}'`;
|
|
1046
|
+
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
1050
|
+
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
1051
|
+
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
1052
|
+
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
1053
|
+
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
1054
|
+
* which the day view can't place). `string`-backed types accept anything
|
|
1055
|
+
* stringifiable; `ref` existence is out of scope. */
|
|
1056
|
+
function strictTypeProblem(key, spec, value) {
|
|
1057
|
+
switch (spec.type) {
|
|
1058
|
+
case "number":
|
|
1059
|
+
case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
1060
|
+
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
1061
|
+
case "date": return require_itemId.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
1062
|
+
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)`;
|
|
1063
|
+
default: return null;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
1067
|
+
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
1068
|
+
* First row problem wins, prefixed with the row number so the fix is
|
|
1069
|
+
* locatable. */
|
|
1070
|
+
function strictTableProblem(key, spec, value) {
|
|
1071
|
+
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
1072
|
+
for (let index = 0; index < value.length; index++) {
|
|
1073
|
+
const row = value[index];
|
|
1074
|
+
if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
1075
|
+
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
1076
|
+
const subValue = row[subKey];
|
|
1077
|
+
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
1078
|
+
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
/** First problem for one field's stored value under `tier`, or null.
|
|
1084
|
+
* Enforced checks always run (and their messages never vary by tier — the
|
|
1085
|
+
* scan and the write gate must agree on them); strict adds the per-type
|
|
1086
|
+
* layer on present values only. */
|
|
1087
|
+
function recordFieldProblem(key, spec, value, tier) {
|
|
1088
|
+
const enforced = enforcedProblem(key, spec, value);
|
|
1089
|
+
if (enforced || tier === "enforced") return enforced;
|
|
1090
|
+
if (isEmptyValue(value)) return null;
|
|
1091
|
+
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
1092
|
+
return strictTypeProblem(key, spec, value);
|
|
1093
|
+
}
|
|
1094
|
+
var compiled = /* @__PURE__ */ new WeakMap();
|
|
1095
|
+
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
1096
|
+
* Loose object: unknown keys are allowed and any declared key may be
|
|
1097
|
+
* absent (records are user files, not parse-and-rewrite targets —
|
|
1098
|
+
* callers validate, they never persist the parse output). The checks run
|
|
1099
|
+
* as ONE object-level refine iterating fields in declaration order —
|
|
1100
|
+
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
1101
|
+
* must still reach the required check", and the single loop keeps the
|
|
1102
|
+
* first reported issue identical to the historical first-problem-wins
|
|
1103
|
+
* contract. */
|
|
1104
|
+
function compileRecordZ(schema, tier) {
|
|
1105
|
+
const cached = compiled.get(schema)?.[tier];
|
|
1106
|
+
if (cached) return cached;
|
|
1107
|
+
const stored = Object.entries(schema.fields).filter(([, spec]) => !require_itemId.COMPUTED_TYPES.has(spec.type));
|
|
1108
|
+
const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
|
|
1109
|
+
for (const [key, spec] of stored) {
|
|
1110
|
+
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
1111
|
+
if (problem) ctx.addIssue({
|
|
1112
|
+
code: "custom",
|
|
1113
|
+
message: problem,
|
|
1114
|
+
path: [key]
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
1118
|
+
const entry = compiled.get(schema) ?? {};
|
|
1119
|
+
entry[tier] = validator;
|
|
1120
|
+
compiled.set(schema, entry);
|
|
1121
|
+
return validator;
|
|
1122
|
+
}
|
|
1123
|
+
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
1124
|
+
* issue per record keeps the report short and the fix obvious (the
|
|
1125
|
+
* historical contract of `validateRecordObject`). */
|
|
1126
|
+
function firstRecordProblem(record, schema, tier) {
|
|
1127
|
+
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
1128
|
+
if (result.success) return null;
|
|
1129
|
+
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
1316
1130
|
}
|
|
1317
1131
|
//#endregion
|
|
1318
|
-
//#region src/collection/server/
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1132
|
+
//#region src/collection/server/validate.ts
|
|
1133
|
+
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
1134
|
+
* because a caller that REPORTS a count has to know the count is a floor —
|
|
1135
|
+
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
1136
|
+
var MAX_RECORD_ISSUES = 25;
|
|
1137
|
+
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
1138
|
+
* at all.
|
|
1139
|
+
*
|
|
1140
|
+
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
1141
|
+
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
1142
|
+
* direction that matters: `publish` lets the user override invalid records,
|
|
1143
|
+
* and overriding this one would mean publishing without ever having looked. */
|
|
1144
|
+
var STORE_UNREADABLE = "(store)";
|
|
1145
|
+
var MAX_ISSUES = 25;
|
|
1146
|
+
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
1147
|
+
* ones that won't load or violate the schema. An empty list means every
|
|
1148
|
+
* record is fine. */
|
|
1149
|
+
/** List entries under the data dir, guarding realpath containment (against a
|
|
1150
|
+
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
1151
|
+
* missing dir as empty while surfacing real I/O faults. */
|
|
1152
|
+
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
1153
|
+
if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) {
|
|
1154
|
+
require_discovery.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
1155
|
+
return [];
|
|
1327
1156
|
}
|
|
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;
|
|
1157
|
+
try {
|
|
1158
|
+
return await (0, node_fs_promises.readdir)(dataDir);
|
|
1159
|
+
} catch (err) {
|
|
1160
|
+
if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
1161
|
+
throw err;
|
|
1347
1162
|
}
|
|
1348
|
-
return loaded;
|
|
1349
1163
|
}
|
|
1350
|
-
function
|
|
1351
|
-
|
|
1164
|
+
async function validateCollectionRecords(collection, opts = {}) {
|
|
1165
|
+
if (collection.schema.dataSource !== void 0) return [];
|
|
1166
|
+
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
1167
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
1168
|
+
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
1169
|
+
const issues = [];
|
|
1170
|
+
for (const name of entries.sort()) {
|
|
1171
|
+
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
1172
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
1173
|
+
const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
|
|
1174
|
+
if (issue) issues.push(issue);
|
|
1175
|
+
}
|
|
1176
|
+
return issues;
|
|
1352
1177
|
}
|
|
1353
|
-
/**
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
1359
|
-
function
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1178
|
+
/** Store-backed twin of the file scan: list every record through the
|
|
1179
|
+
* collection's store and lint it with the same "strict" report-only tier.
|
|
1180
|
+
* A row the store can't even parse is invisible here (the store skips
|
|
1181
|
+
* it), so the read/parse classifications of the file scan don't apply —
|
|
1182
|
+
* schema violations are what this catches. `file` carries the record id
|
|
1183
|
+
* (there is no per-record filename). */
|
|
1184
|
+
async function validateStoreRecords(collection, opts) {
|
|
1185
|
+
let items;
|
|
1186
|
+
try {
|
|
1187
|
+
items = await require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
return [{
|
|
1190
|
+
file: STORE_UNREADABLE,
|
|
1191
|
+
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
1192
|
+
}];
|
|
1193
|
+
}
|
|
1194
|
+
const issues = [];
|
|
1195
|
+
for (const item of items) {
|
|
1196
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
1197
|
+
const itemId = require_itemId.fieldText(item[collection.schema.primaryKey]);
|
|
1198
|
+
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
1199
|
+
if (problem) issues.push({
|
|
1200
|
+
file: itemId,
|
|
1201
|
+
problem
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
return issues;
|
|
1364
1205
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
}
|
|
1377
|
-
if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
|
|
1206
|
+
async function readRecordText(fullPath, name) {
|
|
1207
|
+
try {
|
|
1208
|
+
if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
|
|
1209
|
+
file: name,
|
|
1210
|
+
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
1211
|
+
};
|
|
1212
|
+
return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
|
|
1213
|
+
} catch {
|
|
1214
|
+
return {
|
|
1215
|
+
file: name,
|
|
1216
|
+
problem: "could not be read — skipped, won't appear"
|
|
1217
|
+
};
|
|
1378
1218
|
}
|
|
1379
|
-
return enriched;
|
|
1380
1219
|
}
|
|
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;
|
|
1220
|
+
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
1221
|
+
* schema violation, or null when it's fine. */
|
|
1222
|
+
async function inspectRecord(fullPath, name, schema) {
|
|
1223
|
+
const read = await readRecordText(fullPath, name);
|
|
1224
|
+
if ("problem" in read) return read;
|
|
1225
|
+
let parsed;
|
|
1226
|
+
try {
|
|
1227
|
+
parsed = JSON.parse(read.raw);
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
return {
|
|
1230
|
+
file: name,
|
|
1231
|
+
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.`
|
|
1232
|
+
};
|
|
1396
1233
|
}
|
|
1397
|
-
return
|
|
1234
|
+
if (!require_dist.isRecord(parsed)) return {
|
|
1235
|
+
file: name,
|
|
1236
|
+
problem: "not a JSON object — skipped, won't appear"
|
|
1237
|
+
};
|
|
1238
|
+
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
1239
|
+
return problem ? {
|
|
1240
|
+
file: name,
|
|
1241
|
+
problem
|
|
1242
|
+
} : null;
|
|
1398
1243
|
}
|
|
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));
|
|
1244
|
+
/** What a non-string primary key actually is, for the error message. Names the
|
|
1245
|
+
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
1246
|
+
* nothing about what is wrong. */
|
|
1247
|
+
function describeIdType(value) {
|
|
1248
|
+
if (value === null) return "null";
|
|
1249
|
+
if (value === void 0) return "missing";
|
|
1250
|
+
if (Array.isArray(value)) return "an array";
|
|
1251
|
+
return `a ${typeof value}`;
|
|
1408
1252
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1253
|
+
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
1254
|
+
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
1255
|
+
* tiers), or null when it's fine. One issue per record keeps the report
|
|
1256
|
+
* short and the fix obvious. Pure + exported so write paths
|
|
1257
|
+
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
1258
|
+
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
1259
|
+
* would be) stored under. The default `"enforced"` tier keeps every
|
|
1260
|
+
* write gate on the historical three checks; only pass `"strict"` from
|
|
1261
|
+
* report-only surfaces. */
|
|
1262
|
+
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
1263
|
+
const idValue = record[schema.primaryKey];
|
|
1264
|
+
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`;
|
|
1265
|
+
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
1266
|
+
return firstRecordProblem(record, schema, tier);
|
|
1415
1267
|
}
|
|
1416
1268
|
//#endregion
|
|
1417
1269
|
//#region src/collection/server/mutate.ts
|
|
@@ -1438,7 +1290,7 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
|
|
|
1438
1290
|
status: "invalid-params",
|
|
1439
1291
|
problem: paramProblem
|
|
1440
1292
|
};
|
|
1441
|
-
const store =
|
|
1293
|
+
const store = require_discovery.storeFor(collection, opts);
|
|
1442
1294
|
if (!store.write) return {
|
|
1443
1295
|
ok: false,
|
|
1444
1296
|
status: "write-refused",
|
|
@@ -1457,11 +1309,11 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
|
|
|
1457
1309
|
};
|
|
1458
1310
|
const stored = Object.entries(existing).filter(([key]) => {
|
|
1459
1311
|
const spec = collection.schema.fields[key];
|
|
1460
|
-
return !spec || !
|
|
1312
|
+
return !spec || !require_itemId.COMPUTED_TYPES.has(spec.type);
|
|
1461
1313
|
});
|
|
1462
1314
|
const merged = {
|
|
1463
1315
|
...Object.fromEntries(stored),
|
|
1464
|
-
...
|
|
1316
|
+
...require_discovery.resolveMutateSet(action.set, params)
|
|
1465
1317
|
};
|
|
1466
1318
|
const invalid = validateRecordObject(merged, itemId, collection.schema);
|
|
1467
1319
|
if (invalid) return {
|
|
@@ -1519,10 +1371,10 @@ function schemaRelations(schema) {
|
|
|
1519
1371
|
* Fail-soft: a missing dir or a dataDir escaping the workspace via
|
|
1520
1372
|
* symlink counts 0. */
|
|
1521
1373
|
async function countRecordFiles(dataDir, workspaceRoot) {
|
|
1522
|
-
if (!
|
|
1374
|
+
if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) return 0;
|
|
1523
1375
|
try {
|
|
1524
1376
|
const names = (await (0, node_fs_promises.readdir)(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
|
|
1525
|
-
return (await Promise.all(names.map((name) =>
|
|
1377
|
+
return (await Promise.all(names.map((name) => require_discovery.isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
|
|
1526
1378
|
} catch {
|
|
1527
1379
|
return 0;
|
|
1528
1380
|
}
|
|
@@ -1534,10 +1386,10 @@ async function countRecordFiles(dataDir, workspaceRoot) {
|
|
|
1534
1386
|
* `countRecordFiles` (an unreadable file / missing engine must not
|
|
1535
1387
|
* break the whole ontology). */
|
|
1536
1388
|
async function countRecords(collection, workspaceRoot) {
|
|
1537
|
-
if (
|
|
1538
|
-
return (await
|
|
1389
|
+
if (require_itemId.storageKindFor(collection.schema) !== "file") try {
|
|
1390
|
+
return (await require_discovery.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
|
|
1539
1391
|
} catch (err) {
|
|
1540
|
-
if (
|
|
1392
|
+
if (require_discovery.isBackendUnavailable(err)) return null;
|
|
1541
1393
|
return 0;
|
|
1542
1394
|
}
|
|
1543
1395
|
return countRecordFiles(collection.dataDir, workspaceRoot);
|
|
@@ -1558,8 +1410,8 @@ async function toOntologyEntry(collection, workspaceRoot) {
|
|
|
1558
1410
|
* discovery's order), each with its outbound relations and a
|
|
1559
1411
|
* readdir-cheap record count — fine to call on demand. */
|
|
1560
1412
|
async function buildWorkspaceOntology(opts = {}) {
|
|
1561
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1562
|
-
const collections = await
|
|
1413
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
1414
|
+
const collections = await require_discovery.discoverCollections(opts);
|
|
1563
1415
|
return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
|
|
1564
1416
|
}
|
|
1565
1417
|
//#endregion
|
|
@@ -1579,7 +1431,7 @@ function buildRecordsById(items, primaryKey) {
|
|
|
1579
1431
|
* ties pick a different record — and thus a different icon — between
|
|
1580
1432
|
* reconciles. A stable id sort pins one answer. */
|
|
1581
1433
|
function sortByPrimaryKey(items, primaryKey) {
|
|
1582
|
-
return [...items].sort((left, right) =>
|
|
1434
|
+
return [...items].sort((left, right) => require_itemId.fieldText(left[primaryKey]).localeCompare(require_itemId.fieldText(right[primaryKey])));
|
|
1583
1435
|
}
|
|
1584
1436
|
/** Compute the effective launcher icon for `collection`: its static
|
|
1585
1437
|
* `schema.icon` when it declares no `dynamicIcon`, else the icon
|
|
@@ -1593,15 +1445,15 @@ async function computeCollectionIcon(collection, opts = {}) {
|
|
|
1593
1445
|
const spec = schema.dynamicIcon;
|
|
1594
1446
|
if (!spec) return schema.icon;
|
|
1595
1447
|
try {
|
|
1596
|
-
const source = await
|
|
1448
|
+
const source = await require_discovery.loadCollection(spec.source.collection, opts);
|
|
1597
1449
|
if (!source) return spec.fallback ?? schema.icon;
|
|
1598
|
-
const ordered = sortByPrimaryKey(await
|
|
1450
|
+
const ordered = sortByPrimaryKey(await require_discovery.storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
|
|
1599
1451
|
const orderBy = spec.source.orderBy ?? require_promptSafety.firstDateField(source.schema);
|
|
1600
1452
|
const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
|
|
1601
1453
|
const record = require_promptSafety.selectDynamicRecord(ordered, spec.source, orderBy, recordsById);
|
|
1602
1454
|
return require_promptSafety.resolveIcon(record, spec, schema.icon, recordsById);
|
|
1603
1455
|
} catch (err) {
|
|
1604
|
-
|
|
1456
|
+
require_discovery.log.warn("collections", "dynamic icon compute failed, falling back", {
|
|
1605
1457
|
slug: collection.slug,
|
|
1606
1458
|
source: spec.source.collection,
|
|
1607
1459
|
error: String(err)
|
|
@@ -1716,7 +1568,7 @@ function successorId(sourceId, next) {
|
|
|
1716
1568
|
function matchesWhen(when, schema, item) {
|
|
1717
1569
|
if (when) {
|
|
1718
1570
|
const raw = item[when.field];
|
|
1719
|
-
const text =
|
|
1571
|
+
const text = require_itemId.fieldTextOrNull(raw);
|
|
1720
1572
|
return text !== null && when.in.includes(text);
|
|
1721
1573
|
}
|
|
1722
1574
|
return require_promptSafety.itemIsDone(schema, item);
|
|
@@ -1728,10 +1580,10 @@ function matchesWhen(when, schema, item) {
|
|
|
1728
1580
|
* Discovery rejects a map that doesn't cover the enum's values, so null
|
|
1729
1581
|
* here means a record that predates a map/enum edit. */
|
|
1730
1582
|
function resolveEvery(every, sourceItem) {
|
|
1731
|
-
if (!
|
|
1583
|
+
if (!require_itemId.isFieldDrivenEvery(every)) return every;
|
|
1732
1584
|
const raw = sourceItem[every.fromField];
|
|
1733
1585
|
if (raw === void 0 || raw === null || raw === "") return null;
|
|
1734
|
-
const key =
|
|
1586
|
+
const key = require_itemId.fieldTextOrNull(raw);
|
|
1735
1587
|
if (key === null || !Object.hasOwn(every.map, key)) return null;
|
|
1736
1588
|
return every.map[key] ?? null;
|
|
1737
1589
|
}
|
|
@@ -1763,15 +1615,15 @@ function computeSuccessor(schema, sourceItem, sourceId) {
|
|
|
1763
1615
|
* predates a map/enum edit — discovery rejects this statically otherwise). */
|
|
1764
1616
|
function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
|
|
1765
1617
|
if (parseCivil(sourceItem[triggerField]) === null) {
|
|
1766
|
-
|
|
1618
|
+
require_discovery.log.warn("collections", "spawn skipped: source trigger date unparseable", {
|
|
1767
1619
|
slug,
|
|
1768
1620
|
sourceId,
|
|
1769
1621
|
triggerField
|
|
1770
1622
|
});
|
|
1771
1623
|
return;
|
|
1772
1624
|
}
|
|
1773
|
-
const fromField =
|
|
1774
|
-
|
|
1625
|
+
const fromField = require_itemId.isFieldDrivenEvery(every) ? every.fromField : void 0;
|
|
1626
|
+
require_discovery.log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
|
|
1775
1627
|
slug,
|
|
1776
1628
|
sourceId,
|
|
1777
1629
|
fromField,
|
|
@@ -1794,7 +1646,7 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
|
|
|
1794
1646
|
return;
|
|
1795
1647
|
}
|
|
1796
1648
|
if (matchesWhen(spawn.when, schema, computed.record)) {
|
|
1797
|
-
|
|
1649
|
+
require_discovery.log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
|
|
1798
1650
|
slug,
|
|
1799
1651
|
sourceId,
|
|
1800
1652
|
successorId: computed.id
|
|
@@ -1802,28 +1654,28 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
|
|
|
1802
1654
|
return;
|
|
1803
1655
|
}
|
|
1804
1656
|
try {
|
|
1805
|
-
const { write } =
|
|
1657
|
+
const { write } = require_discovery.storeFor(collection, ioOpts);
|
|
1806
1658
|
if (!write) {
|
|
1807
|
-
|
|
1659
|
+
require_discovery.log.warn("collections", "spawn skipped: collection store is read-only", {
|
|
1808
1660
|
slug,
|
|
1809
1661
|
sourceId
|
|
1810
1662
|
});
|
|
1811
1663
|
return;
|
|
1812
1664
|
}
|
|
1813
1665
|
const result = await write(computed.id, computed.record, { refuseOverwrite: true });
|
|
1814
|
-
if (result.kind === "ok")
|
|
1666
|
+
if (result.kind === "ok") require_discovery.log.info("collections", "spawned successor", {
|
|
1815
1667
|
slug,
|
|
1816
1668
|
sourceId,
|
|
1817
1669
|
successorId: computed.id
|
|
1818
1670
|
});
|
|
1819
|
-
else if (result.kind !== "conflict")
|
|
1671
|
+
else if (result.kind !== "conflict") require_discovery.log.warn("collections", "spawn write failed", {
|
|
1820
1672
|
slug,
|
|
1821
1673
|
sourceId,
|
|
1822
1674
|
successorId: computed.id,
|
|
1823
1675
|
kind: result.kind
|
|
1824
1676
|
});
|
|
1825
1677
|
} catch (err) {
|
|
1826
|
-
|
|
1678
|
+
require_discovery.log.warn("collections", "spawn write threw", {
|
|
1827
1679
|
slug,
|
|
1828
1680
|
sourceId,
|
|
1829
1681
|
successorId: computed.id,
|
|
@@ -1862,7 +1714,7 @@ function todayStamp() {
|
|
|
1862
1714
|
/** Every directory the delete will touch must resolve under the
|
|
1863
1715
|
* workspace root — guards against a symlinked ancestor escaping it. */
|
|
1864
1716
|
function deleteTargets(collection, workspaceRoot) {
|
|
1865
|
-
const staging =
|
|
1717
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1866
1718
|
return [
|
|
1867
1719
|
...staging === null ? [] : [staging],
|
|
1868
1720
|
collection.skillDir,
|
|
@@ -1974,12 +1826,12 @@ function recordLocationLabel(schema) {
|
|
|
1974
1826
|
}
|
|
1975
1827
|
/** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
|
|
1976
1828
|
async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
1977
|
-
const staging =
|
|
1829
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1978
1830
|
const skillSrc = staging !== null && await pathExists(staging) ? staging : collection.skillDir;
|
|
1979
1831
|
await (0, node_fs_promises.cp)(skillSrc, node_path.default.join(archiveDir, "skill"), { recursive: true });
|
|
1980
1832
|
if (await pathExists(collection.dataDir)) await (0, node_fs_promises.cp)(collection.dataDir, node_path.default.join(archiveDir, "records"), { recursive: true });
|
|
1981
1833
|
if (collection.storageFile !== void 0 && await pathExists(collection.storageFile)) {
|
|
1982
|
-
const checkpointed = await
|
|
1834
|
+
const checkpointed = await require_discovery.checkpointSqliteDatabase(collection.storageFile);
|
|
1983
1835
|
await (0, node_fs_promises.cp)(collection.storageFile, node_path.default.join(archiveDir, node_path.default.basename(collection.storageFile)));
|
|
1984
1836
|
if (!checkpointed) for (const suffix of [
|
|
1985
1837
|
"-wal",
|
|
@@ -1996,7 +1848,7 @@ async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
|
1996
1848
|
* is a no-op; the now-empty data parent (`data/<slug>/` after its
|
|
1997
1849
|
* `items/` is gone) is swept too, but only when empty. */
|
|
1998
1850
|
async function removeLocations(collection, workspaceRoot) {
|
|
1999
|
-
const staging =
|
|
1851
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
|
|
2000
1852
|
if (staging !== null) await (0, node_fs_promises.rm)(staging, {
|
|
2001
1853
|
recursive: true,
|
|
2002
1854
|
force: true
|
|
@@ -2020,24 +1872,24 @@ async function removeLocations(collection, workspaceRoot) {
|
|
|
2020
1872
|
}
|
|
2021
1873
|
async function deleteCollection(collection, opts = {}) {
|
|
2022
1874
|
const { slug } = collection;
|
|
2023
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1875
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
2024
1876
|
if (collection.source === "user") return {
|
|
2025
1877
|
kind: "user-scope",
|
|
2026
1878
|
slug
|
|
2027
1879
|
};
|
|
2028
|
-
if (
|
|
1880
|
+
if (require_discovery.isPresetSlug(slug)) return {
|
|
2029
1881
|
kind: "preset",
|
|
2030
1882
|
slug
|
|
2031
1883
|
};
|
|
2032
1884
|
if (collection.schema.storage?.type === "firestore") {
|
|
2033
|
-
|
|
1885
|
+
require_discovery.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
|
|
2034
1886
|
return {
|
|
2035
1887
|
kind: "unsupported-backend",
|
|
2036
1888
|
slug
|
|
2037
1889
|
};
|
|
2038
1890
|
}
|
|
2039
1891
|
if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
|
|
2040
|
-
|
|
1892
|
+
require_discovery.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
|
|
2041
1893
|
slug,
|
|
2042
1894
|
dataDir: collection.dataDir
|
|
2043
1895
|
});
|
|
@@ -2046,19 +1898,19 @@ async function deleteCollection(collection, opts = {}) {
|
|
|
2046
1898
|
slug
|
|
2047
1899
|
};
|
|
2048
1900
|
}
|
|
2049
|
-
if (deleteTargets(collection, workspaceRoot).some((target) => !
|
|
2050
|
-
|
|
1901
|
+
if (deleteTargets(collection, workspaceRoot).some((target) => !require_discovery.isContainedInRoot(target, workspaceRoot))) {
|
|
1902
|
+
require_discovery.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
|
|
2051
1903
|
return {
|
|
2052
1904
|
kind: "path-escape",
|
|
2053
1905
|
slug
|
|
2054
1906
|
};
|
|
2055
1907
|
}
|
|
2056
|
-
const archiveRel = node_path.default.join(
|
|
1908
|
+
const archiveRel = node_path.default.join(require_discovery.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
|
|
2057
1909
|
const archiveDir$1 = node_path.default.join(workspaceRoot, archiveRel);
|
|
2058
1910
|
await (0, node_fs_promises.mkdir)(archiveDir$1, { recursive: true });
|
|
2059
1911
|
await writeArchive(collection, archiveDir$1, workspaceRoot);
|
|
2060
1912
|
await removeLocations(collection, workspaceRoot);
|
|
2061
|
-
|
|
1913
|
+
require_discovery.log.info("collections", "collection deleted + archived", {
|
|
2062
1914
|
slug,
|
|
2063
1915
|
archive: archiveRel
|
|
2064
1916
|
});
|
|
@@ -2088,7 +1940,7 @@ async function fileExists(target) {
|
|
|
2088
1940
|
* deletes agree on both layouts. */
|
|
2089
1941
|
async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
2090
1942
|
if (collection.source !== "project") return collection.skillDir;
|
|
2091
|
-
const staging =
|
|
1943
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
|
|
2092
1944
|
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) return staging;
|
|
2093
1945
|
return collection.skillDir;
|
|
2094
1946
|
}
|
|
@@ -2098,11 +1950,11 @@ async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
|
2098
1950
|
* project collection (no staging mirror) doesn't have an empty staging tree
|
|
2099
1951
|
* materialised by a side effect of the delete. */
|
|
2100
1952
|
async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
|
|
2101
|
-
const active = node_path.default.join(collection.skillDir,
|
|
1953
|
+
const active = node_path.default.join(collection.skillDir, require_discovery.SCHEMA_FILE);
|
|
2102
1954
|
if (collection.source !== "project") return [active];
|
|
2103
|
-
const staging =
|
|
1955
|
+
const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
|
|
2104
1956
|
const targets = [];
|
|
2105
|
-
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging,
|
|
1957
|
+
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_discovery.SCHEMA_FILE));
|
|
2106
1958
|
targets.push(active);
|
|
2107
1959
|
return targets;
|
|
2108
1960
|
}
|
|
@@ -2121,7 +1973,7 @@ async function unlinkIfPresent(target) {
|
|
|
2121
1973
|
* preserved verbatim. */
|
|
2122
1974
|
async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
|
|
2123
1975
|
const base = await canonicalBase(collection, workspaceRoot, safeSlug);
|
|
2124
|
-
const canonical = node_path.default.join(base,
|
|
1976
|
+
const canonical = node_path.default.join(base, require_discovery.SCHEMA_FILE);
|
|
2125
1977
|
const parsed = JSON.parse(await (0, node_fs_promises.readFile)(canonical, "utf-8"));
|
|
2126
1978
|
const next = require_dist.isRecord(parsed) && require_dist.isUnknownArray(parsed.views) ? {
|
|
2127
1979
|
...parsed,
|
|
@@ -2135,8 +1987,8 @@ async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug
|
|
|
2135
1987
|
* refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
|
|
2136
1988
|
async function deleteCustomView(collection, viewId, opts = {}) {
|
|
2137
1989
|
if (collection.source === "user") return { kind: "user-scope" };
|
|
2138
|
-
if (
|
|
2139
|
-
const safeSlug =
|
|
1990
|
+
if (require_discovery.isPresetSlug(collection.slug)) return { kind: "preset" };
|
|
1991
|
+
const safeSlug = require_discovery.safeSlugName(collection.slug);
|
|
2140
1992
|
if (safeSlug === null) return {
|
|
2141
1993
|
kind: "unsafe-path",
|
|
2142
1994
|
viewId
|
|
@@ -2147,8 +1999,8 @@ async function deleteCustomView(collection, viewId, opts = {}) {
|
|
|
2147
1999
|
kind: "not-found",
|
|
2148
2000
|
viewId
|
|
2149
2001
|
};
|
|
2150
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
2151
|
-
const htmlPath =
|
|
2002
|
+
const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
2003
|
+
const htmlPath = require_discovery.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
|
|
2152
2004
|
if (htmlPath === null) return {
|
|
2153
2005
|
kind: "unsafe-path",
|
|
2154
2006
|
viewId
|
|
@@ -2375,7 +2227,7 @@ var SCHEMA_DOCS_PROBE_SLUG = "_";
|
|
|
2375
2227
|
/** Resolve the workspace root the same way every collections call does:
|
|
2376
2228
|
* the injected override (tests) or the configured collection host. */
|
|
2377
2229
|
function resolveBase(deps) {
|
|
2378
|
-
return deps.workspaceRoot ??
|
|
2230
|
+
return deps.workspaceRoot ?? require_discovery.getWorkspaceRoot();
|
|
2379
2231
|
}
|
|
2380
2232
|
/** Where a collection skill is authored in THIS root, and therefore both which
|
|
2381
2233
|
* authoring guide `schemaDocs` serves and where `getSchema` / `putSchema`
|
|
@@ -2389,7 +2241,7 @@ function resolveBase(deps) {
|
|
|
2389
2241
|
* from there, so the tool would silently contradict its own documentation.
|
|
2390
2242
|
* Staged requires BOTH to agree; anything else is direct. */
|
|
2391
2243
|
function authoringTarget(deps, slug) {
|
|
2392
|
-
const stagingDir = deps.stagedSkillAuthoring === false ? null :
|
|
2244
|
+
const stagingDir = deps.stagedSkillAuthoring === false ? null : require_discovery.stagingSkillDir(resolveBase(deps), slug);
|
|
2393
2245
|
return {
|
|
2394
2246
|
variant: stagingDir === null ? "direct" : "staged",
|
|
2395
2247
|
stagingDir
|
|
@@ -2432,7 +2284,7 @@ async function recordIssuesWarning(collection, deps) {
|
|
|
2432
2284
|
return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
|
|
2433
2285
|
}
|
|
2434
2286
|
async function loadRequestedItems(collection, ids, deps) {
|
|
2435
|
-
const store =
|
|
2287
|
+
const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2436
2288
|
if (!ids) return {
|
|
2437
2289
|
items: await store.list(),
|
|
2438
2290
|
missing: []
|
|
@@ -2441,7 +2293,7 @@ async function loadRequestedItems(collection, ids, deps) {
|
|
|
2441
2293
|
const missing = [];
|
|
2442
2294
|
for (const recordId of ids) {
|
|
2443
2295
|
const item = await store.read(recordId).catch((err) => {
|
|
2444
|
-
if (
|
|
2296
|
+
if (require_discovery.isBackendUnavailable(err)) throw err;
|
|
2445
2297
|
return null;
|
|
2446
2298
|
});
|
|
2447
2299
|
if (item) items.push(item);
|
|
@@ -2472,7 +2324,7 @@ async function handleGetItems(collection, args, deps) {
|
|
|
2472
2324
|
function computedKeyProblem(record, schema) {
|
|
2473
2325
|
for (const key of Object.keys(record)) {
|
|
2474
2326
|
const spec = schema.fields[key];
|
|
2475
|
-
if (!spec || !
|
|
2327
|
+
if (!spec || !require_itemId.COMPUTED_TYPES.has(spec.type)) continue;
|
|
2476
2328
|
if (spec.type === "toggle" && spec.field) return `'${key}' is a toggle projection — write the enum field '${spec.field}' instead`;
|
|
2477
2329
|
return `'${key}' is ${{
|
|
2478
2330
|
derived: "derived",
|
|
@@ -2506,13 +2358,13 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
2506
2358
|
try {
|
|
2507
2359
|
existing = await store.read(itemId);
|
|
2508
2360
|
} catch (err) {
|
|
2509
|
-
if (
|
|
2361
|
+
if (require_discovery.isBackendUnavailable(err)) throw err;
|
|
2510
2362
|
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
2363
|
}
|
|
2512
2364
|
if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
|
|
2513
2365
|
const stored = Object.entries(existing).filter(([key]) => {
|
|
2514
2366
|
const spec = collection.schema.fields[key];
|
|
2515
|
-
return !spec || !
|
|
2367
|
+
return !spec || !require_itemId.COMPUTED_TYPES.has(spec.type);
|
|
2516
2368
|
});
|
|
2517
2369
|
return {
|
|
2518
2370
|
...Object.fromEntries(stored),
|
|
@@ -2521,7 +2373,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
2521
2373
|
}
|
|
2522
2374
|
async function putOneItem(collection, store, write, record, mode, deps) {
|
|
2523
2375
|
const { schema } = collection;
|
|
2524
|
-
const itemId =
|
|
2376
|
+
const itemId = require_discovery.resolveCreateItemId(schema, record);
|
|
2525
2377
|
const reject = (about, problem) => ({ rejected: {
|
|
2526
2378
|
id: require_promptSafety.defangForPrompt(about),
|
|
2527
2379
|
problem: require_promptSafety.defangForPrompt(problem)
|
|
@@ -2552,7 +2404,7 @@ async function putOneItem(collection, store, write, record, mode, deps) {
|
|
|
2552
2404
|
* `runCollectionQuery`, shared with the desktop custom view's `/query`
|
|
2553
2405
|
* route so the two surfaces can never drift. */
|
|
2554
2406
|
async function handleQueryItems(collection, queryArg, deps) {
|
|
2555
|
-
const parsed =
|
|
2407
|
+
const parsed = require_discovery.CollectionQueryZ.safeParse(queryArg);
|
|
2556
2408
|
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
2409
|
const rows = await runCollectionQuery(collection, parsed.data, { workspaceRoot: deps.workspaceRoot });
|
|
2558
2410
|
return JSON.stringify({
|
|
@@ -2562,9 +2414,9 @@ async function handleQueryItems(collection, queryArg, deps) {
|
|
|
2562
2414
|
});
|
|
2563
2415
|
}
|
|
2564
2416
|
async function handlePutItems(collection, args, deps) {
|
|
2565
|
-
const store =
|
|
2417
|
+
const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2566
2418
|
const { write } = store;
|
|
2567
|
-
if (!write) return `manageCollection: ${
|
|
2419
|
+
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
2420
|
const written = [];
|
|
2569
2421
|
const rejected = [];
|
|
2570
2422
|
for (const record of args.items) {
|
|
@@ -2583,8 +2435,8 @@ async function handlePutItems(collection, args, deps) {
|
|
|
2583
2435
|
* absent `delete` IS the refusal), and the same per-id result shape so a
|
|
2584
2436
|
* partially-bad batch reports per id instead of failing whole. */
|
|
2585
2437
|
async function handleDeleteItems(collection, ids, deps) {
|
|
2586
|
-
const { delete: removeItem } =
|
|
2587
|
-
if (!removeItem) return `manageCollection: ${
|
|
2438
|
+
const { delete: removeItem } = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
2439
|
+
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
2440
|
const deleted = [];
|
|
2589
2441
|
const rejected = [];
|
|
2590
2442
|
for (const itemId of ids) {
|
|
@@ -2653,32 +2505,6 @@ async function handleGetOntology(deps) {
|
|
|
2653
2505
|
collections
|
|
2654
2506
|
});
|
|
2655
2507
|
}
|
|
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
2508
|
/** Return the collection-authoring reference (`collection-skills.md`),
|
|
2683
2509
|
* rendered by `renderSchemaDocs` — the full doc overflows the agent's
|
|
2684
2510
|
* per-result limit, so the default reply is the core guide + a table of
|
|
@@ -2699,7 +2525,7 @@ async function handleSchemaDocs(deps, topic) {
|
|
|
2699
2525
|
* fallback for user-scope skills that have no staging copy. Raw text —
|
|
2700
2526
|
* not the parsed schema — so the agent edits the true on-disk source. */
|
|
2701
2527
|
async function handleGetSchema(slug, deps) {
|
|
2702
|
-
const collection = await
|
|
2528
|
+
const collection = await require_discovery.loadCollection(slug, deps);
|
|
2703
2529
|
if (!collection) return unknownCollection(slug);
|
|
2704
2530
|
const { stagingDir } = authoringTarget(deps, collection.slug);
|
|
2705
2531
|
const candidates = [...stagingDir === null ? [] : [node_path.default.join(stagingDir, SCHEMA_FILE)], node_path.default.join(collection.skillDir, SCHEMA_FILE)];
|
|
@@ -2761,11 +2587,11 @@ async function writeAndMirrorSchema(slug, skillDir, schema, deps) {
|
|
|
2761
2587
|
* gate doesn't apply. Returns a one-line reason, or null when the schema
|
|
2762
2588
|
* would be accepted. */
|
|
2763
2589
|
function schemaDiscoveryGate(schema, base) {
|
|
2764
|
-
const primaryField =
|
|
2590
|
+
const primaryField = require_discovery.resolvePrimaryField(schema.fields, schema.primaryKey);
|
|
2765
2591
|
if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
|
|
2766
2592
|
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 &&
|
|
2593
|
+
if (schema.dataPath !== void 0 && require_discovery.resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
|
|
2594
|
+
if (schema.dataSource !== void 0 && require_discovery.resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
|
|
2769
2595
|
return null;
|
|
2770
2596
|
}
|
|
2771
2597
|
/** Validate a schema against CollectionSchemaZ and, on success, persist it.
|
|
@@ -2773,11 +2599,11 @@ function schemaDiscoveryGate(schema, base) {
|
|
|
2773
2599
|
* the root's authoring dir (the normal create flow), not through here. */
|
|
2774
2600
|
async function handlePutSchema(slug, schemaArg, deps) {
|
|
2775
2601
|
if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
|
|
2776
|
-
const collection = await
|
|
2602
|
+
const collection = await require_discovery.loadCollection(slug, deps);
|
|
2777
2603
|
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
2604
|
const refusal = schemaEditRefusal(collection, slug, deps);
|
|
2779
2605
|
if (refusal) return refusal;
|
|
2780
|
-
const parsed =
|
|
2606
|
+
const parsed = require_discovery.CollectionSchemaZ.safeParse(schemaArg);
|
|
2781
2607
|
if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
|
|
2782
2608
|
const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
|
|
2783
2609
|
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 +2613,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
2787
2613
|
written: true
|
|
2788
2614
|
});
|
|
2789
2615
|
}
|
|
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.
|
|
2616
|
+
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
2617
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
2792
2618
|
async function dispatchGetItems(collection, args, deps) {
|
|
2793
2619
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -2824,7 +2650,7 @@ async function manageCollectionHandler(deps, args) {
|
|
|
2824
2650
|
try {
|
|
2825
2651
|
return await dispatchManageCollection(deps, args);
|
|
2826
2652
|
} catch (err) {
|
|
2827
|
-
if (
|
|
2653
|
+
if (require_discovery.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
|
|
2828
2654
|
throw err;
|
|
2829
2655
|
}
|
|
2830
2656
|
}
|
|
@@ -2832,19 +2658,18 @@ async function dispatchManageCollection(deps, args) {
|
|
|
2832
2658
|
const action = typeof args.action === "string" ? args.action : "";
|
|
2833
2659
|
if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
|
|
2834
2660
|
if (action === "getOntology") return handleGetOntology(deps);
|
|
2835
|
-
if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
|
|
2836
2661
|
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
2837
2662
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
2838
2663
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
2839
2664
|
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
|
|
2665
|
+
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
2666
|
+
const collection = await require_discovery.loadCollection(slug, deps);
|
|
2842
2667
|
if (!collection) return unknownCollection(slug);
|
|
2843
2668
|
return dispatchRecordAction(action, collection, args, deps);
|
|
2844
2669
|
}
|
|
2845
2670
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
2846
2671
|
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.
|
|
2672
|
+
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
2673
|
inputSchema: {
|
|
2849
2674
|
type: "object",
|
|
2850
2675
|
properties: {
|
|
@@ -2858,8 +2683,7 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2858
2683
|
"getOntology",
|
|
2859
2684
|
"schemaDocs",
|
|
2860
2685
|
"getSchema",
|
|
2861
|
-
"putSchema"
|
|
2862
|
-
"publishApp"
|
|
2686
|
+
"putSchema"
|
|
2863
2687
|
],
|
|
2864
2688
|
description: "What to do."
|
|
2865
2689
|
},
|
|
@@ -2895,10 +2719,6 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2895
2719
|
type: "object",
|
|
2896
2720
|
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
2721
|
},
|
|
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
2722
|
topic: {
|
|
2903
2723
|
type: "string",
|
|
2904
2724
|
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 +2752,12 @@ Object.defineProperty(exports, "APP_ROLES", {
|
|
|
2932
2752
|
return APP_ROLES;
|
|
2933
2753
|
}
|
|
2934
2754
|
});
|
|
2755
|
+
Object.defineProperty(exports, "APP_SLUGS_COLLECTION", {
|
|
2756
|
+
enumerable: true,
|
|
2757
|
+
get: function() {
|
|
2758
|
+
return APP_SLUGS_COLLECTION;
|
|
2759
|
+
}
|
|
2760
|
+
});
|
|
2935
2761
|
Object.defineProperty(exports, "AuthoredAppZ", {
|
|
2936
2762
|
enumerable: true,
|
|
2937
2763
|
get: function() {
|
|
@@ -2992,6 +2818,18 @@ Object.defineProperty(exports, "appSchemasPath", {
|
|
|
2992
2818
|
return appSchemasPath;
|
|
2993
2819
|
}
|
|
2994
2820
|
});
|
|
2821
|
+
Object.defineProperty(exports, "appSlugDoc", {
|
|
2822
|
+
enumerable: true,
|
|
2823
|
+
get: function() {
|
|
2824
|
+
return appSlugDoc;
|
|
2825
|
+
}
|
|
2826
|
+
});
|
|
2827
|
+
Object.defineProperty(exports, "appStagingPath", {
|
|
2828
|
+
enumerable: true,
|
|
2829
|
+
get: function() {
|
|
2830
|
+
return appStagingPath;
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2995
2833
|
Object.defineProperty(exports, "applyMutateAction", {
|
|
2996
2834
|
enumerable: true,
|
|
2997
2835
|
get: function() {
|
|
@@ -3118,16 +2956,28 @@ Object.defineProperty(exports, "projectApp", {
|
|
|
3118
2956
|
return projectApp;
|
|
3119
2957
|
}
|
|
3120
2958
|
});
|
|
3121
|
-
Object.defineProperty(exports, "
|
|
2959
|
+
Object.defineProperty(exports, "projectDeploy", {
|
|
3122
2960
|
enumerable: true,
|
|
3123
2961
|
get: function() {
|
|
3124
|
-
return
|
|
2962
|
+
return projectDeploy;
|
|
2963
|
+
}
|
|
2964
|
+
});
|
|
2965
|
+
Object.defineProperty(exports, "projectPublish", {
|
|
2966
|
+
enumerable: true,
|
|
2967
|
+
get: function() {
|
|
2968
|
+
return projectPublish;
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
Object.defineProperty(exports, "promoteSchema", {
|
|
2972
|
+
enumerable: true,
|
|
2973
|
+
get: function() {
|
|
2974
|
+
return promoteSchema;
|
|
3125
2975
|
}
|
|
3126
2976
|
});
|
|
3127
|
-
Object.defineProperty(exports, "
|
|
2977
|
+
Object.defineProperty(exports, "promptPathsFor", {
|
|
3128
2978
|
enumerable: true,
|
|
3129
2979
|
get: function() {
|
|
3130
|
-
return
|
|
2980
|
+
return promptPathsFor;
|
|
3131
2981
|
}
|
|
3132
2982
|
});
|
|
3133
2983
|
Object.defineProperty(exports, "publishProblems", {
|
|
@@ -3184,6 +3034,12 @@ Object.defineProperty(exports, "schemaRelations", {
|
|
|
3184
3034
|
return schemaRelations;
|
|
3185
3035
|
}
|
|
3186
3036
|
});
|
|
3037
|
+
Object.defineProperty(exports, "stagedRuleConfig", {
|
|
3038
|
+
enumerable: true,
|
|
3039
|
+
get: function() {
|
|
3040
|
+
return stagedRuleConfig;
|
|
3041
|
+
}
|
|
3042
|
+
});
|
|
3187
3043
|
Object.defineProperty(exports, "successorId", {
|
|
3188
3044
|
enumerable: true,
|
|
3189
3045
|
get: function() {
|
|
@@ -3203,4 +3059,4 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
3203
3059
|
}
|
|
3204
3060
|
});
|
|
3205
3061
|
|
|
3206
|
-
//# sourceMappingURL=server-
|
|
3062
|
+
//# sourceMappingURL=server-CgkkWWBd.cjs.map
|