@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.
Files changed (74) hide show
  1. package/dist/collection/core/itemId.d.ts +28 -0
  2. package/dist/collection/firestore.cjs +9 -1
  3. package/dist/collection/firestore.cjs.map +1 -1
  4. package/dist/collection/firestore.js +10 -2
  5. package/dist/collection/firestore.js.map +1 -1
  6. package/dist/collection/index.cjs +55 -66
  7. package/dist/collection/index.cjs.map +1 -1
  8. package/dist/collection/index.d.ts +1 -1
  9. package/dist/collection/index.js +3 -15
  10. package/dist/collection/index.js.map +1 -1
  11. package/dist/collection/registry/server/index.cjs +19 -19
  12. package/dist/collection/registry/server/index.cjs.map +1 -1
  13. package/dist/collection/registry/server/index.js +2 -2
  14. package/dist/collection/server/firestoreDocs.d.ts +18 -0
  15. package/dist/collection/server/host.d.ts +29 -0
  16. package/dist/collection/server/index.cjs +70 -62
  17. package/dist/collection/server/index.d.ts +2 -3
  18. package/dist/collection/server/index.js +4 -4
  19. package/dist/collection/server/io.d.ts +5 -3
  20. package/dist/collection/server/manageTool.d.ts +0 -4
  21. package/dist/collection/server/publishProject.d.ts +171 -2
  22. package/dist/collection-watchers/index.cjs +82 -38
  23. package/dist/collection-watchers/index.cjs.map +1 -1
  24. package/dist/collection-watchers/index.js +67 -23
  25. package/dist/collection-watchers/index.js.map +1 -1
  26. package/dist/{store-_61sO8K8.js → discovery-B9zdNkgW.js} +2525 -2354
  27. package/dist/discovery-B9zdNkgW.js.map +1 -0
  28. package/dist/{store-5_P_NsGa.cjs → discovery-BsXiDZBR.cjs} +2545 -2362
  29. package/dist/discovery-BsXiDZBR.cjs.map +1 -0
  30. package/dist/feeds/index.cjs +4 -4
  31. package/dist/feeds/index.js +2 -2
  32. package/dist/feeds/server/index.cjs +14 -14
  33. package/dist/feeds/server/index.cjs.map +1 -1
  34. package/dist/feeds/server/index.js +4 -4
  35. package/dist/firestore/listen.d.ts +4 -0
  36. package/dist/google/index.cjs +16 -16
  37. package/dist/google/index.cjs.map +1 -1
  38. package/dist/google/index.js +2 -2
  39. package/dist/{ingestTypes-CEi-Ot7F.js → ingestTypes-B312BwXg.js} +2 -2
  40. package/dist/{ingestTypes-CEi-Ot7F.js.map → ingestTypes-B312BwXg.js.map} +1 -1
  41. package/dist/{ingestTypes-iaiq33Ph.cjs → ingestTypes-B4XfrUoq.cjs} +3 -3
  42. package/dist/{ingestTypes-iaiq33Ph.cjs.map → ingestTypes-B4XfrUoq.cjs.map} +1 -1
  43. package/dist/{calendarGrid-CQ8MVSRb.js → itemId-DfTm08jm.js} +34 -2
  44. package/dist/itemId-DfTm08jm.js.map +1 -0
  45. package/dist/{calendarGrid-DGILaVxI.cjs → itemId-Dzrr3vs5.cjs} +45 -1
  46. package/dist/itemId-Dzrr3vs5.cjs.map +1 -0
  47. package/dist/listen-D5av7GzB.js +22 -0
  48. package/dist/listen-D5av7GzB.js.map +1 -0
  49. package/dist/listen-UZA68Upt.cjs +45 -0
  50. package/dist/listen-UZA68Upt.cjs.map +1 -0
  51. package/dist/{promptSafety-CpiME8pj.js → promptSafety-CSdyp7RF.js} +2 -2
  52. package/dist/{promptSafety-CpiME8pj.js.map → promptSafety-CSdyp7RF.js.map} +1 -1
  53. package/dist/{promptSafety-NNGViiCr.cjs → promptSafety-CWqYq-NS.cjs} +8 -8
  54. package/dist/{promptSafety-NNGViiCr.cjs.map → promptSafety-CWqYq-NS.cjs.map} +1 -1
  55. package/dist/remote-host/server/hostRunner.d.ts +2 -4
  56. package/dist/remote-host/server/index.cjs +4 -19
  57. package/dist/remote-host/server/index.cjs.map +1 -1
  58. package/dist/remote-host/server/index.js +1 -16
  59. package/dist/remote-host/server/index.js.map +1 -1
  60. package/dist/{server-B48Jyxcj.js → server-BNe8XMtu.js} +388 -568
  61. package/dist/server-BNe8XMtu.js.map +1 -0
  62. package/dist/{server-CWZyg8fn.cjs → server-CgkkWWBd.cjs} +590 -734
  63. package/dist/server-CgkkWWBd.cjs.map +1 -0
  64. package/dist/whisper/index.cjs +1 -1
  65. package/dist/whisper/index.js +1 -1
  66. package/package.json +1 -1
  67. package/dist/calendarGrid-CQ8MVSRb.js.map +0 -1
  68. package/dist/calendarGrid-DGILaVxI.cjs.map +0 -1
  69. package/dist/collection/core/shortHexId.d.ts +0 -8
  70. package/dist/collection/server/publish.d.ts +0 -56
  71. package/dist/server-B48Jyxcj.js.map +0 -1
  72. package/dist/server-CWZyg8fn.cjs.map +0 -1
  73. package/dist/store-5_P_NsGa.cjs.map +0 -1
  74. package/dist/store-_61sO8K8.js.map +0 -1
@@ -1,17 +1,15 @@
1
1
  import { a as isErrorWithCode, c as isStringArray, l as isUnknownArray, s as isRecord, t as errorMessage } from "./dist-D8zokgGo.js";
2
2
  import { n as writeFileAtomic } from "./root-BMroU_mB.js";
3
3
  import { n as toPosixRelPath } from "./relPath-DW8MC8VO.js";
