@mulmoclaude/core 3.5.0 → 3.7.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 (56) hide show
  1. package/assets/helps/collection-skills.md +49 -1
  2. package/assets/helps/error-recovery.md +53 -0
  3. package/dist/calendarGrid-CQ8MVSRb.js.map +1 -1
  4. package/dist/calendarGrid-DGILaVxI.cjs.map +1 -1
  5. package/dist/collection/core/schema.d.ts +8 -1
  6. package/dist/collection/core/schemaZ.d.ts +36 -26
  7. package/dist/collection/firestore.cjs +51 -0
  8. package/dist/collection/firestore.cjs.map +1 -0
  9. package/dist/collection/firestore.d.ts +1 -0
  10. package/dist/collection/firestore.js +50 -0
  11. package/dist/collection/firestore.js.map +1 -0
  12. package/dist/collection/registry/server/index.cjs +19 -19
  13. package/dist/collection/registry/server/index.cjs.map +1 -1
  14. package/dist/collection/registry/server/index.js +2 -2
  15. package/dist/collection/server/appManifest.d.ts +53 -0
  16. package/dist/collection/server/delete.d.ts +10 -0
  17. package/dist/collection/server/discoveredCollection.d.ts +10 -0
  18. package/dist/collection/server/discovery.d.ts +1 -0
  19. package/dist/collection/server/firestoreDocs.d.ts +39 -0
  20. package/dist/collection/server/firestoreStore.d.ts +14 -0
  21. package/dist/collection/server/host.d.ts +52 -0
  22. package/dist/collection/server/index.cjs +72 -52
  23. package/dist/collection/server/index.d.ts +8 -1
  24. package/dist/collection/server/index.js +3 -3
  25. package/dist/collection/server/manageTool.d.ts +4 -0
  26. package/dist/collection/server/publish.d.ts +56 -0
  27. package/dist/collection/server/publishChecks.d.ts +29 -0
  28. package/dist/collection/server/publishManifest.d.ts +181 -0
  29. package/dist/collection/server/publishProject.d.ts +86 -0
  30. package/dist/collection/server/validate.d.ts +12 -0
  31. package/dist/collection-watchers/index.cjs +143 -52
  32. package/dist/collection-watchers/index.cjs.map +1 -1
  33. package/dist/collection-watchers/index.js +132 -41
  34. package/dist/collection-watchers/index.js.map +1 -1
  35. package/dist/collection-watchers/reconciler.d.ts +1 -1
  36. package/dist/feeds/server/index.cjs +10 -10
  37. package/dist/feeds/server/index.cjs.map +1 -1
  38. package/dist/feeds/server/index.js +2 -2
  39. package/dist/google/index.cjs +12 -12
  40. package/dist/google/index.cjs.map +1 -1
  41. package/dist/google/index.js +1 -1
  42. package/dist/{server-BiRLLMpW.js → server-B48Jyxcj.js} +1100 -230
  43. package/dist/server-B48Jyxcj.js.map +1 -0
  44. package/dist/{server-5EMj3naj.cjs → server-CWZyg8fn.cjs} +1333 -385
  45. package/dist/server-CWZyg8fn.cjs.map +1 -0
  46. package/dist/{discovery-Ck4AqikY.cjs → store-5_P_NsGa.cjs} +2108 -1755
  47. package/dist/store-5_P_NsGa.cjs.map +1 -0
  48. package/dist/{discovery-DH9wweuj.js → store-_61sO8K8.js} +2333 -2022
  49. package/dist/store-_61sO8K8.js.map +1 -0
  50. package/dist/whisper/index.cjs +1 -1
  51. package/dist/whisper/index.js +1 -1
  52. package/package.json +7 -1
  53. package/dist/discovery-Ck4AqikY.cjs.map +0 -1
  54. package/dist/discovery-DH9wweuj.js.map +0 -1
  55. package/dist/server-5EMj3naj.cjs.map +0 -1
  56. package/dist/server-BiRLLMpW.js.map +0 -1
@@ -1,15 +1,1054 @@
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, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.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
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 { $ as log, B as resolveDataDir, F as resolveCreateItemId, G as archiveDir, L as SCHEMA_FILE$1, M as isRegularFile, R as isContainedInRoot, S as queryCsv, T as CollectionQueryZ, U as safeSlugName, V as resolveTemplatePath, X as getWorkspaceRoot, Z as isPresetSlug$1, c as resolveMutateSet, d as storeFor, f as checkpointSqliteDatabase, g as cacheDir, i as resolvePrimaryField, k as isBackendUnavailable, n as discoverCollections, ot as stagingSkillDir, r as loadCollection, s as CollectionSchemaZ, u as readOnlyRefusal, w as compileJsonlQuery, x as normalizeCsvValue } from "./discovery-DH9wweuj.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";
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
+ //#region src/collection/server/publishManifest.ts
16
+ /** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
17
+ * that `sharedCollectionKey` applies. Stated once so a path built later
18
+ * cannot be a way around it. */
19
+ var NameZ = z.string().refine(isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
20
+ /** An address on the roster. Not validated as an email beyond "has an @":
21
+ * the rules compare it to `request.auth.token.email` verbatim, so any
22
+ * narrowing here would refuse addresses Firebase itself accepts. */
23
+ var EmailZ = z.string().trim().min(3).includes("@");
24
+ /** The four roles the deployed rules understand. `participant` is the layer
25
+ * that is NAMED but reads only its own rows — see `readerOf` vs `listedIn`. */
26
+ var APP_ROLES = [
27
+ "owner",
28
+ "editor",
29
+ "viewer",
30
+ "participant"
31
+ ];
32
+ var RoleZ = z.enum(APP_ROLES);
33
+ /** `{ email: { "*" | cid: role } }`. The `"*"` key is the app-wide role; a
34
+ * member may hold per-collection roles only (the stylist who is editor of
35
+ * bookings and viewer of everything else). */
36
+ var MembersZ = z.record(EmailZ, z.record(z.union([z.literal("*"), NameZ]), RoleZ));
37
+ /** The declarative mail queue, as the rules re-derive it: a transition of the
38
+ * status field, a recipient read off the RECORD, and a fixed template. */
39
+ var MailZ = z.object({
40
+ toField: z.string().trim().min(1),
41
+ on: z.record(z.string().trim().min(1), z.object({
42
+ from: z.array(z.string().trim().min(1)).min(1),
43
+ to: z.string().trim().min(1)
44
+ }).strict()),
45
+ dataFields: z.array(z.string().trim().min(1)).optional()
46
+ }).strict();
47
+ /** What the rules read out of `collections[cid]`. NOT the schema — the schema
48
+ * is published beside it, untouched, for clients to render from. */
49
+ var CollectionConfigZ = z.object({
50
+ statusField: z.string().trim().min(1).optional(),
51
+ /** `{ initial: [...], <status>: [<status>...] }`. Binds writers too, and
52
+ * binds `create` — that is the point of publishing it. */
53
+ transitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
54
+ immutable: z.boolean().optional(),
55
+ submitOnly: z.boolean().optional(),
56
+ peerVisibility: z.enum(["public", "hidden"]).optional(),
57
+ revealGated: z.boolean().optional(),
58
+ gatedFrom: NameZ.optional(),
59
+ revealBy: z.string().trim().min(1).optional(),
60
+ mail: MailZ.optional(),
61
+ /** Which fields an aggregate groups by. Declared here rather than in the
62
+ * schema for the same reason as everything else in this file — the schema
63
+ * has no `aggregate` key yet — and it is here at all because the
64
+ * invariant that guards it ("every aggregation key is a CHECKED field")
65
+ * is about `public.submit`, which is an app-level declaration. Published
66
+ * as-is; the rules never read it. */
67
+ aggregate: z.object({ by: z.array(z.string().trim().min(1)).min(1) }).strict().optional()
68
+ }).strict();
69
+ /** An authored submit window. ISO strings, because `app.json` is JSON and a
70
+ * Firestore `Timestamp` has no JSON form. Publish lowers it to epoch millis —
71
+ * the rules do not coerce strings, so an ISO string reaching Firestore is a
72
+ * type error that fails CLOSED (`inWindow` refuses every submission and the
73
+ * author sees "nobody can submit", not an error). */
74
+ var WindowZ = z.object({
75
+ from: z.iso.datetime().optional(),
76
+ until: z.iso.datetime().optional()
77
+ }).strict();
78
+ var ValidateZ = z.object({
79
+ required: z.array(z.string().trim().min(1)).optional(),
80
+ /** Capped at two by the rules themselves: rules have no iteration, so
81
+ * `keyFieldsOk` is unrolled. A third would be accepted here and silently
82
+ * unchecked there. */
83
+ keyFields: z.array(z.object({
84
+ field: z.string().trim().min(1),
85
+ values: z.array(z.union([
86
+ z.string(),
87
+ z.number(),
88
+ z.boolean()
89
+ ])).min(1)
90
+ }).strict()).optional()
91
+ }).strict();
92
+ var SubmitZ = z.object({
93
+ auth: z.enum([
94
+ "none",
95
+ "anonymous",
96
+ "verifiedEmail"
97
+ ]),
98
+ emailField: z.string().trim().min(1).optional(),
99
+ createFields: z.array(z.string().trim().min(1)).min(1),
100
+ initialStatus: z.string().trim().min(1).optional(),
101
+ idFrom: z.enum([
102
+ "auto",
103
+ "auth.uid",
104
+ "auth.uid+field"
105
+ ]).optional(),
106
+ idField: z.string().trim().min(1).optional(),
107
+ validate: ValidateZ.optional(),
108
+ window: WindowZ.optional(),
109
+ /** Per CURRENT STATUS, never a flat list: a flat list lets a customer move
110
+ * an approved booking's `startAt` without anyone re-approving it. */
111
+ selfUpdate: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
112
+ selfTransitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
113
+ finalize: z.boolean().optional(),
114
+ audience: z.literal("participant").optional(),
115
+ gateOn: z.object({
116
+ phase: z.string().trim().min(1),
117
+ match: z.string().trim().min(1)
118
+ }).strict().optional()
119
+ }).strict();
120
+ var PublicZ = z.object({
121
+ /** The master switch. Anonymous submission (`auth: "none"`) needs it as
122
+ * well as its own declaration. */
123
+ enabled: z.boolean().optional(),
124
+ read: z.array(NameZ).optional(),
125
+ submit: z.record(NameZ, SubmitZ).optional()
126
+ }).strict();
127
+ /** The whole authored declaration.
128
+ *
129
+ * `owner` is accepted but is NOT the published value — publish stamps the
130
+ * publisher's uid (or carries the existing one forward, which is what the
131
+ * rules require on update) and refuses a declaration that disagrees. It is
132
+ * accepted rather than banned because the sample app.json in the design note
133
+ * shows it, and a hard refusal on a key the samples contain would be a worse
134
+ * first experience than a message naming the mismatch. */
135
+ var AuthoredAppZ = z.object({
136
+ aid: NameZ,
137
+ name: z.string().trim().min(1).optional(),
138
+ /** Per-worktree app id (design D6, implementation order 7). Accepted so a
139
+ * repository already carrying it parses; nothing reads it yet. */
140
+ aidEnv: z.string().trim().min(1).optional(),
141
+ owner: z.string().trim().min(1).optional(),
142
+ members: MembersZ,
143
+ collections: z.record(NameZ, CollectionConfigZ).optional(),
144
+ participantRead: z.array(NameZ).optional(),
145
+ public: PublicZ.optional()
146
+ }).strict();
147
+ /** Parse the authored declaration out of `app.json`'s text.
148
+ *
149
+ * Returns a LIST of problems rather than throwing, for the same reason
150
+ * `loadAppManifest` returns a failure: the caller is a gate whose entire job
151
+ * is to hand the author something to act on. Every problem is reported at
152
+ * once — publish is a manual step, and a parser that stops at the first key
153
+ * makes it N round trips. */
154
+ function parseAuthoredApp(raw) {
155
+ const manifest = parseAppManifest(raw);
156
+ if (!manifest.ok) return {
157
+ ok: false,
158
+ problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
159
+ };
160
+ const parsed = AuthoredAppZ.safeParse(JSON.parse(raw));
161
+ if (!parsed.success) return {
162
+ ok: false,
163
+ problems: authoredProblems(parsed.error)
164
+ };
165
+ return {
166
+ ok: true,
167
+ app: parsed.data
168
+ };
169
+ }
170
+ /** zod issues as one actionable line each: `public.submit.responses.auth: …`. */
171
+ function authoredProblems(error) {
172
+ return error.issues.map((issue) => {
173
+ return `${issue.path.length > 0 ? issue.path.join(".") : "app.json"}: ${issue.message}`;
174
+ });
175
+ }
176
+ //#endregion
177
+ //#region src/collection/server/publishProject.ts
178
+ /** The document id under `apps/{aid}/config`. One document, named, rather than
179
+ * a spread of them: a second public document is a second thing to keep in
180
+ * step, and nothing yet needs one. */
181
+ var PUBLIC_CONFIG_DOC = "public";
182
+ /** Drop keys whose value is `undefined`. Firestore rejects an undefined field
183
+ * value outright, and `"k" in c` — which every optional key in the rules is
184
+ * read through — must mean "the author declared it". */
185
+ function compact(entries) {
186
+ return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== void 0));
187
+ }
188
+ /** ISO → epoch millis, the one conversion the rules cannot do for themselves.
189
+ * The caller has already refused an unparseable string (the authored parser
190
+ * requires `z.iso.datetime()`), so a NaN here would be a programming error;
191
+ * it is still checked, because a NaN written to Firestore fails closed in the
192
+ * same silent way an ISO string does. */
193
+ function windowMillis(window) {
194
+ if (!window) return void 0;
195
+ const out = {};
196
+ if (window.from !== void 0) out.fromMs = Date.parse(window.from);
197
+ if (window.until !== void 0) out.untilMs = Date.parse(window.until);
198
+ if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
199
+ return Object.keys(out).length > 0 ? out : void 0;
200
+ }
201
+ /** One `public.submit[cid]`, with its window lowered. Everything else passes
202
+ * through: the rules read these keys by the names the author wrote. */
203
+ function projectSubmit(submit) {
204
+ const { window, ...rest } = submit;
205
+ return compact({
206
+ ...rest,
207
+ window: windowMillis(window)
208
+ });
209
+ }
210
+ /** The roster's addresses as a set, in a stable order.
211
+ *
212
+ * Sorted so two publishes of the same declaration produce the same document
213
+ * — idempotence is a property this step is tested for, and Firestore compares
214
+ * arrays by ORDER. `membersConsistent()` compares as sets and would accept
215
+ * any order; the test that would notice is the one asserting a second publish
216
+ * changes nothing but `publishedAt`. */
217
+ function memberEmailsOf(members) {
218
+ return Object.keys(members).sort();
219
+ }
220
+ /** The previous document, kept for rollback, with its OWN `previousPublished`
221
+ * stripped.
222
+ *
223
+ * One level, deliberately. Chaining would make every publish carry the entire
224
+ * history of the app inside a single document, which grows without bound and
225
+ * meets Firestore's 1 MiB document limit as a permission-shaped failure at
226
+ * some unpredictable publish. One level answers the question rollback
227
+ * actually asks — "put back what was there before I broke it" — and the
228
+ * further history is in git, which is where the declaration came from. */
229
+ function previousOf(existing) {
230
+ if (!existing) return void 0;
231
+ const { previousPublished: __dropped, ...rest } = existing;
232
+ return rest;
233
+ }
234
+ /** Project the authored declaration into the documents publish writes.
235
+ *
236
+ * `existing` is the app document as it is in Firestore right now, or null on
237
+ * a first publish. Two things need it, both required by the rules:
238
+ * - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
239
+ * would be refused for any app whose owner ever signed in as a different
240
+ * account, and would silently transfer ownership if it were not.
241
+ * - `previousPublished` is that document, so a rollback has something to
242
+ * put back.
243
+ *
244
+ * Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
245
+ * a parameter, which is what makes the conversion table testable as a table. */
246
+ function projectApp(authored, schemas, stamp, existing) {
247
+ const owner = typeof existing?.owner === "string" ? existing.owner : stamp.uid;
248
+ const submit = Object.fromEntries(Object.entries(authored.public?.submit ?? {}).map(([cid, spec]) => [cid, projectSubmit(spec)]));
249
+ const publicBlock = authored.public ? compact({
250
+ enabled: authored.public.enabled,
251
+ read: authored.public.read,
252
+ submit: Object.keys(submit).length > 0 ? submit : void 0
253
+ }) : void 0;
254
+ const app = compact({
255
+ aid: authored.aid,
256
+ name: authored.name,
257
+ owner,
258
+ members: authored.members,
259
+ memberEmails: memberEmailsOf(authored.members),
260
+ collections: authored.collections,
261
+ participantRead: authored.participantRead,
262
+ public: publicBlock,
263
+ publishedAt: stamp.publishedAt,
264
+ publishedBy: stamp.email,
265
+ publishedCommit: stamp.commit,
266
+ previousPublished: previousOf(existing)
267
+ });
268
+ const config = {
269
+ enabled: authored.public?.enabled === true,
270
+ read: authored.public?.read ?? [],
271
+ submit,
272
+ publishedAt: stamp.publishedAt
273
+ };
274
+ if (authored.name !== void 0) config.name = authored.name;
275
+ return {
276
+ app,
277
+ schemas: schemas.map(({ cid, schema }) => ({
278
+ cid,
279
+ doc: schemaDoc(schema, stamp)
280
+ })),
281
+ config
282
+ };
283
+ }
284
+ /** One published schema document. Written key by key rather than through
285
+ * `compact`, so the declared type is the type — an optional commit is the
286
+ * only variable part. */
287
+ function schemaDoc(schema, stamp) {
288
+ const doc = {
289
+ publishedSchema: schema,
290
+ publishedAt: stamp.publishedAt,
291
+ publishedBy: stamp.email
292
+ };
293
+ if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
294
+ return doc;
295
+ }
296
+ /** The app documents' parent path — the `FirestoreDocs` seam takes a
297
+ * collection path plus a document id, and the app document's id is the aid. */
298
+ var APPS_COLLECTION = "apps";
299
+ /** The collection (schema) documents' parent path. */
300
+ var appSchemasPath = (aid) => `apps/${aid}/collections`;
301
+ /** The public-config documents' parent path. */
302
+ var appConfigPath = (aid) => `apps/${aid}/config`;
303
+ //#endregion
304
+ //#region src/collection/server/publishChecks.ts
305
+ /** Does this submit declaration bind a record to the submitter's identity?
306
+ *
307
+ * The condition for requiring `submitOnly`, and deliberately NOT "declares an
308
+ * `audience`": `audience` appears only in the rules' public-create branch, so
309
+ * an owner or editor never meets it and can add records freely. `immutable`
310
+ * is the wrong condition too — a survey's responses are not immutable and
311
+ * can be padded exactly the same way.
312
+ *
313
+ * What these four have in common is that each one makes the record MEAN "the
314
+ * person who submitted it said this": a per-uid id, a per-uid+field id, a
315
+ * row stamped with the submitter's verified address, or a submission
316
+ * restricted to a named participant. A record created through the writer
317
+ * branch carries the same shape and none of that meaning. */
318
+ function bindsSubmitterIdentity(submit) {
319
+ return submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field" || submit.emailField !== void 0 || submit.audience === "participant";
320
+ }
321
+ /** The fields a rule actually CHECKS the value of, for one collection.
322
+ *
323
+ * `keyFields` pins a value against a declared set, `gateOn.match` pins it
324
+ * against the session's current question, and the status field is pinned by
325
+ * the transition machine. An aggregation grouped by anything else is grouped
326
+ * by a field any submitter may write anything into — so the published
327
+ * aggregate is whatever the noisiest respondent decided it should be. */
328
+ function checkedFields(collection, submit) {
329
+ const fields = /* @__PURE__ */ new Set();
330
+ for (const keyField of submit?.validate?.keyFields ?? []) fields.add(keyField.field);
331
+ if (submit?.gateOn) fields.add(submit.gateOn.match);
332
+ if (collection?.statusField) fields.add(collection.statusField);
333
+ return fields;
334
+ }
335
+ /** INVARIANT 1 — a submission bound to its submitter needs `submitOnly`. */
336
+ function submitOnlyProblems(app) {
337
+ const problems = [];
338
+ for (const [cid, submit] of Object.entries(app.public?.submit ?? {})) {
339
+ if (!bindsSubmitterIdentity(submit)) continue;
340
+ if (app.collections?.[cid]?.submitOnly === true) continue;
341
+ problems.push(`collections.${cid}.submitOnly must be true: public.submit.${cid} binds each record to its submitter (${identityBindings(submit).join(", ")}), so a record created any other way would carry that meaning without having earned it. Without submitOnly the rules let an owner or editor write rows directly into ${cid}.`);
342
+ }
343
+ return problems;
344
+ }
345
+ function identityBindings(submit) {
346
+ const bindings = [];
347
+ if (submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field") bindings.push(`idFrom: "${submit.idFrom}"`);
348
+ if (submit.emailField !== void 0) bindings.push(`emailField: "${submit.emailField}"`);
349
+ if (submit.audience === "participant") bindings.push(`audience: "participant"`);
350
+ return bindings;
351
+ }
352
+ /** INVARIANT 2 — every aggregation key is a field some rule checks. */
353
+ function aggregateProblems(app) {
354
+ const problems = [];
355
+ for (const [cid, collection] of Object.entries(app.collections ?? {})) {
356
+ const keys = collection.aggregate?.by;
357
+ if (!keys) continue;
358
+ const checked = checkedFields(collection, app.public?.submit?.[cid]);
359
+ const loose = keys.filter((field) => !checked.has(field));
360
+ const spelled = loose.map((field) => `'${field}'`).join(", ");
361
+ if (loose.length > 0) problems.push(`collections.${cid}.aggregate.by names ${spelled}, which no rule checks the value of. An aggregation key must appear in public.submit.${cid}.validate.keyFields, in gateOn.match, or be the statusField — otherwise a submitter chooses their own bucket and the published aggregate is not a count of anything.`);
362
+ }
363
+ return problems;
364
+ }
365
+ /** INVARIANT 3 — `auth: "verifiedEmail"` only.
366
+ *
367
+ * A product decision, not a rules limitation: the rules keep all three stages
368
+ * and the emulator tests keep exercising them, because deleting a stage from
369
+ * the rules turns a change of mind into a cross-repo deploy. Publish is where
370
+ * the current decision is expressed, and it is one line to move. */
371
+ function authProblems(app) {
372
+ return Object.entries(app.public?.submit ?? {}).filter(([, submit]) => submit.auth !== "verifiedEmail").map(([cid, submit]) => `public.submit.${cid}.auth is "${submit.auth}": only "verifiedEmail" may be published. The rules still implement "none" and "anonymous" — this is a product decision, and lifting it is a change here, not a rules deploy.`);
373
+ }
374
+ /** INVARIANT 5 — a mail transition's origins and destination must be disjoint.
375
+ *
376
+ * Overlap means the same write can satisfy the same template twice over, and
377
+ * the deterministic mail id is the only other thing stopping a duplicate
378
+ * send. The rules also require the status to have CHANGED, so an overlapping
379
+ * declaration is not merely redundant: `from` containing `to` is a transition
380
+ * that can never fire, which is a mail nobody ever receives. */
381
+ function mailProblems(app) {
382
+ return Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => collectionMailProblems(cid, collection));
383
+ }
384
+ function collectionMailProblems(cid, collection) {
385
+ const { mail } = collection;
386
+ if (!mail) return [];
387
+ const problems = [];
388
+ if (!collection.statusField) problems.push(`collections.${cid}.mail needs collections.${cid}.statusField: the rules read the status before and after the write to decide the mail is warranted.`);
389
+ for (const [template, transition] of Object.entries(mail.on)) problems.push(...templateMailProblems(cid, collection, template, transition));
390
+ return problems;
391
+ }
392
+ function templateMailProblems(cid, collection, template, transition) {
393
+ const problems = [];
394
+ if (transition.from.includes(transition.to)) problems.push(`collections.${cid}.mail.on.${template} lists "${transition.to}" in both \`from\` and \`to\`. The rules require the status to CHANGE in the same write, so this template can never send.`);
395
+ const allowed = collection.transitions;
396
+ if (allowed) {
397
+ const unreachable = transition.from.filter((from) => !(allowed[from] ?? []).includes(transition.to));
398
+ const spelled = unreachable.map((from) => `'${from}' -> '${transition.to}'`).join(", ");
399
+ if (unreachable.length > 0) problems.push(`collections.${cid}.mail.on.${template} sends on ${spelled}, which collections.${cid}.transitions does not allow. The record write is refused first, so the mail never fires.`);
400
+ }
401
+ return problems;
402
+ }
403
+ /** INVARIANTS 6 and 7 — the window is a real interval, and `keyFields` fits
404
+ * the unrolled check in the rules. */
405
+ function submitShapeProblems(app) {
406
+ return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => [...windowProblems(cid, submit), ...keyFieldCountProblems(cid, submit)]);
407
+ }
408
+ function windowProblems(cid, submit) {
409
+ const { window } = submit;
410
+ if (window?.from === void 0 || window.until === void 0) return [];
411
+ if (Date.parse(window.until) > Date.parse(window.from)) return [];
412
+ return [`public.submit.${cid}.window closes at or before it opens (${window.from} -> ${window.until}): nothing could ever be submitted.`];
413
+ }
414
+ function keyFieldCountProblems(cid, submit) {
415
+ const keyFields = submit.validate?.keyFields ?? [];
416
+ if (keyFields.length <= 2) return [];
417
+ return [`public.submit.${cid}.validate.keyFields declares ${keyFields.length}; the rules check at most 2. Rules have no iteration, so the check is unrolled — a third would be published and never enforced.`];
418
+ }
419
+ /** The fail-closed traps: declarations the rules read together, where the
420
+ * missing half denies every write instead of loosening one. */
421
+ function coherenceProblems(app) {
422
+ const fromSubmits = Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitCoherenceProblems(app, cid, submit));
423
+ const fromCollections = Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => gateCoherenceProblems(cid, collection));
424
+ return [...fromSubmits, ...fromCollections];
425
+ }
426
+ /** `initialStatus` is read together with the collection's `statusField` and
427
+ * with `createFields`; miss either and every submission is refused. */
428
+ function statusCoherenceProblems(cid, submit, collection) {
429
+ if (submit.initialStatus === void 0) return [];
430
+ if (!collection?.statusField) return [`public.submit.${cid}.initialStatus needs collections.${cid}.statusField: the rules look the status up by that name, and refuse every submission without it.`];
431
+ if (new Set(submit.createFields).has(collection.statusField)) return [];
432
+ return [`public.submit.${cid}.createFields must include "${collection.statusField}": a submission may carry ONLY the createFields, and the rules also require the status field to be present and equal to initialStatus. As written, every submission is refused.`];
433
+ }
434
+ /** Every field a RULE reads off a submitted record, other than the status
435
+ * field (which `statusCoherenceProblems` words for itself).
436
+ *
437
+ * `emailField` and `idField` belong here for exactly the reason `required`
438
+ * and `keyFields` do, and forgetting them was the same oversight twice: the
439
+ * rules read `request.resource.data[s.emailField]` and rebuild the document
440
+ * id from `s.idField`, while `hasOnly(createFields)` decides what a
441
+ * submission may carry at all. A field in one list and not the other is a
442
+ * contradiction the submitter cannot resolve — including it is refused,
443
+ * omitting it fails the check. */
444
+ function ruleReadFields(submit) {
445
+ const fields = [];
446
+ if (submit.emailField !== void 0) fields.push({
447
+ field: submit.emailField,
448
+ why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
449
+ });
450
+ if (submit.idFrom === "auth.uid+field" && submit.idField !== void 0) fields.push({
451
+ field: submit.idField,
452
+ why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
453
+ });
454
+ return fields;
455
+ }
456
+ /** A checked field a submission is not allowed to carry can never be
457
+ * satisfied: carrying it fails `hasOnly`, omitting it fails the check. */
458
+ function createFieldProblems(cid, submit) {
459
+ const createFields = new Set(submit.createFields);
460
+ const ruleRead = ruleReadFields(submit).filter((entry) => !createFields.has(entry.field)).map((entry) => `public.submit.${cid}.createFields must include "${entry.field}" (${entry.why.replace("<cid>", cid)}): a submission may carry only the createFields, so as written every submission is refused whether or not it carries the field.`);
461
+ const required = (submit.validate?.required ?? []).filter((field) => !createFields.has(field)).map((field) => `public.submit.${cid}.validate.required names "${field}", which is not in createFields: a submission may carry only the createFields, so the requirement can never be met.`);
462
+ const keyFields = (submit.validate?.keyFields ?? []).filter((keyField) => !createFields.has(keyField.field)).map((keyField) => `public.submit.${cid}.validate.keyFields checks "${keyField.field}", which is not in createFields: a submission carrying it is refused, and one omitting it fails the check.`);
463
+ return [
464
+ ...ruleRead,
465
+ ...required,
466
+ ...keyFields
467
+ ];
468
+ }
469
+ function submitCoherenceProblems(app, cid, submit) {
470
+ const collection = app.collections?.[cid];
471
+ const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
472
+ if (submit.idFrom === "auth.uid+field" && submit.idField === void 0) problems.push(`public.submit.${cid}.idFrom is "auth.uid+field" but no idField is declared: the rules rebuild the document id from that field and refuse every create.`);
473
+ if ((submit.selfUpdate !== void 0 || submit.selfTransitions !== void 0) && !collection?.statusField) problems.push(`public.submit.${cid}.selfUpdate / selfTransitions are declared per CURRENT STATUS, but collections.${cid} declares no statusField: the rules read the current status first and refuse every self-edit without it.`);
474
+ if (submit.audience === "participant" && Object.keys(app.members).length === 0) problems.push(`public.submit.${cid}.audience is "participant" but the roster is empty: the rules resolve the submitter's role from members, so every submission is refused.`);
475
+ return problems;
476
+ }
477
+ /** The staged reveal reads its flag off the PARENT record, so the path to that
478
+ * parent is not optional decoration — without it the gate never opens. */
479
+ function gateCoherenceProblems(cid, collection) {
480
+ if (collection.revealGated !== true) return [];
481
+ if (collection.gatedFrom !== void 0 && collection.revealBy !== void 0) return [];
482
+ return [`collections.${cid}.revealGated needs both gatedFrom and revealBy: the flag is read off the PARENT record, and without the path the gate never opens.`];
483
+ }
484
+ /** The publisher must be able to write what they are about to write.
485
+ *
486
+ * On a first publish the rules require the creator to name themselves owner,
487
+ * in the roster, under `'*'`. Getting this wrong produces a bare permission
488
+ * error from Firestore with nothing in it about rosters — worth one line
489
+ * here instead. */
490
+ function publisherProblems(app, publisherEmail) {
491
+ if (app.members[publisherEmail]?.["*"] === "owner") return [];
492
+ return [`members must give you app-wide owner: add "${publisherEmail}": { "*": "owner" }. The rules require the publisher to hold that role (and to name themselves owner when the app is first created); otherwise the write is refused with no explanation.`];
493
+ }
494
+ /** Every cid the declaration mentions must be a collection that exists.
495
+ *
496
+ * A typo'd cid is not an error anywhere else: the app document simply carries
497
+ * a configuration for a collection nobody publishes, and the collection the
498
+ * author meant is published with no configuration at all — i.e. with the
499
+ * status machine and the submit path silently absent. */
500
+ function unknownCidProblems(app, collections) {
501
+ const known = new Set(collections.map((collection) => collection.cid));
502
+ return [
503
+ ["collections", Object.keys(app.collections ?? {})],
504
+ ["public.read", app.public?.read ?? []],
505
+ ["public.submit", Object.keys(app.public?.submit ?? {})],
506
+ ["participantRead", app.participantRead ?? []]
507
+ ].flatMap(([where, cids]) => cids.filter((cid) => !known.has(cid)).map((cid) => `${where} names '${cid}', which is not a shared collection in this repository. Shared collections here: ${known.size > 0 ? [...known].sort().join(", ") : "(none - a schema needs storage.type \"firestore\")"}.`));
508
+ }
509
+ /** Everything publish refuses, as lines the author can act on.
510
+ *
511
+ * All of them, every time. Publish is a manual step with a human waiting on
512
+ * it; stopping at the first problem turns one review into five. */
513
+ function publishProblems(app, collections, publisherEmail) {
514
+ return [
515
+ ...unknownCidProblems(app, collections),
516
+ ...publisherProblems(app, publisherEmail),
517
+ ...submitOnlyProblems(app),
518
+ ...aggregateProblems(app),
519
+ ...authProblems(app),
520
+ ...mailProblems(app),
521
+ ...submitShapeProblems(app),
522
+ ...coherenceProblems(app),
523
+ ...primaryKeyProblems(app, collections)
524
+ ];
525
+ }
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
+ };
1050
+ }
1051
+ //#endregion
13
1052
  //#region src/collection/server/skillAssets.ts