4
- import { F as isFieldDrivenEvery, L as storageKindFor, P as embedTargetId, R as fieldText, j as COMPUTED_TYPES, l as parseIsoDate, u as parseIsoDateTime, y as isValidCollectionName, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
5
- import { C as actionVisible, _ as uniqueRefTargets, b as projectBacklinkRow, c as selectDynamicRecord, f as itemIsDone, g as uniqueEmbedTargets, h as uniqueBacklinkSources, i as ownProp, n as deriveAll, o as firstDateField, s as resolveIcon, t as defangForPrompt, v as backlinkRows, x as rollupValue, y as coerceNumeric } from "./promptSafety-CpiME8pj.js";
6
- import { B as SCHEMA_FILE$1, C as resolveCreateItemId, D as loadCollection, E as discoverCollections, I as parseAppManifest, J as isBackendUnavailable, K as safeSlugName, M as resolveMutateSet, N as APP_MANIFEST_FILE, O as resolvePrimaryField, U as resolveDataDir, V as isContainedInRoot, W as resolveTemplatePath, X as archiveDir, at as log, b as isRegularFile, d as normalizeCsvValue, f as queryCsv, h as CollectionQueryZ, i as checkpointSqliteDatabase, j as CollectionSchemaZ, m as compileJsonlQuery, n as readOnlyRefusal, nt as getWorkspaceRoot, o as cacheDir, pt as stagingSkillDir, r as storeFor, rt as isPresetSlug$1, tt as firestoreHandle } from "./store-_61sO8K8.js";
4
+ import { B as fieldText, I as embedTargetId, L as isFieldDrivenEvery, N as COMPUTED_TYPES, V as fieldTextOrNull, d as parseIsoDate, f as parseIsoDateTime, x as isValidCollectionName, z as storageKindFor } from "./itemId-DfTm08jm.js";
5
+ import { C as actionVisible, _ as uniqueRefTargets, b as projectBacklinkRow, c as selectDynamicRecord, f as itemIsDone, g as uniqueEmbedTargets, h as uniqueBacklinkSources, i as ownProp, n as deriveAll, o as firstDateField, s as resolveIcon, t as defangForPrompt, v as backlinkRows, x as rollupValue, y as coerceNumeric } from "./promptSafety-CSdyp7RF.js";
6
+ import { B as SCHEMA_FILE$1, C as CollectionQueryZ, I as parseAppManifest, J as isBackendUnavailable, K as safeSlugName, O as isRegularFile, S as compileJsonlQuery, U as resolveDataDir, V as isContainedInRoot, W as resolveTemplatePath, X as archiveDir, b as queryCsv, c as resolveMutateSet, d as storeFor, f as checkpointSqliteDatabase, ht as stagingSkillDir, i as resolvePrimaryField, it as isPresetSlug$1, j as resolveCreateItemId, m as cacheDir, n as discoverCollections, nt as getWorkspaceRoot, ot as log, r as loadCollection, s as CollectionSchemaZ, u as readOnlyRefusal, y as normalizeCsvValue } from "./discovery-B9zdNkgW.js";
7
7
  import { ingestStatePath } from "./feeds/paths.js";
8
8
  import { mirrorSkillWrite } from "./skill-bridge/index.js";
9
9
  import path from "node:path";
10
10
  import { randomBytes, randomUUID } from "node:crypto";
11
11
  import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
12
12
  import { z } from "zod";
13
- import { execFile } from "node:child_process";
14
- import { promisify } from "node:util";
15
13
  //#region src/collection/server/publishManifest.ts
16
14
  /** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
17
15
  * that `sharedCollectionKey` applies. Stated once so a path built later
@@ -263,6 +261,7 @@ function projectApp(authored, schemas, stamp, existing) {
263
261
  publishedAt: stamp.publishedAt,
264
262
  publishedBy: stamp.email,
265
263
  publishedCommit: stamp.commit,
264
+ publishedDirty: stamp.dirty === true ? true : void 0,
266
265
  previousPublished: previousOf(existing)
267
266
  });
268
267
  const config = {
@@ -281,6 +280,102 @@ function projectApp(authored, schemas, stamp, existing) {
281
280
  config
282
281
  };
283
282
  }
283
+ /** Keys on the app document that publish owns: what is PUBLIC right now, and
284
+ * the rule-facing configuration anonymous access is judged against. Deploy
285
+ * carries them through from the existing document and never authors them. */
286
+ var PUBLISH_OWNED_KEYS = [
287
+ "public",
288
+ "collections",
289
+ "participantRead",
290
+ "publishedAt",
291
+ "publishedBy",
292
+ "publishedCommit",
293
+ "publishedDirty",
294
+ "previousPublished"
295
+ ];
296
+ var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
297
+ function projectDeploy(authored, schemas, stamp, existing) {
298
+ const { app } = projectApp(authored, schemas, stamp, existing);
299
+ const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
300
+ for (const key of PUBLISH_OWNED_KEYS) {
301
+ const live = existing?.[key];
302
+ if (live !== void 0) deployed[key] = live;
303
+ }
304
+ deployed.deployedAt = stamp.publishedAt;
305
+ deployed.deployedBy = stamp.email;
306
+ if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
307
+ return {
308
+ app: deployed,
309
+ staging: schemas.map(({ cid, schema }) => ({
310
+ cid,
311
+ doc: stagedDoc(schema, stamp, authored, cid)
312
+ }))
313
+ };
314
+ }
315
+ /** One staged schema document, carrying this cid's rule-facing configuration
316
+ * alongside the schema so publish can promote them together. */
317
+ function stagedDoc(schema, stamp, authored, cid) {
318
+ const doc = {
319
+ publishedSchema: schema,
320
+ deployedAt: stamp.publishedAt,
321
+ deployedBy: stamp.email
322
+ };
323
+ const config = authored.collections?.[cid];
324
+ if (config !== void 0) doc.config = config;
325
+ if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
326
+ if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
327
+ return doc;
328
+ }
329
+ /** The rule-facing configuration to promote, read from the STAGED documents
330
+ * rather than from the manifest as it reads right now.
331
+ *
332
+ * Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
333
+ * the promoted schema is A's while the authorization behaviour is B's, a
334
+ * combination nobody exercised through `/staging/{aid}`.
335
+ *
336
+ * (`public` is deliberately NOT part of this: it is not staged, because it is
337
+ * the decision being made AT publish rather than something under test.) */
338
+ function stagedRuleConfig(staged) {
339
+ const entries = [];
340
+ const participantRead = [];
341
+ for (const entry of staged) {
342
+ const { config } = entry.doc;
343
+ if (config !== void 0) entries.push([entry.cid, config]);
344
+ if (entry.doc.participantRead === true) participantRead.push(entry.cid);
345
+ }
346
+ return {
347
+ collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
348
+ participantRead: participantRead.length > 0 ? participantRead : void 0
349
+ };
350
+ }
351
+ function projectPublish(authored, staged, stamp, existing) {
352
+ const { app, config } = projectApp(authored, [], stamp, existing);
353
+ const staging = stagedRuleConfig(staged);
354
+ app.collections = staging.collections;
355
+ app.participantRead = staging.participantRead;
356
+ const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
357
+ for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
358
+ const publicBlock = app.public;
359
+ return {
360
+ app: published,
361
+ config,
362
+ public: isRecord(publicBlock) ? publicBlock : void 0
363
+ };
364
+ }
365
+ /** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
366
+ *
367
+ * The stamp answers "which version is PUBLIC right now, and who made it so",
368
+ * so it is written by the operation that changes the answer — publish — not
369
+ * carried over from the deploy that staged it. */
370
+ function promoteSchema(staged, stamp) {
371
+ const doc = {
372
+ publishedSchema: staged.publishedSchema,
373
+ publishedAt: stamp.publishedAt,
374
+ publishedBy: stamp.email
375
+ };
376
+ if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
377
+ return doc;
378
+ }
284
379
  /** One published schema document. Written key by key rather than through
285
380
  * `compact`, so the declared type is the type — an optional commit is the
286
381
  * only variable part. */
@@ -296,8 +391,31 @@ function schemaDoc(schema, stamp) {
296
391
  /** The app documents' parent path — the `FirestoreDocs` seam takes a
297
392
  * collection path plus a document id, and the app document's id is the aid. */
298
393
  var APPS_COLLECTION = "apps";
299
- /** The collection (schema) documents' parent path. */
394
+ /** The collection (schema) documents' parent path — what the PUBLIC page
395
+ * reads, written only by publish (promotion). */
300
396
  var appSchemasPath = (aid) => `apps/${aid}/collections`;
397
+ /** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
398
+ *
399
+ * A TOP-LEVEL collection, not a field on the app: the public page resolves a
400
+ * slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
401
+ * has to be claimable atomically (create-if-absent) so two apps cannot hold
402
+ * the same URL.
403
+ *
404
+ * `published` is what makes the reservation invisible until publish. The slug
405
+ * is human-readable, so a readable reservation would let anyone guess the URL
406
+ * and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
407
+ * `allow read: if resource.data.published == true`, which needs no `get()` and
408
+ * so costs nothing against the rules' expression budget. */
409
+ var APP_SLUGS_COLLECTION = "appSlugs";
410
+ var appSlugDoc = (aid, published) => ({
411
+ aid,
412
+ published
413
+ });
414
+ /** The staged schema documents' parent path — what `/staging/{aid}` reads,
415
+ * written by deploy. A separate DOCUMENT rather than a field beside
416
+ * `publishedSchema`, because the rules cannot hide a field: anything inside a
417
+ * document the public page may read is public. */
418
+ var appStagingPath = (aid) => `apps/${aid}/staging`;
301
419
  /** The public-config documents' parent path. */
302
420
  var appConfigPath = (aid) => `apps/${aid}/config`;
303
421
  //#endregion
@@ -523,530 +641,32 @@ function publishProblems(app, collections, publisherEmail) {
523
641
  ...primaryKeyProblems(app, collections)
524
642
  ];
525
643
  }
526
- /** A public submission must be able to produce a record the HOST can read.
527
- *
528
- * The rules and the engine disagree about what identifies a record, and the
529
- * gap is invisible from either side alone. The rules bind the DOCUMENT ID
530
- * (`idFrom`) and let a submission carry only `createFields`; the engine
531
- * identifies a record by its schema's `primaryKey` FIELD, and the firestore
532
- * store hands back the document's fields verbatim — `toItem` does not
533
- * synthesize the key from the document id. So a submit path whose
534
- * `createFields` omits the primary key writes rows that Firestore accepts and
535
- * every reader rejects: `validateRecordObject` fails them, the collection
536
- * renders empty-ish, and the next publish's own pre-check reports them as
537
- * broken records the publisher never wrote.
538
- *
539
- * Not checkable by the rules (they have never heard of a schema) and not
540
- * catchable at write time (nothing is wrong with the write). Publish is the
541
- * only place that holds both halves. */
542
- function primaryKeyProblems(app, collections) {
543
- const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
544
- return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
545
- const primaryKey = primaryKeyOf.get(cid);
546
- if (primaryKey === void 0 || submit.createFields.includes(primaryKey)) return [];
547
- return [`public.submit.${cid}.createFields must include "${primaryKey}", the schema's primaryKey: a submission may carry only the createFields, and a shared record is stored as exactly the fields it was written with — the document id is not copied into the record. Without it every submission is accepted by the rules and then rejected by every reader.`];
548
- });
549
- }
550
- //#endregion
551
- //#region src/collection/core/recordZ.ts
552
- /** The emptiness rule shared by `required` and the "only check present
553
- * values" gate. NOT a truthiness check — `0` and `false` are filled. */
554
- var isEmptyValue = (value) => value === void 0 || value === null || value === "";
555
- /** The historical write-gate checks, verbatim: required non-empty, enum
556
- * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
557
- function enforcedProblem(key, spec, value) {
558
- const empty = isEmptyValue(value);
559
- if (spec.required && empty) return `missing required field '${key}'`;
560
- if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
561
- return null;
562
- }
563
- /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
564
- * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
565
- * the lint flags exactly the values the calendar / trigger / spawn code
566
- * would silently drop — impossible days like `2026-02-30`, and datetimes
567
- * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
568
- * which the day view can't place). `string`-backed types accept anything
569
- * stringifiable; `ref` existence is out of scope. */
570
- function strictTypeProblem(key, spec, value) {
571
- switch (spec.type) {
572
- case "number":
573
- case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
574
- case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
575
- case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
576
- case "datetime": return 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)`;
577
- default: return null;
578
- }
579
- }
580
- /** Strict check for a PRESENT `table` value: an array of row objects, each
581
- * row conforming to the sub-schema (required / enum / typed sub-values).
582
- * First row problem wins, prefixed with the row number so the fix is
583
- * locatable. */
584
- function strictTableProblem(key, spec, value) {
585
- if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
586
- for (let index = 0; index < value.length; index++) {
587
- const row = value[index];
588
- if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
589
- for (const [subKey, subSpec] of Object.entries(spec.of)) {
590
- const subValue = row[subKey];
591
- const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
592
- if (problem) return `'${key}' row ${index + 1}: ${problem}`;
593
- }
594
- }
595
- return null;
596
- }
597
- /** First problem for one field's stored value under `tier`, or null.
598
- * Enforced checks always run (and their messages never vary by tier — the
599
- * scan and the write gate must agree on them); strict adds the per-type
600
- * layer on present values only. */
601
- function recordFieldProblem(key, spec, value, tier) {
602
- const enforced = enforcedProblem(key, spec, value);
603
- if (enforced || tier === "enforced") return enforced;
604
- if (isEmptyValue(value)) return null;
605
- if (spec.type === "table") return strictTableProblem(key, spec, value);
606
- return strictTypeProblem(key, spec, value);
607
- }
608
- var compiled = /* @__PURE__ */ new WeakMap();
609
- /** Compile `schema.fields` into a zod validator for a stored record.
610
- * Loose object: unknown keys are allowed and any declared key may be
611
- * absent (records are user files, not parse-and-rewrite targets —
612
- * callers validate, they never persist the parse output). The checks run
613
- * as ONE object-level refine iterating fields in declaration order —
614
- * per-key shape schemas can't express "key may be absent BUT its absence
615
- * must still reach the required check", and the single loop keeps the
616
- * first reported issue identical to the historical first-problem-wins
617
- * contract. */
618
- function compileRecordZ(schema, tier) {
619
- const cached = compiled.get(schema)?.[tier];
620
- if (cached) return cached;
621
- const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
622
- const validator = z.looseObject({}).superRefine((record, ctx) => {
623
- for (const [key, spec] of stored) {
624
- const problem = recordFieldProblem(key, spec, record[key], tier);
625
- if (problem) ctx.addIssue({
626
- code: "custom",
627
- message: problem,
628
- path: [key]
629
- });
630
- }
631
- });
632
- const entry = compiled.get(schema) ?? {};
633
- entry[tier] = validator;
634
- compiled.set(schema, entry);
635
- return validator;
636
- }
637
- /** First schema problem on an in-memory record under `tier`, or null. One
638
- * issue per record keeps the report short and the fix obvious (the
639
- * historical contract of `validateRecordObject`). */
640
- function firstRecordProblem(record, schema, tier) {
641
- const result = compileRecordZ(schema, tier).safeParse(record);
642
- if (result.success) return null;
643
- return result.error.issues[0]?.message ?? "record failed schema validation";
644
- }
645
- //#endregion
646
- //#region src/collection/server/validate.ts
647
- /** Don't flood the result; the first batch is enough to act on. Exported
648
- * because a caller that REPORTS a count has to know the count is a floor —
649
- * `publish` presents a full batch as "at least N" rather than as a total. */
650
- var MAX_RECORD_ISSUES = 25;
651
- /** The `file` of the pseudo-issue reported when the backend could not be read
652
- * at all.
653
- *
654
- * Exported because it is a DIFFERENT KIND of answer from "this record is
655
- * invalid", and a caller that treats the two alike gets it wrong in the
656
- * direction that matters: `publish` lets the user override invalid records,
657
- * and overriding this one would mean publishing without ever having looked. */
658
- var STORE_UNREADABLE = "(store)";
659
- var MAX_ISSUES = 25;
660
- /** Read every `<id>.json` under the collection's dataDir and report the
661
- * ones that won't load or violate the schema. An empty list means every
662
- * record is fine. */
663
- /** List entries under the data dir, guarding realpath containment (against a
664
- * symlinked dir swapped in after discovery, like `listItems`) and treating a
665
- * missing dir as empty while surfacing real I/O faults. */
666
- async function listRecordFilenames(dataDir, workspaceRoot) {
667
- if (!isContainedInRoot(dataDir, workspaceRoot)) {
668
- log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
669
- return [];
670
- }
671
- try {
672
- return await readdir(dataDir);
673
- } catch (err) {
674
- if (isErrorWithCode(err) && err.code === "ENOENT") return [];
675
- throw err;
676
- }
677
- }
678
- async function validateCollectionRecords(collection, opts = {}) {
679
- if (collection.schema.dataSource !== void 0) return [];
680
- if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
681
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
682
- const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
683
- const issues = [];
684
- for (const name of entries.sort()) {
685
- if (!name.endsWith(".json") || name.startsWith(".")) continue;
686
- if (issues.length >= MAX_ISSUES) break;
687
- const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
688
- if (issue) issues.push(issue);
689
- }
690
- return issues;
691
- }
692
- /** Store-backed twin of the file scan: list every record through the
693
- * collection's store and lint it with the same "strict" report-only tier.
694
- * A row the store can't even parse is invisible here (the store skips
695
- * it), so the read/parse classifications of the file scan don't apply —
696
- * schema violations are what this catches. `file` carries the record id
697
- * (there is no per-record filename). */
698
- async function validateStoreRecords(collection, opts) {
699
- let items;
700
- try {
701
- items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
702
- } catch (err) {
703
- return [{
704
- file: STORE_UNREADABLE,
705
- problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
706
- }];
707
- }
708
- const issues = [];
709
- for (const item of items) {
710
- if (issues.length >= MAX_ISSUES) break;
711
- const itemId = fieldText(item[collection.schema.primaryKey]);
712
- const problem = validateRecordObject(item, itemId, collection.schema, "strict");
713
- if (problem) issues.push({
714
- file: itemId,
715
- problem
716
- });
717
- }
718
- return issues;
719
- }
720
- async function readRecordText(fullPath, name) {
721
- try {
722
- if (!(await lstat(fullPath)).isFile()) return {
723
- file: name,
724
- problem: "not a regular file (symlink?) — skipped, won't appear"
725
- };
726
- return { raw: await readFile(fullPath, "utf-8") };
727
- } catch {
728
- return {
729
- file: name,
730
- problem: "could not be read — skipped, won't appear"
731
- };
732
- }
733
- }
734
- /** Classify a single record file: unreadable / unparseable / non-object /
735
- * schema violation, or null when it's fine. */
736
- async function inspectRecord(fullPath, name, schema) {
737
- const read = await readRecordText(fullPath, name);
738
- if ("problem" in read) return read;
739
- let parsed;
740
- try {
741
- parsed = JSON.parse(read.raw);
742
- } catch (err) {
743
- return {
744
- file: name,
745
- 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.`
746
- };
747
- }
748
- if (!isRecord(parsed)) return {
749
- file: name,
750
- problem: "not a JSON object — skipped, won't appear"
751
- };
752
- const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
753
- return problem ? {
754
- file: name,
755
- problem
756
- } : null;
757
- }
758
- /** What a non-string primary key actually is, for the error message. Names the
759
- * shape rather than stringifying the value — "[object Object]" tells the reader
760
- * nothing about what is wrong. */
761
- function describeIdType(value) {
762
- if (value === null) return "null";
763
- if (value === void 0) return "missing";
764
- if (Array.isArray(value)) return "an array";
765
- return `a ${typeof value}`;
766
- }
767
- /** First schema problem on an in-memory record (primaryKey↔id mismatch,
768
- * then the compiled per-field checks — see `../core/recordZ` for the two
769
- * tiers), or null when it's fine. One issue per record keeps the report
770
- * short and the fix obvious. Pure + exported so write paths
771
- * (manageCollection putItems) can gate on the SAME enforced rules the
772
- * post-hoc file scan reports — `itemId` is the id the record is (or
773
- * would be) stored under. The default `"enforced"` tier keeps every
774
- * write gate on the historical three checks; only pass `"strict"` from
775
- * report-only surfaces. */
776
- function validateRecordObject(record, itemId, schema, tier = "enforced") {
777
- const idValue = record[schema.primaryKey];
778
- 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`;
779
- if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
780
- return firstRecordProblem(record, schema, tier);
781
- }
782
- //#endregion
783
- //#region src/collection/server/publish.ts
784
- var execFileAsync = promisify(execFile);
785
- /** How many broken records to name before summarising. A publish that would
786
- * break a thousand rows is answered by the count and a sample; dumping all of
787
- * them buries the number, which is the part the decision turns on. */
788
- var MAX_LISTED_ISSUES = 10;
789
- /** `git rev-parse HEAD` plus a dirty check, or nothing.
790
- *
791
- * A missing git, a repository with no commits and a non-repository are all
792
- * the same answer here — no commit — because the stamp is attribution, not a
793
- * requirement. What is NOT acceptable is a stamp that lies, which is why the
794
- * dirty flag exists: publishing from a modified tree records a commit that
795
- * does not describe what was published, and the flag is the only thing that
796
- * would ever tell a reader so. */
797
- async function gitStamp(root) {
798
- try {
799
- const { stdout } = await execFileAsync("git", [
800
- "-C",
801
- root,
802
- "rev-parse",
803
- "HEAD"
804
- ]);
805
- const commit = stdout.trim();
806
- const { stdout: status } = await execFileAsync("git", [
807
- "-C",
808
- root,
809
- "status",
810
- "--porcelain"
811
- ]);
812
- return {
813
- commit: commit.length > 0 ? commit : void 0,
814
- dirty: status.trim().length > 0
815
- };
816
- } catch {
817
- return {};
818
- }
819
- }
820
- /** The shared collections of THIS REPOSITORY, by cid.
821
- *
822
- * `userSkillsDir: null` — not a test convenience, a boundary. Discovery
823
- * resolves every schema it finds against the WORKSPACE root, user-scope
824
- * included, so a globally installed skill under `~/.claude/skills` carrying
825
- * `storage.type: "firestore"` picks up whichever repository's `aid` it
826
- * happens to be discovered from. Left in, publish would write that schema
827
- * into this app — and into every other app the same user publishes, since the
828
- * skill is installed once per machine and the repositories are not.
829
- *
830
- * An app is a REPOSITORY (design D1): its collections are the ones committed
831
- * beside its `app.json`, which is what makes a clone resolve the same
832
- * collections and an invitation a matter of authorization rather than
833
- * discovery. A schema that is not in the repository has no claim on a cid
834
- * there. And because a view is HTML, publishing one is not a tidiness
835
- * question: it is the machine's own skills reaching every member's browser.
836
- *
837
- * The consequence is deliberate: a cid named in `app.json` that exists only
838
- * in user scope is now an unknown cid, and publish says so by name instead of
839
- * quietly publishing a schema from outside the repository. */
840
- async function sharedCollections(opts, root) {
841
- return (await discoverCollections({
842
- ...opts,
843
- workspaceRoot: root,
844
- userSkillsDir: null
845
- })).filter((collection) => collection.appId !== void 0);
846
- }
847
- /** Existing records that would not satisfy the schema about to be published.
848
- *
849
- * Read from FIRESTORE, not from disk: a shared collection's records live in
850
- * the app, and the question this answers is "what does the live data look
851
- * like under the new schema" — which is the migration question. Reported as
852
- * a refusal the publisher can override, because a breaking change is
853
- * sometimes exactly what is intended and the point is that it is a decision
854
- * rather than a discovery. */
855
- async function recordProblems(collections, opts) {
856
- const lines = [];
857
- const unreadable = [];
858
- let records = 0;
859
- let cappedAnywhere = false;
860
- for (const collection of collections) {
861
- const issues = await validateCollectionRecords(collection, opts);
862
- if (issues.length === 0) continue;
863
- const unread = issues.filter((issue) => issue.file === STORE_UNREADABLE);
864
- if (unread.length > 0) {
865
- unreadable.push(`${collection.slug}: ${unread.map((issue) => issue.problem).join("; ")}`);
866
- continue;
867
- }
868
- records += issues.length;
869
- const capped = issues.length >= 25;
870
- cappedAnywhere = cappedAnywhere || capped;
871
- const count = capped ? `at least ${issues.length}` : String(issues.length);
872
- const plural = issues.length === 1 ? "" : "s";
873
- const note = capped ? " (the scan stops there)" : "";
874
- lines.push(`${collection.slug}: ${count} existing record${plural} would not satisfy the schema about to be published${note}`);
875
- for (const issue of issues.slice(0, MAX_LISTED_ISSUES)) lines.push(` - ${issue.file}: ${issue.problem}`);
876
- if (issues.length > MAX_LISTED_ISSUES) lines.push(` - … and ${issues.length - MAX_LISTED_ISSUES} more`);
877
- }
878
- return {
879
- lines,
880
- records,
881
- capped: cappedAnywhere,
882
- unreadable
883
- };
884
- }
885
- function schemasOf(collections) {
886
- return collections.map((collection) => ({
887
- cid: collection.slug,
888
- schema: collection.schema
889
- })).sort((left, right) => left.cid < right.cid ? -1 : left.cid > right.cid ? 1 : 0);
890
- }
891
- /** Read and parse `<root>/app.json`'s full declaration. */
892
- async function readAuthored(root) {
893
- let raw;
894
- try {
895
- raw = await readFile(path.join(root, APP_MANIFEST_FILE), "utf-8");
896
- } catch (err) {
897
- return {
898
- ok: false,
899
- problems: [`cannot read ${path.join(root, APP_MANIFEST_FILE)}: ${String(err)}`]
900
- };
901
- }
902
- return parseAuthoredApp(raw);
903
- }
904
- /** Everything wrong with the declaration itself, publisher included. */
905
- function declarationProblems(app, collections, handle) {
906
- const problems = publishProblems(app, collections.map((collection) => ({
907
- cid: collection.slug,
908
- primaryKey: collection.schema.primaryKey
909
- })), handle.email);
910
- 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.`);
911
- return problems;
912
- }
913
- /** Put the three kinds of document, in the order the rules require, and turn a
914
- * rejected write into the result type instead of letting it escape.
915
- *
916
- * Returns null when everything was written; a failure result otherwise.
917
- *
918
- * A raw rejection here would reach the agent as a tool crash rather than the
919
- * actionable text this tool promises. But "actionable" is a strong claim for
920
- * a half-finished publish, so the message ENUMERATES what landed rather than
921
- * summarising it: the order is app → every schema → config, and a summary
922
- * written for one failure point is wrong at the others. Saying "the roster
923
- * and configuration are live" after a SCHEMA write failed names a config
924
- * document this publish never wrote — which still holds whatever the last
925
- * publish left, and is exactly the state the caller is trying to repair. */
926
- async function writeDocuments(handle, aid, published) {
927
- const steps = [
928
- {
929
- what: `the app document (apps/${aid})`,
930
- run: () => handle.docs.set(APPS_COLLECTION, aid, published.app)
931
- },
932
- ...published.schemas.map(({ cid, doc }) => ({
933
- what: `the published schema for '${cid}'`,
934
- run: () => handle.docs.set(appSchemasPath(aid), cid, doc)
935
- })),
936
- {
937
- what: `the public config document (apps/${aid}/config/${PUBLIC_CONFIG_DOC})`,
938
- run: () => handle.docs.set(appConfigPath(aid), PUBLIC_CONFIG_DOC, published.config)
939
- }
940
- ];
941
- const landed = [];
942
- for (const [index, step] of steps.entries()) try {
943
- await step.run();
944
- landed.push(step.what);
945
- } catch (err) {
946
- const reason = err instanceof Error ? err.message : String(err);
947
- return {
948
- ok: false,
949
- partial: index > 0,
950
- problems: [`publish failed while writing ${step.what}: ${reason}`, ...partialState(landed, [step.what, ...steps.slice(index + 1).map((rest) => rest.what)])]
951
- };
952
- }
953
- return null;
954
- }
955
- /** What is live and what is not, listed rather than summarised.
956
- *
957
- * Two facts, and both matter for the repair: a document this publish wrote is
958
- * live NOW, and a document it did not write still holds what the LAST publish
959
- * left — which is not the same as being absent, and not the same as matching
960
- * the declaration that was just half-applied. */
961
- function partialState(landed, notWritten) {
962
- const repair = "Publishing again is the repair: the write is idempotent, and it re-does every step, including the ones that did land.";
963
- if (landed.length === 0) return [`Nothing was written. ${repair}`];
964
- return [
965
- `Written by this publish, and live now: ${landed.join("; ")}.`,
966
- `NOT written: ${notWritten.join("; ")} — ${notWritten.length === 1 ? "it still holds" : "they still hold"} whatever the previous publish left.`,
967
- repair
968
- ];
969
- }
970
- /** Publish this repository's declaration to its app.
971
- *
972
- * Everything variable is a parameter or comes from the host binding, so the
973
- * whole path is exercisable against an in-memory `FirestoreDocs` with no
974
- * network and no API key — which is the only way the conversion table gets
975
- * tested as a table. */
976
- async function publishApp(opts = {}) {
977
- const root = opts.workspaceRoot ?? getWorkspaceRoot();
978
- const handle = firestoreHandle();
979
- if (!handle) return {
980
- ok: false,
981
- partial: false,
982
- 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."]
983
- };
984
- const authored = await readAuthored(root);
985
- if (!authored.ok) return {
986
- ...authored,
987
- partial: false
988
- };
989
- const collections = await sharedCollections(opts, root);
990
- const problems = declarationProblems(authored.app, collections, handle);
991
- if (problems.length > 0) return {
992
- ok: false,
993
- partial: false,
994
- problems
995
- };
996
- const issues = await recordProblems(collections, {
997
- ...opts,
998
- workspaceRoot: root
999
- });
1000
- if (issues.unreadable.length > 0) return {
1001
- ok: false,
1002
- partial: false,
1003
- 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."]
1004
- };
1005
- if (issues.records > 0 && opts.confirm !== true) return {
1006
- ok: false,
1007
- partial: false,
1008
- 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."]
1009
- };
1010
- return writePublished(authored.app, collections, handle, opts, root, issues);
1011
- }
1012
- /** The write half: stamp, project, and put the documents in the order the
1013
- * rules require. Split from the gate above so neither half hides the other —
1014
- * everything up to here can refuse, and nothing from here on does. */
1015
- async function writePublished(authored, collections, handle, opts, root, issues) {
1016
- const { aid } = authored;
1017
- let existing;
1018
- try {
1019
- existing = await handle.docs.get(APPS_COLLECTION, aid);
1020
- } catch (err) {
1021
- return {
1022
- ok: false,
1023
- partial: false,
1024
- 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."]
1025
- };
1026
- }
1027
- const stampSource = await (opts.resolveCommit ?? gitStamp)(root);
1028
- const stamp = {
1029
- uid: handle.uid,
1030
- email: handle.email,
1031
- publishedAt: (opts.now ?? Date.now)(),
1032
- commit: stampSource.commit
1033
- };
1034
- const existingApp = isRecord(existing) ? existing : null;
1035
- const published = projectApp(authored, schemasOf(collections), stamp, existingApp);
1036
- if (stampSource.dirty === true) published.app.publishedDirty = true;
1037
- const written = await writeDocuments(handle, aid, published);
1038
- if (written !== null) return written;
1039
- return {
1040
- ok: true,
1041
- aid,
1042
- cids: published.schemas.map((entry) => entry.cid),
1043
- created: existingApp === null,
1044
- commit: stamp.commit,
1045
- dirty: stampSource.dirty === true,
1046
- recordIssues: issues.records,
1047
- recordIssuesCapped: issues.capped,
1048
- published
1049
- };
644
+ /** A public submission must NOT be allowed to name its own primary key.
645
+ *
646
+ * The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
647
+ * value of a field — nothing compares `request.resource.data[primaryKey]`
648
+ * with the path being written. So a submit path that accepts the primary key
649
+ * as a `createField` lets a submitter write at their one permitted document
650
+ * id while CLAIMING another record's identity, or a duplicate.
651
+ *
652
+ * It is refused rather than tolerated because there is nothing for the field
653
+ * to do: `firestoreStore` takes a shared record's identity from the document
654
+ * id and overwrites the field on read, so a submitted value is either equal
655
+ * to the id (noise) or a lie (silently discarded). Publishing a form field
656
+ * whose value is thrown away is worse than not having it — the author will
657
+ * believe submitters choose their ids.
658
+ *
659
+ * This is the second answer to the same question. The first was the reverse —
660
+ * REQUIRE the key, because a record without one was rejected by every reader
661
+ * — and it was right about the symptom and wrong about the cure: the identity
662
+ * belongs to the id the rules can pin, not to a field they cannot. */
663
+ function primaryKeyProblems(app, collections) {
664
+ const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
665
+ return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
666
+ const primaryKey = primaryKeyOf.get(cid);
667
+ if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
668
+ 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.`];
669
+ });
1050
670
  }
1051
671
  //#endregion
1052
672
  //#region src/collection/server/skillAssets.ts
@@ -1412,6 +1032,238 @@ async function runCollectionQuery(collection, query, opts = {}) {
1412
1032
  return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
1413
1033
  }
1414
1034
  //#endregion
1035
+ //#region src/collection/core/recordZ.ts
1036
+ /** The emptiness rule shared by `required` and the "only check present
1037
+ * values" gate. NOT a truthiness check — `0` and `false` are filled. */
1038
+ var isEmptyValue = (value) => value === void 0 || value === null || value === "";
1039
+ /** The historical write-gate checks, verbatim: required non-empty, enum
1040
+ * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
1041
+ function enforcedProblem(key, spec, value) {
1042
+ const empty = isEmptyValue(value);
1043
+ if (spec.required && empty) return `missing required field '${key}'`;
1044
+ if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
1045
+ return null;
1046
+ }
1047
+ /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
1048
+ * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
1049
+ * the lint flags exactly the values the calendar / trigger / spawn code
1050
+ * would silently drop — impossible days like `2026-02-30`, and datetimes
1051
+ * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
1052
+ * which the day view can't place). `string`-backed types accept anything
1053
+ * stringifiable; `ref` existence is out of scope. */
1054
+ function strictTypeProblem(key, spec, value) {
1055
+ switch (spec.type) {
1056
+ case "number":
1057
+ case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
1058
+ case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
1059
+ case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
1060
+ case "datetime": return 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)`;
1061
+ default: return null;
1062
+ }
1063
+ }
1064
+ /** Strict check for a PRESENT `table` value: an array of row objects, each
1065
+ * row conforming to the sub-schema (required / enum / typed sub-values).
1066
+ * First row problem wins, prefixed with the row number so the fix is
1067
+ * locatable. */
1068
+ function strictTableProblem(key, spec, value) {
1069
+ if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
1070
+ for (let index = 0; index < value.length; index++) {
1071
+ const row = value[index];
1072
+ if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
1073
+ for (const [subKey, subSpec] of Object.entries(spec.of)) {
1074
+ const subValue = row[subKey];
1075
+ const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
1076
+ if (problem) return `'${key}' row ${index + 1}: ${problem}`;
1077
+ }
1078
+ }
1079
+ return null;
1080
+ }
1081
+ /** First problem for one field's stored value under `tier`, or null.
1082
+ * Enforced checks always run (and their messages never vary by tier — the
1083
+ * scan and the write gate must agree on them); strict adds the per-type
1084
+ * layer on present values only. */
1085
+ function recordFieldProblem(key, spec, value, tier) {
1086
+ const enforced = enforcedProblem(key, spec, value);
1087
+ if (enforced || tier === "enforced") return enforced;
1088
+ if (isEmptyValue(value)) return null;
1089
+ if (spec.type === "table") return strictTableProblem(key, spec, value);
1090
+ return strictTypeProblem(key, spec, value);
1091
+ }
1092
+ var compiled = /* @__PURE__ */ new WeakMap();
1093
+ /** Compile `schema.fields` into a zod validator for a stored record.
1094
+ * Loose object: unknown keys are allowed and any declared key may be
1095
+ * absent (records are user files, not parse-and-rewrite targets —
1096
+ * callers validate, they never persist the parse output). The checks run
1097
+ * as ONE object-level refine iterating fields in declaration order —
1098
+ * per-key shape schemas can't express "key may be absent BUT its absence
1099
+ * must still reach the required check", and the single loop keeps the
1100
+ * first reported issue identical to the historical first-problem-wins
1101
+ * contract. */
1102
+ function compileRecordZ(schema, tier) {
1103
+ const cached = compiled.get(schema)?.[tier];
1104
+ if (cached) return cached;
1105
+ const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
1106
+ const validator = z.looseObject({}).superRefine((record, ctx) => {
1107
+ for (const [key, spec] of stored) {
1108
+ const problem = recordFieldProblem(key, spec, record[key], tier);
1109
+ if (problem) ctx.addIssue({
1110
+ code: "custom",
1111
+ message: problem,
1112
+ path: [key]
1113
+ });
1114
+ }
1115
+ });
1116
+ const entry = compiled.get(schema) ?? {};
1117
+ entry[tier] = validator;
1118
+ compiled.set(schema, entry);
1119
+ return validator;
1120
+ }
1121
+ /** First schema problem on an in-memory record under `tier`, or null. One
1122
+ * issue per record keeps the report short and the fix obvious (the
1123
+ * historical contract of `validateRecordObject`). */
1124
+ function firstRecordProblem(record, schema, tier) {
1125
+ const result = compileRecordZ(schema, tier).safeParse(record);
1126
+ if (result.success) return null;
1127
+ return result.error.issues[0]?.message ?? "record failed schema validation";
1128
+ }
1129
+ //#endregion
1130
+ //#region src/collection/server/validate.ts
1131
+ /** Don't flood the result; the first batch is enough to act on. Exported
1132
+ * because a caller that REPORTS a count has to know the count is a floor —
1133
+ * `publish` presents a full batch as "at least N" rather than as a total. */
1134
+ var MAX_RECORD_ISSUES = 25;
1135
+ /** The `file` of the pseudo-issue reported when the backend could not be read
1136
+ * at all.
1137
+ *
1138
+ * Exported because it is a DIFFERENT KIND of answer from "this record is
1139
+ * invalid", and a caller that treats the two alike gets it wrong in the
1140
+ * direction that matters: `publish` lets the user override invalid records,
1141
+ * and overriding this one would mean publishing without ever having looked. */
1142
+ var STORE_UNREADABLE = "(store)";
1143
+ var MAX_ISSUES = 25;
1144
+ /** Read every `<id>.json` under the collection's dataDir and report the
1145
+ * ones that won't load or violate the schema. An empty list means every
1146
+ * record is fine. */
1147
+ /** List entries under the data dir, guarding realpath containment (against a
1148
+ * symlinked dir swapped in after discovery, like `listItems`) and treating a
1149
+ * missing dir as empty while surfacing real I/O faults. */
1150
+ async function listRecordFilenames(dataDir, workspaceRoot) {
1151
+ if (!isContainedInRoot(dataDir, workspaceRoot)) {
1152
+ log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
1153
+ return [];
1154
+ }
1155
+ try {
1156
+ return await readdir(dataDir);
1157
+ } catch (err) {
1158
+ if (isErrorWithCode(err) && err.code === "ENOENT") return [];
1159
+ throw err;
1160
+ }
1161
+ }
1162
+ async function validateCollectionRecords(collection, opts = {}) {
1163
+ if (collection.schema.dataSource !== void 0) return [];
1164
+ if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
1165
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1166
+ const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
1167
+ const issues = [];
1168
+ for (const name of entries.sort()) {
1169
+ if (!name.endsWith(".json") || name.startsWith(".")) continue;
1170
+ if (issues.length >= MAX_ISSUES) break;
1171
+ const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
1172
+ if (issue) issues.push(issue);
1173
+ }
1174
+ return issues;
1175
+ }
1176
+ /** Store-backed twin of the file scan: list every record through the
1177
+ * collection's store and lint it with the same "strict" report-only tier.
1178
+ * A row the store can't even parse is invisible here (the store skips
1179
+ * it), so the read/parse classifications of the file scan don't apply —
1180
+ * schema violations are what this catches. `file` carries the record id
1181
+ * (there is no per-record filename). */
1182
+ async function validateStoreRecords(collection, opts) {
1183
+ let items;
1184
+ try {
1185
+ items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
1186
+ } catch (err) {
1187
+ return [{
1188
+ file: STORE_UNREADABLE,
1189
+ problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
1190
+ }];
1191
+ }
1192
+ const issues = [];
1193
+ for (const item of items) {
1194
+ if (issues.length >= MAX_ISSUES) break;
1195
+ const itemId = fieldText(item[collection.schema.primaryKey]);
1196
+ const problem = validateRecordObject(item, itemId, collection.schema, "strict");
1197
+ if (problem) issues.push({
1198
+ file: itemId,
1199
+ problem
1200
+ });
1201
+ }
1202
+ return issues;
1203
+ }
1204
+ async function readRecordText(fullPath, name) {
1205
+ try {
1206
+ if (!(await lstat(fullPath)).isFile()) return {
1207
+ file: name,
1208
+ problem: "not a regular file (symlink?) — skipped, won't appear"
1209
+ };
1210
+ return { raw: await readFile(fullPath, "utf-8") };
1211
+ } catch {
1212
+ return {
1213
+ file: name,
1214
+ problem: "could not be read — skipped, won't appear"
1215
+ };
1216
+ }
1217
+ }
1218
+ /** Classify a single record file: unreadable / unparseable / non-object /
1219
+ * schema violation, or null when it's fine. */
1220
+ async function inspectRecord(fullPath, name, schema) {
1221
+ const read = await readRecordText(fullPath, name);
1222
+ if ("problem" in read) return read;
1223
+ let parsed;
1224
+ try {
1225
+ parsed = JSON.parse(read.raw);
1226
+ } catch (err) {
1227
+ return {
1228
+ file: name,
1229
+ 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.`
1230
+ };
1231
+ }
1232
+ if (!isRecord(parsed)) return {
1233
+ file: name,
1234
+ problem: "not a JSON object — skipped, won't appear"
1235
+ };
1236
+ const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
1237
+ return problem ? {
1238
+ file: name,
1239
+ problem
1240
+ } : null;
1241
+ }
1242
+ /** What a non-string primary key actually is, for the error message. Names the
1243
+ * shape rather than stringifying the value — "[object Object]" tells the reader
1244
+ * nothing about what is wrong. */
1245
+ function describeIdType(value) {
1246
+ if (value === null) return "null";
1247
+ if (value === void 0) return "missing";
1248
+ if (Array.isArray(value)) return "an array";
1249
+ return `a ${typeof value}`;
1250
+ }
1251
+ /** First schema problem on an in-memory record (primaryKey↔id mismatch,
1252
+ * then the compiled per-field checks — see `../core/recordZ` for the two
1253
+ * tiers), or null when it's fine. One issue per record keeps the report
1254
+ * short and the fix obvious. Pure + exported so write paths
1255
+ * (manageCollection putItems) can gate on the SAME enforced rules the
1256
+ * post-hoc file scan reports — `itemId` is the id the record is (or
1257
+ * would be) stored under. The default `"enforced"` tier keeps every
1258
+ * write gate on the historical three checks; only pass `"strict"` from
1259
+ * report-only surfaces. */
1260
+ function validateRecordObject(record, itemId, schema, tier = "enforced") {
1261
+ const idValue = record[schema.primaryKey];
1262
+ 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`;
1263
+ if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
1264
+ return firstRecordProblem(record, schema, tier);
1265
+ }
1266
+ //#endregion
1415
1267
  //#region src/collection/server/mutate.ts
1416
1268
  /** First problem with the submitted params, or null. Every declared param
1417
1269
  * is checked by the shared record-field validator; keys the action never
@@ -2651,32 +2503,6 @@ async function handleGetOntology(deps) {
2651
2503
  collections
2652
2504
  });
2653
2505
  }
2654
- /** Publish this repository's `app.json` + shared schemas to its Firestore app.
2655
- *
2656
- * Named as a whole-app action because it IS one: publish takes the repository
2657
- * as its unit (one roster, one public config, every shared collection), so it
2658
- * carries no `slug` and refusing one is not a limitation to work around.
2659
- *
2660
- * The reply is prose rather than a status code on purpose. This is the one
2661
- * operation in the collection surface that changes what every member sees the
2662
- * moment it lands, and the two things the caller has to relay — what will
2663
- * break, and that `confirm` is how it proceeds anyway — are sentences, not
2664
- * fields. */
2665
- async function handlePublishApp(deps, confirm) {
2666
- const result = await publishApp({
2667
- ...deps,
2668
- confirm
2669
- });
2670
- if (!result.ok) {
2671
- const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
2672
- return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
2673
- }
2674
- const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
2675
- const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
2676
- const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
2677
- const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
2678
- 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}`;
2679
- }
2680
2506
  /** Return the collection-authoring reference (`collection-skills.md`),
2681
2507
  * rendered by `renderSchemaDocs` — the full doc overflows the agent's
2682
2508
  * per-result limit, so the default reply is the core guide + a table of
@@ -2785,7 +2611,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
2785
2611
  written: true
2786
2612
  });
2787
2613
  }
2788
- 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. `publishApp` publishes the whole repository — its `app.json` (member roster, public read/submit configuration) and every shared collection's schema — to the app's Firestore. It is the ONE operation here that changes what every member sees the moment it runs, and it cannot be undone by reverting a commit: publishing again is the only undo (the previous app document is kept as `previousPublished`). Call it when the user asks to publish, invite, or open an app; never as a follow-up to an edit the user did not ask you to ship. Its refusals are the product, not an obstacle — relay them verbatim, and do not work around one by rewriting `app.json` until the user has said what they actually want. If it reports live records the new schemas would break, show the user that list and ask before re-running with `confirm`. 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.";
2614
+ 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.";
2789
2615
  /** Validate getItems' optional `ids`/`fields` args, then delegate. */
2790
2616
  async function dispatchGetItems(collection, args, deps) {
2791
2617
  const ids = optionalStringArray(args.ids, "ids");
@@ -2830,19 +2656,18 @@ async function dispatchManageCollection(deps, args) {
2830
2656
  const action = typeof args.action === "string" ? args.action : "";
2831
2657
  if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
2832
2658
  if (action === "getOntology") return handleGetOntology(deps);
2833
- if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
2834
2659
  const slug = typeof args.slug === "string" ? args.slug.trim() : "";
2835
2660
  if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
2836
2661
  if (action === "getSchema") return handleGetSchema(slug, deps);
2837
2662
  if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
2838
- if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", \"putSchema\", or \"publishApp\".";
2663
+ if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
2839
2664
  const collection = await loadCollection(slug, deps);
2840
2665
  if (!collection) return unknownCollection(slug);
2841
2666
  return dispatchRecordAction(action, collection, args, deps);
2842
2667
  }
2843
2668
  var MANAGE_COLLECTION_DEFINITION = {
2844
2669
  name: "manageCollection",
2845
- 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. publishApp publishes the repository's app.json + shared schemas to Firestore, where every member sees them immediately -- it refuses declarations that would be silently permissive or silently deny everyone, and refuses to publish over records the new schemas would break unless `confirm` is set. Prefer it over raw file I/O on collections.",
2670
+ 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.",
2846
2671
  inputSchema: {
2847
2672
  type: "object",
2848
2673
  properties: {
@@ -2856,8 +2681,7 @@ var MANAGE_COLLECTION_DEFINITION = {
2856
2681
  "getOntology",
2857
2682
  "schemaDocs",
2858
2683
  "getSchema",
2859
- "putSchema",
2860
- "publishApp"
2684
+ "putSchema"
2861
2685
  ],
2862
2686
  description: "What to do."
2863
2687
  },
@@ -2893,10 +2717,6 @@ var MANAGE_COLLECTION_DEFINITION = {
2893
2717
  type: "object",
2894
2718
  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."
2895
2719
  },
2896
- confirm: {
2897
- type: "boolean",
2898
- 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."
2899
- },
2900
2720
  topic: {
2901
2721
  type: "string",
2902
2722
  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)."
@@ -2918,6 +2738,6 @@ function makeManageCollectionTool(deps = {}) {
2918
2738
  };
2919
2739
  }
2920
2740
  //#endregion
2921
- export { readSkillTemplate as A, APPS_COLLECTION as B, enrichItems as C, promptPathsFor as D, buildCollectionActionSeedPrompt as E, validateRecordObject as F, APP_ROLES as G, appConfigPath as H, compileRecordZ as I, AuthoredAppZ as K, recordFieldProblem as L, MAX_RECORD_ISSUES as M, STORE_UNREADABLE as N, readCustomViewHtml as O, validateCollectionRecords as P, bindsSubmitterIdentity as R, runCollectionQuery as S, buildActionSeedPrompt as T, appSchemasPath as U, PUBLIC_CONFIG_DOC as V, projectApp as W, computeCollectionIcon as _, deleteCollection as a, applyMutateAction as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, publishApp as j, readCustomViewI18n as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, parseAuthoredApp as q, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, buildWorkspaceOntology as v, runQueryOverRows as w, firstMutateParamProblem as x, schemaRelations as y, publishProblems as z };
2741
+ export { parseAuthoredApp as $, runQueryOverRows as A, APP_SLUGS_COLLECTION as B, STORE_UNREADABLE as C, recordFieldProblem as D, compileRecordZ as E, readCustomViewI18n as F, appStagingPath as G, appConfigPath as H, readSkillTemplate as I, projectPublish as J, projectApp as K, bindsSubmitterIdentity as L, buildCollectionActionSeedPrompt as M, promptPathsFor as N, runCollectionQuery as O, readCustomViewHtml as P, AuthoredAppZ as Q, publishProblems as R, MAX_RECORD_ISSUES as S, validateRecordObject as T, appSchemasPath as U, PUBLIC_CONFIG_DOC as V, appSlugDoc as W, stagedRuleConfig as X, promoteSchema as Y, APP_ROLES as Z, computeCollectionIcon as _, deleteCollection as a, applyMutateAction as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, buildActionSeedPrompt as j, enrichItems as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, projectDeploy as q, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, buildWorkspaceOntology as v, validateCollectionRecords as w, firstMutateParamProblem as x, schemaRelations as y, APPS_COLLECTION as z };
2922
2742
 
2923
- //# sourceMappingURL=server-B48Jyxcj.js.map
2743
+ //# sourceMappingURL=server-BNe8XMtu.js.map