14
1053
  /** Read a collection's custom-view HTML, path-safely. `viewFile` is a
15
1054
  * schema-validated `views/*.html` path, resolved with realpath containment.
@@ -373,226 +1412,6 @@ async function runCollectionQuery(collection, query, opts = {}) {
373
1412
  return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
374
1413
  }
375
1414
  //#endregion
376
- //#region src/collection/core/recordZ.ts
377
- /** The emptiness rule shared by `required` and the "only check present
378
- * values" gate. NOT a truthiness check — `0` and `false` are filled. */
379
- var isEmptyValue = (value) => value === void 0 || value === null || value === "";
380
- /** The historical write-gate checks, verbatim: required non-empty, enum
381
- * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
382
- function enforcedProblem(key, spec, value) {
383
- const empty = isEmptyValue(value);
384
- if (spec.required && empty) return `missing required field '${key}'`;
385
- if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
386
- return null;
387
- }
388
- /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
389
- * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
390
- * the lint flags exactly the values the calendar / trigger / spawn code
391
- * would silently drop — impossible days like `2026-02-30`, and datetimes
392
- * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
393
- * which the day view can't place). `string`-backed types accept anything
394
- * stringifiable; `ref` existence is out of scope. */
395
- function strictTypeProblem(key, spec, value) {
396
- switch (spec.type) {
397
- case "number":
398
- case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
399
- case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
400
- case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
401
- 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)`;
402
- default: return null;
403
- }
404
- }
405
- /** Strict check for a PRESENT `table` value: an array of row objects, each
406
- * row conforming to the sub-schema (required / enum / typed sub-values).
407
- * First row problem wins, prefixed with the row number so the fix is
408
- * locatable. */
409
- function strictTableProblem(key, spec, value) {
410
- if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
411
- for (let index = 0; index < value.length; index++) {
412
- const row = value[index];
413
- if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
414
- for (const [subKey, subSpec] of Object.entries(spec.of)) {
415
- const subValue = row[subKey];
416
- const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
417
- if (problem) return `'${key}' row ${index + 1}: ${problem}`;
418
- }
419
- }
420
- return null;
421
- }
422
- /** First problem for one field's stored value under `tier`, or null.
423
- * Enforced checks always run (and their messages never vary by tier — the
424
- * scan and the write gate must agree on them); strict adds the per-type
425
- * layer on present values only. */
426
- function recordFieldProblem(key, spec, value, tier) {
427
- const enforced = enforcedProblem(key, spec, value);
428
- if (enforced || tier === "enforced") return enforced;
429
- if (isEmptyValue(value)) return null;
430
- if (spec.type === "table") return strictTableProblem(key, spec, value);
431
- return strictTypeProblem(key, spec, value);
432
- }
433
- var compiled = /* @__PURE__ */ new WeakMap();
434
- /** Compile `schema.fields` into a zod validator for a stored record.
435
- * Loose object: unknown keys are allowed and any declared key may be
436
- * absent (records are user files, not parse-and-rewrite targets —
437
- * callers validate, they never persist the parse output). The checks run
438
- * as ONE object-level refine iterating fields in declaration order —
439
- * per-key shape schemas can't express "key may be absent BUT its absence
440
- * must still reach the required check", and the single loop keeps the
441
- * first reported issue identical to the historical first-problem-wins
442
- * contract. */
443
- function compileRecordZ(schema, tier) {
444
- const cached = compiled.get(schema)?.[tier];
445
- if (cached) return cached;
446
- const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
447
- const validator = z.looseObject({}).superRefine((record, ctx) => {
448
- for (const [key, spec] of stored) {
449
- const problem = recordFieldProblem(key, spec, record[key], tier);
450
- if (problem) ctx.addIssue({
451
- code: "custom",
452
- message: problem,
453
- path: [key]
454
- });
455
- }
456
- });
457
- const entry = compiled.get(schema) ?? {};
458
- entry[tier] = validator;
459
- compiled.set(schema, entry);
460
- return validator;
461
- }
462
- /** First schema problem on an in-memory record under `tier`, or null. One
463
- * issue per record keeps the report short and the fix obvious (the
464
- * historical contract of `validateRecordObject`). */
465
- function firstRecordProblem(record, schema, tier) {
466
- const result = compileRecordZ(schema, tier).safeParse(record);
467
- if (result.success) return null;
468
- return result.error.issues[0]?.message ?? "record failed schema validation";
469
- }
470
- //#endregion
471
- //#region src/collection/server/validate.ts
472
- var MAX_ISSUES = 25;
473
- /** Read every `<id>.json` under the collection's dataDir and report the
474
- * ones that won't load or violate the schema. An empty list means every
475
- * record is fine. */
476
- /** List entries under the data dir, guarding realpath containment (against a
477
- * symlinked dir swapped in after discovery, like `listItems`) and treating a
478
- * missing dir as empty while surfacing real I/O faults. */
479
- async function listRecordFilenames(dataDir, workspaceRoot) {
480
- if (!isContainedInRoot(dataDir, workspaceRoot)) {
481
- log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
482
- return [];
483
- }
484
- try {
485
- return await readdir(dataDir);
486
- } catch (err) {
487
- if (isErrorWithCode(err) && err.code === "ENOENT") return [];
488
- throw err;
489
- }
490
- }
491
- async function validateCollectionRecords(collection, opts = {}) {
492
- if (collection.schema.dataSource !== void 0) return [];
493
- if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
494
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
495
- const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
496
- const issues = [];
497
- for (const name of entries.sort()) {
498
- if (!name.endsWith(".json") || name.startsWith(".")) continue;
499
- if (issues.length >= MAX_ISSUES) break;
500
- const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
501
- if (issue) issues.push(issue);
502
- }
503
- return issues;
504
- }
505
- /** Store-backed twin of the file scan: list every record through the
506
- * collection's store and lint it with the same "strict" report-only tier.
507
- * A row the store can't even parse is invisible here (the store skips
508
- * it), so the read/parse classifications of the file scan don't apply —
509
- * schema violations are what this catches. `file` carries the record id
510
- * (there is no per-record filename). */
511
- async function validateStoreRecords(collection, opts) {
512
- let items;
513
- try {
514
- items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
515
- } catch (err) {
516
- return [{
517
- file: "(store)",
518
- problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
519
- }];
520
- }
521
- const issues = [];
522
- for (const item of items) {
523
- if (issues.length >= MAX_ISSUES) break;
524
- const itemId = fieldText(item[collection.schema.primaryKey]);
525
- const problem = validateRecordObject(item, itemId, collection.schema, "strict");
526
- if (problem) issues.push({
527
- file: itemId,
528
- problem
529
- });
530
- }
531
- return issues;
532
- }
533
- async function readRecordText(fullPath, name) {
534
- try {
535
- if (!(await lstat(fullPath)).isFile()) return {
536
- file: name,
537
- problem: "not a regular file (symlink?) — skipped, won't appear"
538
- };
539
- return { raw: await readFile(fullPath, "utf-8") };
540
- } catch {
541
- return {
542
- file: name,
543
- problem: "could not be read — skipped, won't appear"
544
- };
545
- }
546
- }
547
- /** Classify a single record file: unreadable / unparseable / non-object /
548
- * schema violation, or null when it's fine. */
549
- async function inspectRecord(fullPath, name, schema) {
550
- const read = await readRecordText(fullPath, name);
551
- if ("problem" in read) return read;
552
- let parsed;
553
- try {
554
- parsed = JSON.parse(read.raw);
555
- } catch (err) {
556
- return {
557
- file: name,
558
- 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.`
559
- };
560
- }
561
- if (!isRecord(parsed)) return {
562
- file: name,
563
- problem: "not a JSON object — skipped, won't appear"
564
- };
565
- const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
566
- return problem ? {
567
- file: name,
568
- problem
569
- } : null;
570
- }
571
- /** What a non-string primary key actually is, for the error message. Names the
572
- * shape rather than stringifying the value — "[object Object]" tells the reader
573
- * nothing about what is wrong. */
574
- function describeIdType(value) {
575
- if (value === null) return "null";
576
- if (value === void 0) return "missing";
577
- if (Array.isArray(value)) return "an array";
578
- return `a ${typeof value}`;
579
- }
580
- /** First schema problem on an in-memory record (primaryKey↔id mismatch,
581
- * then the compiled per-field checks — see `../core/recordZ` for the two
582
- * tiers), or null when it's fine. One issue per record keeps the report
583
- * short and the fix obvious. Pure + exported so write paths
584
- * (manageCollection putItems) can gate on the SAME enforced rules the
585
- * post-hoc file scan reports — `itemId` is the id the record is (or
586
- * would be) stored under. The default `"enforced"` tier keeps every
587
- * write gate on the historical three checks; only pass `"strict"` from
588
- * report-only surfaces. */
589
- function validateRecordObject(record, itemId, schema, tier = "enforced") {
590
- const idValue = record[schema.primaryKey];
591
- 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`;
592
- if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
593
- return firstRecordProblem(record, schema, tier);
594
- }
595
- //#endregion
596
1415
  //#region src/collection/server/mutate.ts
597
1416
  /** First problem with the submitted params, or null. Every declared param
598
1417
  * is checked by the shared record-field validator; keys the action never
@@ -1022,7 +1841,8 @@ function deleteCollectionRefusalMessage(result) {
1022
1841
  "user-scope": `collection '${slug}' is user-scope (~/.claude/skills/) and is read-only from MulmoClaude`,
1023
1842
  preset: `collection '${slug}' is a preset (mc-*) and re-seeds on restart; unstar it from the catalog instead`,
1024
1843
  "unsafe-data-path": `collection '${slug}' declares a dataPath outside its own data/${slug}/ subtree; refusing to delete`,
1025
- "path-escape": `a directory for collection '${slug}' escapes the workspace`
1844
+ "path-escape": `a directory for collection '${slug}' escapes the workspace`,
1845
+ "unsupported-backend": `collection '${slug}' is a shared collection — its records are documents of its app, which this delete can neither archive nor remove, and other members read the same documents. Removing its records first does NOT unlock it. To retire the whole app, a Firestore project administrator deletes it recursively (\`firebase firestore:delete "apps/<aid>" --recursive\`, children first); the app owner's client credentials cannot do it.`
1026
1846
  }[result.kind];
1027
1847
  }
1028
1848
  async function pathExists(target) {
@@ -1078,6 +1898,10 @@ function isDataDirSafe(dataDir, slug, workspaceRoot) {
1078
1898
  * `dataSource` collection has no record files to copy (its rows live in
1079
1899
  * the external data file, which the delete never touches). */
1080
1900
  function restoreRecordsStep(schema) {
1901
+ if (schema.storage?.type === "firestore") return `2. Records: NOT archived. This is a shared collection: its records are
1902
+ documents at \`apps/<aid>/collections/<cid>/items\`, which this delete did
1903
+ not touch or export. They are still there, and other members still read
1904
+ them.`;
1081
1905
  if (schema.storage !== void 0) return `2. Records: copy the archived database file
1082
1906
  \`${path.basename(schema.storage.path)}\` (next to this document) back to
1083
1907
  \`${schema.storage.path}\` (workspace-relative, \`cp\`). It holds every
@@ -1136,9 +1960,16 @@ ${restoreRecordsStep(schema)}
1136
1960
 
1137
1961
  - slug: \`${slug}\`
1138
1962
  - title: ${schema.title}
1139
- - dataPath: \`${schema.dataPath ?? (schema.storage !== void 0 ? `(storage) ${schema.storage.path}` : `(dataSource) ${schema.dataSource?.path}`)}\`
1963
+ - dataPath: \`${schema.dataPath ?? recordLocationLabel(schema)}\`
1140
1964
  `;
1141
1965
  }
1966
+ /** Where a non-`dataPath` collection's records live, for the restore doc's
1967
+ * header line. */
1968
+ function recordLocationLabel(schema) {
1969
+ if (schema.storage?.type === "firestore") return "(storage) shared app";
1970
+ if (schema.storage !== void 0) return `(storage) ${schema.storage.path}`;
1971
+ return `(dataSource) ${schema.dataSource?.path}`;
1972
+ }
1142
1973
  /** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
1143
1974
  async function writeArchive(collection, archiveDir, workspaceRoot) {
1144
1975
  const staging = stagingSkillDir(workspaceRoot, collection.slug);
@@ -1196,6 +2027,13 @@ async function deleteCollection(collection, opts = {}) {
1196
2027
  kind: "preset",
1197
2028
  slug
1198
2029
  };
2030
+ if (collection.schema.storage?.type === "firestore") {
2031
+ log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
2032
+ return {
2033
+ kind: "unsupported-backend",
2034
+ slug
2035
+ };
2036
+ }
1199
2037
  if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
1200
2038
  log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
1201
2039
  slug,
@@ -1813,6 +2651,32 @@ async function handleGetOntology(deps) {
1813
2651
  collections
1814
2652
  });
1815
2653
  }
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
+ }
1816
2680
  /** Return the collection-authoring reference (`collection-skills.md`),
1817
2681
  * rendered by `renderSchemaDocs` — the full doc overflows the agent's
1818
2682
  * per-result limit, so the default reply is the core guide + a table of
@@ -1921,7 +2785,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
1921
2785
  written: true
1922
2786
  });
1923
2787
  }
1924
- 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.";
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.";
1925
2789
  /** Validate getItems' optional `ids`/`fields` args, then delegate. */
1926
2790
  async function dispatchGetItems(collection, args, deps) {
1927
2791
  const ids = optionalStringArray(args.ids, "ids");
@@ -1966,18 +2830,19 @@ async function dispatchManageCollection(deps, args) {
1966
2830
  const action = typeof args.action === "string" ? args.action : "";
1967
2831
  if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
1968
2832
  if (action === "getOntology") return handleGetOntology(deps);
2833
+ if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
1969
2834
  const slug = typeof args.slug === "string" ? args.slug.trim() : "";
1970
2835
  if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
1971
2836
  if (action === "getSchema") return handleGetSchema(slug, deps);
1972
2837
  if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
1973
- if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
2838
+ if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", \"putSchema\", or \"publishApp\".";
1974
2839
  const collection = await loadCollection(slug, deps);
1975
2840
  if (!collection) return unknownCollection(slug);
1976
2841
  return dispatchRecordAction(action, collection, args, deps);
1977
2842
  }
1978
2843
  var MANAGE_COLLECTION_DEFINITION = {
1979
2844
  name: "manageCollection",
1980
- 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.",
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.",
1981
2846
  inputSchema: {
1982
2847
  type: "object",
1983
2848
  properties: {
@@ -1991,7 +2856,8 @@ var MANAGE_COLLECTION_DEFINITION = {
1991
2856
  "getOntology",
1992
2857
  "schemaDocs",
1993
2858
  "getSchema",
1994
- "putSchema"
2859
+ "putSchema",
2860
+ "publishApp"
1995
2861
  ],
1996
2862
  description: "What to do."
1997
2863
  },
@@ -2027,6 +2893,10 @@ var MANAGE_COLLECTION_DEFINITION = {
2027
2893
  type: "object",
2028
2894
  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."
2029
2895
  },
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
+ },
2030
2900
  topic: {
2031
2901
  type: "string",
2032
2902
  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)."
@@ -2048,6 +2918,6 @@ function makeManageCollectionTool(deps = {}) {
2048
2918
  };
2049
2919
  }
2050
2920
  //#endregion
2051
- export { buildCollectionActionSeedPrompt as A, validateRecordObject as C, enrichItems as D, runCollectionQuery as E, readCustomViewHtml as M, readCustomViewI18n as N, runQueryOverRows as O, readSkillTemplate as P, validateCollectionRecords as S, recordFieldProblem as T, 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, promptPathsFor as j, buildActionSeedPrompt as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, buildWorkspaceOntology as v, compileRecordZ as w, firstMutateParamProblem as x, schemaRelations as y };
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 };
2052
2922
 
2053
- //# sourceMappingURL=server-BiRLLMpW.js.map
2923
+ //# sourceMappingURL=server-B48Jyxcj.js.map