@mulmoclaude/core 3.6.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 (37) hide show
  1. package/dist/collection/registry/server/index.cjs +19 -19
  2. package/dist/collection/registry/server/index.cjs.map +1 -1
  3. package/dist/collection/registry/server/index.js +2 -2
  4. package/dist/collection/server/host.d.ts +11 -0
  5. package/dist/collection/server/index.cjs +72 -59
  6. package/dist/collection/server/index.d.ts +4 -0
  7. package/dist/collection/server/index.js +3 -3
  8. package/dist/collection/server/manageTool.d.ts +4 -0
  9. package/dist/collection/server/publish.d.ts +56 -0
  10. package/dist/collection/server/publishChecks.d.ts +29 -0
  11. package/dist/collection/server/publishManifest.d.ts +181 -0
  12. package/dist/collection/server/publishProject.d.ts +86 -0
  13. package/dist/collection/server/validate.d.ts +12 -0
  14. package/dist/collection-watchers/index.cjs +15 -15
  15. package/dist/collection-watchers/index.cjs.map +1 -1
  16. package/dist/collection-watchers/index.js +2 -2
  17. package/dist/feeds/server/index.cjs +10 -10
  18. package/dist/feeds/server/index.cjs.map +1 -1
  19. package/dist/feeds/server/index.js +2 -2
  20. package/dist/google/index.cjs +12 -12
  21. package/dist/google/index.cjs.map +1 -1
  22. package/dist/google/index.js +1 -1
  23. package/dist/{server-DdHaX_Uu.js → server-B48Jyxcj.js} +1079 -228
  24. package/dist/server-B48Jyxcj.js.map +1 -0
  25. package/dist/{server-9mFaGLdq.cjs → server-CWZyg8fn.cjs} +1318 -389
  26. package/dist/server-CWZyg8fn.cjs.map +1 -0
  27. package/dist/{discovery-Cw5xWzZD.cjs → store-5_P_NsGa.cjs} +1947 -1947
  28. package/dist/store-5_P_NsGa.cjs.map +1 -0
  29. package/dist/{discovery-CdkaURVY.js → store-_61sO8K8.js} +1948 -1948
  30. package/dist/store-_61sO8K8.js.map +1 -0
  31. package/dist/whisper/index.cjs +1 -1
  32. package/dist/whisper/index.js +1 -1
  33. package/package.json +1 -1
  34. package/dist/discovery-CdkaURVY.js.map +0 -1
  35. package/dist/discovery-Cw5xWzZD.cjs.map +0 -1
  36. package/dist/server-9mFaGLdq.cjs.map +0 -1
  37. package/dist/server-DdHaX_Uu.js.map +0 -1
@@ -4,7 +4,7 @@ const require_root = require("./root-rPH6FGDT.cjs");
4
4
  const require_relPath = require("./relPath-CTAjGdCL.cjs");
5
5
  const require_calendarGrid = require("./calendarGrid-DGILaVxI.cjs");
6
6
  const require_promptSafety = require("./promptSafety-NNGViiCr.cjs");
7
- const require_discovery = require("./discovery-Cw5xWzZD.cjs");
7
+ const require_store = require("./store-5_P_NsGa.cjs");
8
8
  const require_feeds_paths = require("./feeds/paths.cjs");
9
9
  const require_skill_bridge_index = require("./skill-bridge/index.cjs");
10
10
  let node_path = require("node:path");
@@ -12,6 +12,1045 @@ node_path = require_rolldown_runtime.__toESM(node_path, 1);
12
12
  let node_crypto = require("node:crypto");
13
13
  let node_fs_promises = require("node:fs/promises");
14
14
  let zod = require("zod");
15
+ let node_child_process = require("node:child_process");
16
+ let node_util = require("node:util");
17
+ //#region src/collection/server/publishManifest.ts
18
+ /** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
19
+ * that `sharedCollectionKey` applies. Stated once so a path built later
20
+ * cannot be a way around it. */
21
+ var NameZ = zod.z.string().refine(require_calendarGrid.isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
22
+ /** An address on the roster. Not validated as an email beyond "has an @":
23
+ * the rules compare it to `request.auth.token.email` verbatim, so any
24
+ * narrowing here would refuse addresses Firebase itself accepts. */
25
+ var EmailZ = zod.z.string().trim().min(3).includes("@");
26
+ /** The four roles the deployed rules understand. `participant` is the layer
27
+ * that is NAMED but reads only its own rows — see `readerOf` vs `listedIn`. */
28
+ var APP_ROLES = [
29
+ "owner",
30
+ "editor",
31
+ "viewer",
32
+ "participant"
33
+ ];
34
+ var RoleZ = zod.z.enum(APP_ROLES);
35
+ /** `{ email: { "*" | cid: role } }`. The `"*"` key is the app-wide role; a
36
+ * member may hold per-collection roles only (the stylist who is editor of
37
+ * bookings and viewer of everything else). */
38
+ var MembersZ = zod.z.record(EmailZ, zod.z.record(zod.z.union([zod.z.literal("*"), NameZ]), RoleZ));
39
+ /** The declarative mail queue, as the rules re-derive it: a transition of the
40
+ * status field, a recipient read off the RECORD, and a fixed template. */
41
+ var MailZ = zod.z.object({
42
+ toField: zod.z.string().trim().min(1),
43
+ on: zod.z.record(zod.z.string().trim().min(1), zod.z.object({
44
+ from: zod.z.array(zod.z.string().trim().min(1)).min(1),
45
+ to: zod.z.string().trim().min(1)
46
+ }).strict()),
47
+ dataFields: zod.z.array(zod.z.string().trim().min(1)).optional()
48
+ }).strict();
49
+ /** What the rules read out of `collections[cid]`. NOT the schema — the schema
50
+ * is published beside it, untouched, for clients to render from. */
51
+ var CollectionConfigZ = zod.z.object({
52
+ statusField: zod.z.string().trim().min(1).optional(),
53
+ /** `{ initial: [...], <status>: [<status>...] }`. Binds writers too, and
54
+ * binds `create` — that is the point of publishing it. */
55
+ transitions: zod.z.record(zod.z.string().trim().min(1), zod.z.array(zod.z.string().trim().min(1))).optional(),
56
+ immutable: zod.z.boolean().optional(),
57
+ submitOnly: zod.z.boolean().optional(),
58
+ peerVisibility: zod.z.enum(["public", "hidden"]).optional(),
59
+ revealGated: zod.z.boolean().optional(),
60
+ gatedFrom: NameZ.optional(),
61
+ revealBy: zod.z.string().trim().min(1).optional(),
62
+ mail: MailZ.optional(),
63
+ /** Which fields an aggregate groups by. Declared here rather than in the
64
+ * schema for the same reason as everything else in this file — the schema
65
+ * has no `aggregate` key yet — and it is here at all because the
66
+ * invariant that guards it ("every aggregation key is a CHECKED field")
67
+ * is about `public.submit`, which is an app-level declaration. Published
68
+ * as-is; the rules never read it. */
69
+ aggregate: zod.z.object({ by: zod.z.array(zod.z.string().trim().min(1)).min(1) }).strict().optional()
70
+ }).strict();
71
+ /** An authored submit window. ISO strings, because `app.json` is JSON and a
72
+ * Firestore `Timestamp` has no JSON form. Publish lowers it to epoch millis —
73
+ * the rules do not coerce strings, so an ISO string reaching Firestore is a
74
+ * type error that fails CLOSED (`inWindow` refuses every submission and the
75
+ * author sees "nobody can submit", not an error). */
76
+ var WindowZ = zod.z.object({
77
+ from: zod.z.iso.datetime().optional(),
78
+ until: zod.z.iso.datetime().optional()
79
+ }).strict();
80
+ var ValidateZ = zod.z.object({
81
+ required: zod.z.array(zod.z.string().trim().min(1)).optional(),
82
+ /** Capped at two by the rules themselves: rules have no iteration, so
83
+ * `keyFieldsOk` is unrolled. A third would be accepted here and silently
84
+ * unchecked there. */
85
+ keyFields: zod.z.array(zod.z.object({
86
+ field: zod.z.string().trim().min(1),
87
+ values: zod.z.array(zod.z.union([
88
+ zod.z.string(),
89
+ zod.z.number(),
90
+ zod.z.boolean()
91
+ ])).min(1)
92
+ }).strict()).optional()
93
+ }).strict();
94
+ var SubmitZ = zod.z.object({
95
+ auth: zod.z.enum([
96
+ "none",
97
+ "anonymous",
98
+ "verifiedEmail"
99
+ ]),
100
+ emailField: zod.z.string().trim().min(1).optional(),
101
+ createFields: zod.z.array(zod.z.string().trim().min(1)).min(1),
102
+ initialStatus: zod.z.string().trim().min(1).optional(),
103
+ idFrom: zod.z.enum([
104
+ "auto",
105
+ "auth.uid",
106
+ "auth.uid+field"
107
+ ]).optional(),
108
+ idField: zod.z.string().trim().min(1).optional(),
109
+ validate: ValidateZ.optional(),
110
+ window: WindowZ.optional(),
111
+ /** Per CURRENT STATUS, never a flat list: a flat list lets a customer move
112
+ * an approved booking's `startAt` without anyone re-approving it. */
113
+ selfUpdate: zod.z.record(zod.z.string().trim().min(1), zod.z.array(zod.z.string().trim().min(1))).optional(),
114
+ selfTransitions: zod.z.record(zod.z.string().trim().min(1), zod.z.array(zod.z.string().trim().min(1))).optional(),
115
+ finalize: zod.z.boolean().optional(),
116
+ audience: zod.z.literal("participant").optional(),
117
+ gateOn: zod.z.object({
118
+ phase: zod.z.string().trim().min(1),
119
+ match: zod.z.string().trim().min(1)
120
+ }).strict().optional()
121
+ }).strict();
122
+ var PublicZ = zod.z.object({
123
+ /** The master switch. Anonymous submission (`auth: "none"`) needs it as
124
+ * well as its own declaration. */
125
+ enabled: zod.z.boolean().optional(),
126
+ read: zod.z.array(NameZ).optional(),
127
+ submit: zod.z.record(NameZ, SubmitZ).optional()
128
+ }).strict();
129
+ /** The whole authored declaration.
130
+ *
131
+ * `owner` is accepted but is NOT the published value — publish stamps the
132
+ * publisher's uid (or carries the existing one forward, which is what the
133
+ * rules require on update) and refuses a declaration that disagrees. It is
134
+ * accepted rather than banned because the sample app.json in the design note
135
+ * shows it, and a hard refusal on a key the samples contain would be a worse
136
+ * first experience than a message naming the mismatch. */
137
+ var AuthoredAppZ = zod.z.object({
138
+ aid: NameZ,
139
+ name: zod.z.string().trim().min(1).optional(),
140
+ /** Per-worktree app id (design D6, implementation order 7). Accepted so a
141
+ * repository already carrying it parses; nothing reads it yet. */
142
+ aidEnv: zod.z.string().trim().min(1).optional(),
143
+ owner: zod.z.string().trim().min(1).optional(),
144
+ members: MembersZ,
145
+ collections: zod.z.record(NameZ, CollectionConfigZ).optional(),
146
+ participantRead: zod.z.array(NameZ).optional(),
147
+ public: PublicZ.optional()
148
+ }).strict();
149
+ /** Parse the authored declaration out of `app.json`'s text.
150
+ *
151
+ * Returns a LIST of problems rather than throwing, for the same reason
152
+ * `loadAppManifest` returns a failure: the caller is a gate whose entire job
153
+ * is to hand the author something to act on. Every problem is reported at
154
+ * once — publish is a manual step, and a parser that stops at the first key
155
+ * makes it N round trips. */
156
+ function parseAuthoredApp(raw) {
157
+ const manifest = require_store.parseAppManifest(raw);
158
+ if (!manifest.ok) return {
159
+ ok: false,
160
+ problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
161
+ };
162
+ const parsed = AuthoredAppZ.safeParse(JSON.parse(raw));
163
+ if (!parsed.success) return {
164
+ ok: false,
165
+ problems: authoredProblems(parsed.error)
166
+ };
167
+ return {
168
+ ok: true,
169
+ app: parsed.data
170
+ };
171
+ }
172
+ /** zod issues as one actionable line each: `public.submit.responses.auth: …`. */
173
+ function authoredProblems(error) {
174
+ return error.issues.map((issue) => {
175
+ return `${issue.path.length > 0 ? issue.path.join(".") : "app.json"}: ${issue.message}`;
176
+ });
177
+ }
178
+ //#endregion
179
+ //#region src/collection/server/publishProject.ts
180
+ /** The document id under `apps/{aid}/config`. One document, named, rather than
181
+ * a spread of them: a second public document is a second thing to keep in
182
+ * step, and nothing yet needs one. */
183
+ var PUBLIC_CONFIG_DOC = "public";
184
+ /** Drop keys whose value is `undefined`. Firestore rejects an undefined field
185
+ * value outright, and `"k" in c` — which every optional key in the rules is
186
+ * read through — must mean "the author declared it". */
187
+ function compact(entries) {
188
+ return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== void 0));
189
+ }
190
+ /** ISO → epoch millis, the one conversion the rules cannot do for themselves.
191
+ * The caller has already refused an unparseable string (the authored parser
192
+ * requires `z.iso.datetime()`), so a NaN here would be a programming error;
193
+ * it is still checked, because a NaN written to Firestore fails closed in the
194
+ * same silent way an ISO string does. */
195
+ function windowMillis(window) {
196
+ if (!window) return void 0;
197
+ const out = {};
198
+ if (window.from !== void 0) out.fromMs = Date.parse(window.from);
199
+ if (window.until !== void 0) out.untilMs = Date.parse(window.until);
200
+ if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
201
+ return Object.keys(out).length > 0 ? out : void 0;
202
+ }
203
+ /** One `public.submit[cid]`, with its window lowered. Everything else passes
204
+ * through: the rules read these keys by the names the author wrote. */
205
+ function projectSubmit(submit) {
206
+ const { window, ...rest } = submit;
207
+ return compact({
208
+ ...rest,
209
+ window: windowMillis(window)
210
+ });
211
+ }
212
+ /** The roster's addresses as a set, in a stable order.
213
+ *
214
+ * Sorted so two publishes of the same declaration produce the same document
215
+ * — idempotence is a property this step is tested for, and Firestore compares
216
+ * arrays by ORDER. `membersConsistent()` compares as sets and would accept
217
+ * any order; the test that would notice is the one asserting a second publish
218
+ * changes nothing but `publishedAt`. */
219
+ function memberEmailsOf(members) {
220
+ return Object.keys(members).sort();
221
+ }
222
+ /** The previous document, kept for rollback, with its OWN `previousPublished`
223
+ * stripped.
224
+ *
225
+ * One level, deliberately. Chaining would make every publish carry the entire
226
+ * history of the app inside a single document, which grows without bound and
227
+ * meets Firestore's 1 MiB document limit as a permission-shaped failure at
228
+ * some unpredictable publish. One level answers the question rollback
229
+ * actually asks — "put back what was there before I broke it" — and the
230
+ * further history is in git, which is where the declaration came from. */
231
+ function previousOf(existing) {
232
+ if (!existing) return void 0;
233
+ const { previousPublished: __dropped, ...rest } = existing;
234
+ return rest;
235
+ }
236
+ /** Project the authored declaration into the documents publish writes.
237
+ *
238
+ * `existing` is the app document as it is in Firestore right now, or null on
239
+ * a first publish. Two things need it, both required by the rules:
240
+ * - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
241
+ * would be refused for any app whose owner ever signed in as a different
242
+ * account, and would silently transfer ownership if it were not.
243
+ * - `previousPublished` is that document, so a rollback has something to
244
+ * put back.
245
+ *
246
+ * Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
247
+ * a parameter, which is what makes the conversion table testable as a table. */
248
+ function projectApp(authored, schemas, stamp, existing) {
249
+ const owner = typeof existing?.owner === "string" ? existing.owner : stamp.uid;
250
+ const submit = Object.fromEntries(Object.entries(authored.public?.submit ?? {}).map(([cid, spec]) => [cid, projectSubmit(spec)]));
251
+ const publicBlock = authored.public ? compact({
252
+ enabled: authored.public.enabled,
253
+ read: authored.public.read,
254
+ submit: Object.keys(submit).length > 0 ? submit : void 0
255
+ }) : void 0;
256
+ const app = compact({
257
+ aid: authored.aid,
258
+ name: authored.name,
259
+ owner,
260
+ members: authored.members,
261
+ memberEmails: memberEmailsOf(authored.members),
262
+ collections: authored.collections,
263
+ participantRead: authored.participantRead,
264
+ public: publicBlock,
265
+ publishedAt: stamp.publishedAt,
266
+ publishedBy: stamp.email,
267
+ publishedCommit: stamp.commit,
268
+ previousPublished: previousOf(existing)
269
+ });
270
+ const config = {
271
+ enabled: authored.public?.enabled === true,
272
+ read: authored.public?.read ?? [],
273
+ submit,
274
+ publishedAt: stamp.publishedAt
275
+ };
276
+ if (authored.name !== void 0) config.name = authored.name;
277
+ return {
278
+ app,
279
+ schemas: schemas.map(({ cid, schema }) => ({
280
+ cid,
281
+ doc: schemaDoc(schema, stamp)
282
+ })),
283
+ config
284
+ };
285
+ }
286
+ /** One published schema document. Written key by key rather than through
287
+ * `compact`, so the declared type is the type — an optional commit is the
288
+ * only variable part. */
289
+ function schemaDoc(schema, stamp) {
290
+ const doc = {
291
+ publishedSchema: schema,
292
+ publishedAt: stamp.publishedAt,
293
+ publishedBy: stamp.email
294
+ };
295
+ if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
296
+ return doc;
297
+ }
298
+ /** The app documents' parent path — the `FirestoreDocs` seam takes a
299
+ * collection path plus a document id, and the app document's id is the aid. */
300
+ var APPS_COLLECTION = "apps";
301
+ /** The collection (schema) documents' parent path. */
302
+ var appSchemasPath = (aid) => `apps/${aid}/collections`;
303
+ /** The public-config documents' parent path. */
304
+ var appConfigPath = (aid) => `apps/${aid}/config`;
305
+ //#endregion
306
+ //#region src/collection/server/publishChecks.ts
307
+ /** Does this submit declaration bind a record to the submitter's identity?
308
+ *
309
+ * The condition for requiring `submitOnly`, and deliberately NOT "declares an
310
+ * `audience`": `audience` appears only in the rules' public-create branch, so
311
+ * an owner or editor never meets it and can add records freely. `immutable`
312
+ * is the wrong condition too — a survey's responses are not immutable and
313
+ * can be padded exactly the same way.
314
+ *
315
+ * What these four have in common is that each one makes the record MEAN "the
316
+ * person who submitted it said this": a per-uid id, a per-uid+field id, a
317
+ * row stamped with the submitter's verified address, or a submission
318
+ * restricted to a named participant. A record created through the writer
319
+ * branch carries the same shape and none of that meaning. */
320
+ function bindsSubmitterIdentity(submit) {
321
+ return submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field" || submit.emailField !== void 0 || submit.audience === "participant";
322
+ }
323
+ /** The fields a rule actually CHECKS the value of, for one collection.
324
+ *
325
+ * `keyFields` pins a value against a declared set, `gateOn.match` pins it
326
+ * against the session's current question, and the status field is pinned by
327
+ * the transition machine. An aggregation grouped by anything else is grouped
328
+ * by a field any submitter may write anything into — so the published
329
+ * aggregate is whatever the noisiest respondent decided it should be. */
330
+ function checkedFields(collection, submit) {
331
+ const fields = /* @__PURE__ */ new Set();
332
+ for (const keyField of submit?.validate?.keyFields ?? []) fields.add(keyField.field);
333
+ if (submit?.gateOn) fields.add(submit.gateOn.match);
334
+ if (collection?.statusField) fields.add(collection.statusField);
335
+ return fields;
336
+ }
337
+ /** INVARIANT 1 — a submission bound to its submitter needs `submitOnly`. */
338
+ function submitOnlyProblems(app) {
339
+ const problems = [];
340
+ for (const [cid, submit] of Object.entries(app.public?.submit ?? {})) {
341
+ if (!bindsSubmitterIdentity(submit)) continue;
342
+ if (app.collections?.[cid]?.submitOnly === true) continue;
343
+ 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}.`);
344
+ }
345
+ return problems;
346
+ }
347
+ function identityBindings(submit) {
348
+ const bindings = [];
349
+ if (submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field") bindings.push(`idFrom: "${submit.idFrom}"`);
350
+ if (submit.emailField !== void 0) bindings.push(`emailField: "${submit.emailField}"`);
351
+ if (submit.audience === "participant") bindings.push(`audience: "participant"`);
352
+ return bindings;
353
+ }
354
+ /** INVARIANT 2 — every aggregation key is a field some rule checks. */
355
+ function aggregateProblems(app) {
356
+ const problems = [];
357
+ for (const [cid, collection] of Object.entries(app.collections ?? {})) {
358
+ const keys = collection.aggregate?.by;
359
+ if (!keys) continue;
360
+ const checked = checkedFields(collection, app.public?.submit?.[cid]);
361
+ const loose = keys.filter((field) => !checked.has(field));
362
+ const spelled = loose.map((field) => `'${field}'`).join(", ");
363
+ 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.`);
364
+ }
365
+ return problems;
366
+ }
367
+ /** INVARIANT 3 — `auth: "verifiedEmail"` only.
368
+ *
369
+ * A product decision, not a rules limitation: the rules keep all three stages
370
+ * and the emulator tests keep exercising them, because deleting a stage from
371
+ * the rules turns a change of mind into a cross-repo deploy. Publish is where
372
+ * the current decision is expressed, and it is one line to move. */
373
+ function authProblems(app) {
374
+ 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.`);
375
+ }
376
+ /** INVARIANT 5 — a mail transition's origins and destination must be disjoint.
377
+ *
378
+ * Overlap means the same write can satisfy the same template twice over, and
379
+ * the deterministic mail id is the only other thing stopping a duplicate
380
+ * send. The rules also require the status to have CHANGED, so an overlapping
381
+ * declaration is not merely redundant: `from` containing `to` is a transition
382
+ * that can never fire, which is a mail nobody ever receives. */
383
+ function mailProblems(app) {
384
+ return Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => collectionMailProblems(cid, collection));
385
+ }
386
+ function collectionMailProblems(cid, collection) {
387
+ const { mail } = collection;
388
+ if (!mail) return [];
389
+ const problems = [];
390
+ 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.`);
391
+ for (const [template, transition] of Object.entries(mail.on)) problems.push(...templateMailProblems(cid, collection, template, transition));
392
+ return problems;
393
+ }
394
+ function templateMailProblems(cid, collection, template, transition) {
395
+ const problems = [];
396
+ 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.`);
397
+ const allowed = collection.transitions;
398
+ if (allowed) {
399
+ const unreachable = transition.from.filter((from) => !(allowed[from] ?? []).includes(transition.to));
400
+ const spelled = unreachable.map((from) => `'${from}' -> '${transition.to}'`).join(", ");
401
+ 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.`);
402
+ }
403
+ return problems;
404
+ }
405
+ /** INVARIANTS 6 and 7 — the window is a real interval, and `keyFields` fits
406
+ * the unrolled check in the rules. */
407
+ function submitShapeProblems(app) {
408
+ return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => [...windowProblems(cid, submit), ...keyFieldCountProblems(cid, submit)]);
409
+ }
410
+ function windowProblems(cid, submit) {
411
+ const { window } = submit;
412
+ if (window?.from === void 0 || window.until === void 0) return [];
413
+ if (Date.parse(window.until) > Date.parse(window.from)) return [];
414
+ return [`public.submit.${cid}.window closes at or before it opens (${window.from} -> ${window.until}): nothing could ever be submitted.`];
415
+ }
416
+ function keyFieldCountProblems(cid, submit) {
417
+ const keyFields = submit.validate?.keyFields ?? [];
418
+ if (keyFields.length <= 2) return [];
419
+ 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.`];
420
+ }
421
+ /** The fail-closed traps: declarations the rules read together, where the
422
+ * missing half denies every write instead of loosening one. */
423
+ function coherenceProblems(app) {
424
+ const fromSubmits = Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitCoherenceProblems(app, cid, submit));
425
+ const fromCollections = Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => gateCoherenceProblems(cid, collection));
426
+ return [...fromSubmits, ...fromCollections];
427
+ }
428
+ /** `initialStatus` is read together with the collection's `statusField` and
429
+ * with `createFields`; miss either and every submission is refused. */
430
+ function statusCoherenceProblems(cid, submit, collection) {
431
+ if (submit.initialStatus === void 0) return [];
432
+ 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.`];
433
+ if (new Set(submit.createFields).has(collection.statusField)) return [];
434
+ 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.`];
435
+ }
436
+ /** Every field a RULE reads off a submitted record, other than the status
437
+ * field (which `statusCoherenceProblems` words for itself).
438
+ *
439
+ * `emailField` and `idField` belong here for exactly the reason `required`
440
+ * and `keyFields` do, and forgetting them was the same oversight twice: the
441
+ * rules read `request.resource.data[s.emailField]` and rebuild the document
442
+ * id from `s.idField`, while `hasOnly(createFields)` decides what a
443
+ * submission may carry at all. A field in one list and not the other is a
444
+ * contradiction the submitter cannot resolve — including it is refused,
445
+ * omitting it fails the check. */
446
+ function ruleReadFields(submit) {
447
+ const fields = [];
448
+ if (submit.emailField !== void 0) fields.push({
449
+ field: submit.emailField,
450
+ why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
451
+ });
452
+ if (submit.idFrom === "auth.uid+field" && submit.idField !== void 0) fields.push({
453
+ field: submit.idField,
454
+ why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
455
+ });
456
+ return fields;
457
+ }
458
+ /** A checked field a submission is not allowed to carry can never be
459
+ * satisfied: carrying it fails `hasOnly`, omitting it fails the check. */
460
+ function createFieldProblems(cid, submit) {
461
+ const createFields = new Set(submit.createFields);
462
+ 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.`);
463
+ 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.`);
464
+ 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.`);
465
+ return [
466
+ ...ruleRead,
467
+ ...required,
468
+ ...keyFields
469
+ ];
470
+ }
471
+ function submitCoherenceProblems(app, cid, submit) {
472
+ const collection = app.collections?.[cid];
473
+ const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
474
+ 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.`);
475
+ 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.`);
476
+ 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.`);
477
+ return problems;
478
+ }
479
+ /** The staged reveal reads its flag off the PARENT record, so the path to that
480
+ * parent is not optional decoration — without it the gate never opens. */
481
+ function gateCoherenceProblems(cid, collection) {
482
+ if (collection.revealGated !== true) return [];
483
+ if (collection.gatedFrom !== void 0 && collection.revealBy !== void 0) return [];
484
+ 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.`];
485
+ }
486
+ /** The publisher must be able to write what they are about to write.
487
+ *
488
+ * On a first publish the rules require the creator to name themselves owner,
489
+ * in the roster, under `'*'`. Getting this wrong produces a bare permission
490
+ * error from Firestore with nothing in it about rosters — worth one line
491
+ * here instead. */
492
+ function publisherProblems(app, publisherEmail) {
493
+ if (app.members[publisherEmail]?.["*"] === "owner") return [];
494
+ 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.`];
495
+ }
496
+ /** Every cid the declaration mentions must be a collection that exists.
497
+ *
498
+ * A typo'd cid is not an error anywhere else: the app document simply carries
499
+ * a configuration for a collection nobody publishes, and the collection the
500
+ * author meant is published with no configuration at all — i.e. with the
501
+ * status machine and the submit path silently absent. */
502
+ function unknownCidProblems(app, collections) {
503
+ const known = new Set(collections.map((collection) => collection.cid));
504
+ return [
505
+ ["collections", Object.keys(app.collections ?? {})],
506
+ ["public.read", app.public?.read ?? []],
507
+ ["public.submit", Object.keys(app.public?.submit ?? {})],
508
+ ["participantRead", app.participantRead ?? []]
509
+ ].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\")"}.`));
510
+ }
511
+ /** Everything publish refuses, as lines the author can act on.
512
+ *
513
+ * All of them, every time. Publish is a manual step with a human waiting on
514
+ * it; stopping at the first problem turns one review into five. */
515
+ function publishProblems(app, collections, publisherEmail) {
516
+ return [
517
+ ...unknownCidProblems(app, collections),
518
+ ...publisherProblems(app, publisherEmail),
519
+ ...submitOnlyProblems(app),
520
+ ...aggregateProblems(app),
521
+ ...authProblems(app),
522
+ ...mailProblems(app),
523
+ ...submitShapeProblems(app),
524
+ ...coherenceProblems(app),
525
+ ...primaryKeyProblems(app, collections)
526
+ ];
527
+ }
528
+ /** A public submission must be able to produce a record the HOST can read.
529
+ *
530
+ * The rules and the engine disagree about what identifies a record, and the
531
+ * gap is invisible from either side alone. The rules bind the DOCUMENT ID
532
+ * (`idFrom`) and let a submission carry only `createFields`; the engine
533
+ * identifies a record by its schema's `primaryKey` FIELD, and the firestore
534
+ * store hands back the document's fields verbatim — `toItem` does not
535
+ * synthesize the key from the document id. So a submit path whose
536
+ * `createFields` omits the primary key writes rows that Firestore accepts and
537
+ * every reader rejects: `validateRecordObject` fails them, the collection
538
+ * renders empty-ish, and the next publish's own pre-check reports them as
539
+ * broken records the publisher never wrote.
540
+ *
541
+ * Not checkable by the rules (they have never heard of a schema) and not
542
+ * catchable at write time (nothing is wrong with the write). Publish is the
543
+ * only place that holds both halves. */
544
+ function primaryKeyProblems(app, collections) {
545
+ const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
546
+ return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
547
+ const primaryKey = primaryKeyOf.get(cid);
548
+ if (primaryKey === void 0 || submit.createFields.includes(primaryKey)) return [];
549
+ return [`public.submit.${cid}.createFields must include "${primaryKey}", the schema's primaryKey: 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.`];
550
+ });
551
+ }
552
+ //#endregion
553
+ //#region src/collection/core/recordZ.ts
554
+ /** The emptiness rule shared by `required` and the "only check present
555
+ * values" gate. NOT a truthiness check — `0` and `false` are filled. */
556
+ var isEmptyValue = (value) => value === void 0 || value === null || value === "";
557
+ /** The historical write-gate checks, verbatim: required non-empty, enum
558
+ * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
559
+ function enforcedProblem(key, spec, value) {
560
+ const empty = isEmptyValue(value);
561
+ if (spec.required && empty) return `missing required field '${key}'`;
562
+ if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
563
+ return null;
564
+ }
565
+ /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
566
+ * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
567
+ * the lint flags exactly the values the calendar / trigger / spawn code
568
+ * would silently drop — impossible days like `2026-02-30`, and datetimes
569
+ * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
570
+ * which the day view can't place). `string`-backed types accept anything
571
+ * stringifiable; `ref` existence is out of scope. */
572
+ function strictTypeProblem(key, spec, value) {
573
+ switch (spec.type) {
574
+ case "number":
575
+ case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
576
+ case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
577
+ case "date": return require_calendarGrid.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
578
+ case "datetime": return require_calendarGrid.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
579
+ default: return null;
580
+ }
581
+ }
582
+ /** Strict check for a PRESENT `table` value: an array of row objects, each
583
+ * row conforming to the sub-schema (required / enum / typed sub-values).
584
+ * First row problem wins, prefixed with the row number so the fix is
585
+ * locatable. */
586
+ function strictTableProblem(key, spec, value) {
587
+ if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
588
+ for (let index = 0; index < value.length; index++) {
589
+ const row = value[index];
590
+ if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
591
+ for (const [subKey, subSpec] of Object.entries(spec.of)) {
592
+ const subValue = row[subKey];
593
+ const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
594
+ if (problem) return `'${key}' row ${index + 1}: ${problem}`;
595
+ }
596
+ }
597
+ return null;
598
+ }
599
+ /** First problem for one field's stored value under `tier`, or null.
600
+ * Enforced checks always run (and their messages never vary by tier — the
601
+ * scan and the write gate must agree on them); strict adds the per-type
602
+ * layer on present values only. */
603
+ function recordFieldProblem(key, spec, value, tier) {
604
+ const enforced = enforcedProblem(key, spec, value);
605
+ if (enforced || tier === "enforced") return enforced;
606
+ if (isEmptyValue(value)) return null;
607
+ if (spec.type === "table") return strictTableProblem(key, spec, value);
608
+ return strictTypeProblem(key, spec, value);
609
+ }
610
+ var compiled = /* @__PURE__ */ new WeakMap();
611
+ /** Compile `schema.fields` into a zod validator for a stored record.
612
+ * Loose object: unknown keys are allowed and any declared key may be
613
+ * absent (records are user files, not parse-and-rewrite targets —
614
+ * callers validate, they never persist the parse output). The checks run
615
+ * as ONE object-level refine iterating fields in declaration order —
616
+ * per-key shape schemas can't express "key may be absent BUT its absence
617
+ * must still reach the required check", and the single loop keeps the
618
+ * first reported issue identical to the historical first-problem-wins
619
+ * contract. */
620
+ function compileRecordZ(schema, tier) {
621
+ const cached = compiled.get(schema)?.[tier];
622
+ if (cached) return cached;
623
+ const stored = Object.entries(schema.fields).filter(([, spec]) => !require_calendarGrid.COMPUTED_TYPES.has(spec.type));
624
+ const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
625
+ for (const [key, spec] of stored) {
626
+ const problem = recordFieldProblem(key, spec, record[key], tier);
627
+ if (problem) ctx.addIssue({
628
+ code: "custom",
629
+ message: problem,
630
+ path: [key]
631
+ });
632
+ }
633
+ });
634
+ const entry = compiled.get(schema) ?? {};
635
+ entry[tier] = validator;
636
+ compiled.set(schema, entry);
637
+ return validator;
638
+ }
639
+ /** First schema problem on an in-memory record under `tier`, or null. One
640
+ * issue per record keeps the report short and the fix obvious (the
641
+ * historical contract of `validateRecordObject`). */
642
+ function firstRecordProblem(record, schema, tier) {
643
+ const result = compileRecordZ(schema, tier).safeParse(record);
644
+ if (result.success) return null;
645
+ return result.error.issues[0]?.message ?? "record failed schema validation";
646
+ }
647
+ //#endregion
648
+ //#region src/collection/server/validate.ts
649
+ /** Don't flood the result; the first batch is enough to act on. Exported
650
+ * because a caller that REPORTS a count has to know the count is a floor —
651
+ * `publish` presents a full batch as "at least N" rather than as a total. */
652
+ var MAX_RECORD_ISSUES = 25;
653
+ /** The `file` of the pseudo-issue reported when the backend could not be read
654
+ * at all.
655
+ *
656
+ * Exported because it is a DIFFERENT KIND of answer from "this record is
657
+ * invalid", and a caller that treats the two alike gets it wrong in the
658
+ * direction that matters: `publish` lets the user override invalid records,
659
+ * and overriding this one would mean publishing without ever having looked. */
660
+ var STORE_UNREADABLE = "(store)";
661
+ var MAX_ISSUES = 25;
662
+ /** Read every `<id>.json` under the collection's dataDir and report the
663
+ * ones that won't load or violate the schema. An empty list means every
664
+ * record is fine. */
665
+ /** List entries under the data dir, guarding realpath containment (against a
666
+ * symlinked dir swapped in after discovery, like `listItems`) and treating a
667
+ * missing dir as empty while surfacing real I/O faults. */
668
+ async function listRecordFilenames(dataDir, workspaceRoot) {
669
+ if (!require_store.isContainedInRoot(dataDir, workspaceRoot)) {
670
+ require_store.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
671
+ return [];
672
+ }
673
+ try {
674
+ return await (0, node_fs_promises.readdir)(dataDir);
675
+ } catch (err) {
676
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
677
+ throw err;
678
+ }
679
+ }
680
+ async function validateCollectionRecords(collection, opts = {}) {
681
+ if (collection.schema.dataSource !== void 0) return [];
682
+ if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
683
+ const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
684
+ const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
685
+ const issues = [];
686
+ for (const name of entries.sort()) {
687
+ if (!name.endsWith(".json") || name.startsWith(".")) continue;
688
+ if (issues.length >= MAX_ISSUES) break;
689
+ const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
690
+ if (issue) issues.push(issue);
691
+ }
692
+ return issues;
693
+ }
694
+ /** Store-backed twin of the file scan: list every record through the
695
+ * collection's store and lint it with the same "strict" report-only tier.
696
+ * A row the store can't even parse is invisible here (the store skips
697
+ * it), so the read/parse classifications of the file scan don't apply —
698
+ * schema violations are what this catches. `file` carries the record id
699
+ * (there is no per-record filename). */
700
+ async function validateStoreRecords(collection, opts) {
701
+ let items;
702
+ try {
703
+ items = await require_store.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
704
+ } catch (err) {
705
+ return [{
706
+ file: STORE_UNREADABLE,
707
+ problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
708
+ }];
709
+ }
710
+ const issues = [];
711
+ for (const item of items) {
712
+ if (issues.length >= MAX_ISSUES) break;
713
+ const itemId = require_calendarGrid.fieldText(item[collection.schema.primaryKey]);
714
+ const problem = validateRecordObject(item, itemId, collection.schema, "strict");
715
+ if (problem) issues.push({
716
+ file: itemId,
717
+ problem
718
+ });
719
+ }
720
+ return issues;
721
+ }
722
+ async function readRecordText(fullPath, name) {
723
+ try {
724
+ if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
725
+ file: name,
726
+ problem: "not a regular file (symlink?) — skipped, won't appear"
727
+ };
728
+ return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
729
+ } catch {
730
+ return {
731
+ file: name,
732
+ problem: "could not be read — skipped, won't appear"
733
+ };
734
+ }
735
+ }
736
+ /** Classify a single record file: unreadable / unparseable / non-object /
737
+ * schema violation, or null when it's fine. */
738
+ async function inspectRecord(fullPath, name, schema) {
739
+ const read = await readRecordText(fullPath, name);
740
+ if ("problem" in read) return read;
741
+ let parsed;
742
+ try {
743
+ parsed = JSON.parse(read.raw);
744
+ } catch (err) {
745
+ return {
746
+ file: name,
747
+ problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
748
+ };
749
+ }
750
+ if (!require_dist.isRecord(parsed)) return {
751
+ file: name,
752
+ problem: "not a JSON object — skipped, won't appear"
753
+ };
754
+ const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
755
+ return problem ? {
756
+ file: name,
757
+ problem
758
+ } : null;
759
+ }
760
+ /** What a non-string primary key actually is, for the error message. Names the
761
+ * shape rather than stringifying the value — "[object Object]" tells the reader
762
+ * nothing about what is wrong. */
763
+ function describeIdType(value) {
764
+ if (value === null) return "null";
765
+ if (value === void 0) return "missing";
766
+ if (Array.isArray(value)) return "an array";
767
+ return `a ${typeof value}`;
768
+ }
769
+ /** First schema problem on an in-memory record (primaryKey↔id mismatch,
770
+ * then the compiled per-field checks — see `../core/recordZ` for the two
771
+ * tiers), or null when it's fine. One issue per record keeps the report
772
+ * short and the fix obvious. Pure + exported so write paths
773
+ * (manageCollection putItems) can gate on the SAME enforced rules the
774
+ * post-hoc file scan reports — `itemId` is the id the record is (or
775
+ * would be) stored under. The default `"enforced"` tier keeps every
776
+ * write gate on the historical three checks; only pass `"strict"` from
777
+ * report-only surfaces. */
778
+ function validateRecordObject(record, itemId, schema, tier = "enforced") {
779
+ const idValue = record[schema.primaryKey];
780
+ if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
781
+ if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
782
+ return firstRecordProblem(record, schema, tier);
783
+ }
784
+ //#endregion
785
+ //#region src/collection/server/publish.ts
786
+ var execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
787
+ /** How many broken records to name before summarising. A publish that would
788
+ * break a thousand rows is answered by the count and a sample; dumping all of
789
+ * them buries the number, which is the part the decision turns on. */
790
+ var MAX_LISTED_ISSUES = 10;
791
+ /** `git rev-parse HEAD` plus a dirty check, or nothing.
792
+ *
793
+ * A missing git, a repository with no commits and a non-repository are all
794
+ * the same answer here — no commit — because the stamp is attribution, not a
795
+ * requirement. What is NOT acceptable is a stamp that lies, which is why the
796
+ * dirty flag exists: publishing from a modified tree records a commit that
797
+ * does not describe what was published, and the flag is the only thing that
798
+ * would ever tell a reader so. */
799
+ async function gitStamp(root) {
800
+ try {
801
+ const { stdout } = await execFileAsync("git", [
802
+ "-C",
803
+ root,
804
+ "rev-parse",
805
+ "HEAD"
806
+ ]);
807
+ const commit = stdout.trim();
808
+ const { stdout: status } = await execFileAsync("git", [
809
+ "-C",
810
+ root,
811
+ "status",
812
+ "--porcelain"
813
+ ]);
814
+ return {
815
+ commit: commit.length > 0 ? commit : void 0,
816
+ dirty: status.trim().length > 0
817
+ };
818
+ } catch {
819
+ return {};
820
+ }
821
+ }
822
+ /** The shared collections of THIS REPOSITORY, by cid.
823
+ *
824
+ * `userSkillsDir: null` — not a test convenience, a boundary. Discovery
825
+ * resolves every schema it finds against the WORKSPACE root, user-scope
826
+ * included, so a globally installed skill under `~/.claude/skills` carrying
827
+ * `storage.type: "firestore"` picks up whichever repository's `aid` it
828
+ * happens to be discovered from. Left in, publish would write that schema
829
+ * into this app — and into every other app the same user publishes, since the
830
+ * skill is installed once per machine and the repositories are not.
831
+ *
832
+ * An app is a REPOSITORY (design D1): its collections are the ones committed
833
+ * beside its `app.json`, which is what makes a clone resolve the same
834
+ * collections and an invitation a matter of authorization rather than
835
+ * discovery. A schema that is not in the repository has no claim on a cid
836
+ * there. And because a view is HTML, publishing one is not a tidiness
837
+ * question: it is the machine's own skills reaching every member's browser.
838
+ *
839
+ * The consequence is deliberate: a cid named in `app.json` that exists only
840
+ * in user scope is now an unknown cid, and publish says so by name instead of
841
+ * quietly publishing a schema from outside the repository. */
842
+ async function sharedCollections(opts, root) {
843
+ return (await require_store.discoverCollections({
844
+ ...opts,
845
+ workspaceRoot: root,
846
+ userSkillsDir: null
847
+ })).filter((collection) => collection.appId !== void 0);
848
+ }
849
+ /** Existing records that would not satisfy the schema about to be published.
850
+ *
851
+ * Read from FIRESTORE, not from disk: a shared collection's records live in
852
+ * the app, and the question this answers is "what does the live data look
853
+ * like under the new schema" — which is the migration question. Reported as
854
+ * a refusal the publisher can override, because a breaking change is
855
+ * sometimes exactly what is intended and the point is that it is a decision
856
+ * rather than a discovery. */
857
+ async function recordProblems(collections, opts) {
858
+ const lines = [];
859
+ const unreadable = [];
860
+ let records = 0;
861
+ let cappedAnywhere = false;
862
+ for (const collection of collections) {
863
+ const issues = await validateCollectionRecords(collection, opts);
864
+ if (issues.length === 0) continue;
865
+ const unread = issues.filter((issue) => issue.file === STORE_UNREADABLE);
866
+ if (unread.length > 0) {
867
+ unreadable.push(`${collection.slug}: ${unread.map((issue) => issue.problem).join("; ")}`);
868
+ continue;
869
+ }
870
+ records += issues.length;
871
+ const capped = issues.length >= 25;
872
+ cappedAnywhere = cappedAnywhere || capped;
873
+ const count = capped ? `at least ${issues.length}` : String(issues.length);
874
+ const plural = issues.length === 1 ? "" : "s";
875
+ const note = capped ? " (the scan stops there)" : "";
876
+ lines.push(`${collection.slug}: ${count} existing record${plural} would not satisfy the schema about to be published${note}`);
877
+ for (const issue of issues.slice(0, MAX_LISTED_ISSUES)) lines.push(` - ${issue.file}: ${issue.problem}`);
878
+ if (issues.length > MAX_LISTED_ISSUES) lines.push(` - … and ${issues.length - MAX_LISTED_ISSUES} more`);
879
+ }
880
+ return {
881
+ lines,
882
+ records,
883
+ capped: cappedAnywhere,
884
+ unreadable
885
+ };
886
+ }
887
+ function schemasOf(collections) {
888
+ return collections.map((collection) => ({
889
+ cid: collection.slug,
890
+ schema: collection.schema
891
+ })).sort((left, right) => left.cid < right.cid ? -1 : left.cid > right.cid ? 1 : 0);
892
+ }
893
+ /** Read and parse `<root>/app.json`'s full declaration. */
894
+ async function readAuthored(root) {
895
+ let raw;
896
+ try {
897
+ raw = await (0, node_fs_promises.readFile)(node_path.default.join(root, require_store.APP_MANIFEST_FILE), "utf-8");
898
+ } catch (err) {
899
+ return {
900
+ ok: false,
901
+ problems: [`cannot read ${node_path.default.join(root, require_store.APP_MANIFEST_FILE)}: ${String(err)}`]
902
+ };
903
+ }
904
+ return parseAuthoredApp(raw);
905
+ }
906
+ /** Everything wrong with the declaration itself, publisher included. */
907
+ function declarationProblems(app, collections, handle) {
908
+ const problems = publishProblems(app, collections.map((collection) => ({
909
+ cid: collection.slug,
910
+ primaryKey: collection.schema.primaryKey
911
+ })), handle.email);
912
+ if (app.owner !== void 0 && app.owner !== handle.uid) problems.push(`app.json declares owner "${app.owner}", which is not your uid (${handle.uid}). \`owner\` is stamped by publish and carried forward unchanged afterwards — remove it from app.json rather than maintaining it by hand.`);
913
+ return problems;
914
+ }
915
+ /** Put the three kinds of document, in the order the rules require, and turn a
916
+ * rejected write into the result type instead of letting it escape.
917
+ *
918
+ * Returns null when everything was written; a failure result otherwise.
919
+ *
920
+ * A raw rejection here would reach the agent as a tool crash rather than the
921
+ * actionable text this tool promises. But "actionable" is a strong claim for
922
+ * a half-finished publish, so the message ENUMERATES what landed rather than
923
+ * summarising it: the order is app → every schema → config, and a summary
924
+ * written for one failure point is wrong at the others. Saying "the roster
925
+ * and configuration are live" after a SCHEMA write failed names a config
926
+ * document this publish never wrote — which still holds whatever the last
927
+ * publish left, and is exactly the state the caller is trying to repair. */
928
+ async function writeDocuments(handle, aid, published) {
929
+ const steps = [
930
+ {
931
+ what: `the app document (apps/${aid})`,
932
+ run: () => handle.docs.set(APPS_COLLECTION, aid, published.app)
933
+ },
934
+ ...published.schemas.map(({ cid, doc }) => ({
935
+ what: `the published schema for '${cid}'`,
936
+ run: () => handle.docs.set(appSchemasPath(aid), cid, doc)
937
+ })),
938
+ {
939
+ what: `the public config document (apps/${aid}/config/${PUBLIC_CONFIG_DOC})`,
940
+ run: () => handle.docs.set(appConfigPath(aid), PUBLIC_CONFIG_DOC, published.config)
941
+ }
942
+ ];
943
+ const landed = [];
944
+ for (const [index, step] of steps.entries()) try {
945
+ await step.run();
946
+ landed.push(step.what);
947
+ } catch (err) {
948
+ const reason = err instanceof Error ? err.message : String(err);
949
+ return {
950
+ ok: false,
951
+ partial: index > 0,
952
+ problems: [`publish failed while writing ${step.what}: ${reason}`, ...partialState(landed, [step.what, ...steps.slice(index + 1).map((rest) => rest.what)])]
953
+ };
954
+ }
955
+ return null;
956
+ }
957
+ /** What is live and what is not, listed rather than summarised.
958
+ *
959
+ * Two facts, and both matter for the repair: a document this publish wrote is
960
+ * live NOW, and a document it did not write still holds what the LAST publish
961
+ * left — which is not the same as being absent, and not the same as matching
962
+ * the declaration that was just half-applied. */
963
+ function partialState(landed, notWritten) {
964
+ const repair = "Publishing again is the repair: the write is idempotent, and it re-does every step, including the ones that did land.";
965
+ if (landed.length === 0) return [`Nothing was written. ${repair}`];
966
+ return [
967
+ `Written by this publish, and live now: ${landed.join("; ")}.`,
968
+ `NOT written: ${notWritten.join("; ")} — ${notWritten.length === 1 ? "it still holds" : "they still hold"} whatever the previous publish left.`,
969
+ repair
970
+ ];
971
+ }
972
+ /** Publish this repository's declaration to its app.
973
+ *
974
+ * Everything variable is a parameter or comes from the host binding, so the
975
+ * whole path is exercisable against an in-memory `FirestoreDocs` with no
976
+ * network and no API key — which is the only way the conversion table gets
977
+ * tested as a table. */
978
+ async function publishApp(opts = {}) {
979
+ const root = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
980
+ const handle = require_store.firestoreHandle();
981
+ if (!handle) return {
982
+ ok: false,
983
+ partial: false,
984
+ problems: ["publish needs a signed-in Firestore session: connect remote-host first. Publishing writes the app's roster and configuration as the app's owner, which is an authenticated write."]
985
+ };
986
+ const authored = await readAuthored(root);
987
+ if (!authored.ok) return {
988
+ ...authored,
989
+ partial: false
990
+ };
991
+ const collections = await sharedCollections(opts, root);
992
+ const problems = declarationProblems(authored.app, collections, handle);
993
+ if (problems.length > 0) return {
994
+ ok: false,
995
+ partial: false,
996
+ problems
997
+ };
998
+ const issues = await recordProblems(collections, {
999
+ ...opts,
1000
+ workspaceRoot: root
1001
+ });
1002
+ if (issues.unreadable.length > 0) return {
1003
+ ok: false,
1004
+ partial: false,
1005
+ problems: [...issues.unreadable, "publish stopped: the live records could not be read, so nothing checked whether the schemas about to be published still fit them. This is not something `confirm` overrides — confirming means accepting a known breakage, and here there is no reading at all. Fix the access (or the connection) and publish again."]
1006
+ };
1007
+ if (issues.records > 0 && opts.confirm !== true) return {
1008
+ ok: false,
1009
+ partial: false,
1010
+ problems: [...issues.lines, "publish stopped: these records are live and members are reading them. Migrate them first, or re-run with confirm to publish the schema anyway and repair the records afterwards."]
1011
+ };
1012
+ return writePublished(authored.app, collections, handle, opts, root, issues);
1013
+ }
1014
+ /** The write half: stamp, project, and put the documents in the order the
1015
+ * rules require. Split from the gate above so neither half hides the other —
1016
+ * everything up to here can refuse, and nothing from here on does. */
1017
+ async function writePublished(authored, collections, handle, opts, root, issues) {
1018
+ const { aid } = authored;
1019
+ let existing;
1020
+ try {
1021
+ existing = await handle.docs.get(APPS_COLLECTION, aid);
1022
+ } catch (err) {
1023
+ return {
1024
+ ok: false,
1025
+ partial: false,
1026
+ problems: [`publish failed while reading the current app document (apps/${aid}): ${err instanceof Error ? err.message : String(err)}`, "Nothing was written. Publishing again is safe — this read only decides whether the app is created or updated."]
1027
+ };
1028
+ }
1029
+ const stampSource = await (opts.resolveCommit ?? gitStamp)(root);
1030
+ const stamp = {
1031
+ uid: handle.uid,
1032
+ email: handle.email,
1033
+ publishedAt: (opts.now ?? Date.now)(),
1034
+ commit: stampSource.commit
1035
+ };
1036
+ const existingApp = require_dist.isRecord(existing) ? existing : null;
1037
+ const published = projectApp(authored, schemasOf(collections), stamp, existingApp);
1038
+ if (stampSource.dirty === true) published.app.publishedDirty = true;
1039
+ const written = await writeDocuments(handle, aid, published);
1040
+ if (written !== null) return written;
1041
+ return {
1042
+ ok: true,
1043
+ aid,
1044
+ cids: published.schemas.map((entry) => entry.cid),
1045
+ created: existingApp === null,
1046
+ commit: stamp.commit,
1047
+ dirty: stampSource.dirty === true,
1048
+ recordIssues: issues.records,
1049
+ recordIssuesCapped: issues.capped,
1050
+ published
1051
+ };
1052
+ }
1053
+ //#endregion
15
1054
  //#region src/collection/server/skillAssets.ts
16
1055
  /** Read a collection's custom-view HTML, path-safely. `viewFile` is a
17
1056
  * schema-validated `views/*.html` path, resolved with realpath containment.
@@ -36,13 +1075,13 @@ async function readCustomViewHtml(collection, viewFile, opts = {}) {
36
1075
  * `readCustomViewHtml`. Used by both `readCustomViewHtml` and
37
1076
  * `readCustomViewI18n` so the two stay in lockstep. */
38
1077
  async function readSourceAwareFile(collection, relPath, opts) {
39
- const safeSlug = require_discovery.safeSlugName(collection.slug);
1078
+ const safeSlug = require_store.safeSlugName(collection.slug);
40
1079
  if (safeSlug === null) return null;
41
- const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
42
- const staging = collection.source === "project" ? require_discovery.stagingSkillDir(workspaceRoot, safeSlug) : null;
1080
+ const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
1081
+ const staging = collection.source === "project" ? require_store.stagingSkillDir(workspaceRoot, safeSlug) : null;
43
1082
  const bases = staging === null ? [collection.skillDir] : [staging, collection.skillDir];
44
1083
  for (const base of bases) {
45
- const resolved = require_discovery.resolveTemplatePath(base, relPath);
1084
+ const resolved = require_store.resolveTemplatePath(base, relPath);
46
1085
  if (resolved === null) continue;
47
1086
  try {
48
1087
  return await (0, node_fs_promises.readFile)(resolved, "utf-8");
@@ -100,9 +1139,9 @@ async function readCustomViewI18n(collection, i18nFile, locale, opts = {}) {
100
1139
  * the file contents, or null when the path escapes the skill dir, the
101
1140
  * resolved target isn't a regular file, or the read fails. */
102
1141
  async function readSkillTemplate(skillDir, templateRelPath) {
103
- const resolved = require_discovery.resolveTemplatePath(skillDir, templateRelPath);
1142
+ const resolved = require_store.resolveTemplatePath(skillDir, templateRelPath);
104
1143
  if (resolved === null) return null;
105
- if (!await require_discovery.isRegularFile(resolved)) return null;
1144
+ if (!await require_store.isRegularFile(resolved)) return null;
106
1145
  try {
107
1146
  return await (0, node_fs_promises.readFile)(resolved, "utf-8");
108
1147
  } catch {
@@ -244,11 +1283,11 @@ function absentReferencedColumns(rows, query) {
244
1283
  /** Run a validated query over enriched collection rows. */
245
1284
  async function runQueryOverRows(rows, query) {
246
1285
  if (rows.length === 0) return emptyCollectionResult(query);
247
- await (0, node_fs_promises.mkdir)(require_discovery.cacheDir(), {
1286
+ await (0, node_fs_promises.mkdir)(require_store.cacheDir(), {
248
1287
  recursive: true,
249
1288
  mode: 448
250
1289
  });
251
- const jsonlPath = node_path.default.join(require_discovery.cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
1290
+ const jsonlPath = node_path.default.join(require_store.cacheDir(), `q-${(0, node_crypto.randomBytes)(8).toString("hex")}.jsonl`);
252
1291
  try {
253
1292
  const nullPads = Object.fromEntries(absentReferencedColumns(rows, query).map((column) => [column, null]));
254
1293
  const handle = await (0, node_fs_promises.open)(jsonlPath, "wx", 384);
@@ -264,335 +1303,115 @@ async function runQueryOverRows(rows, query) {
264
1303
  } finally {
265
1304
  await handle.close();
266
1305
  }
267
- const { sql, params } = require_discovery.compileJsonlQuery(query);
268
- return (await require_discovery.queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, require_discovery.normalizeCsvValue(value)])));
1306
+ const { sql, params } = require_store.compileJsonlQuery(query);
1307
+ return (await require_store.queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, require_store.normalizeCsvValue(value)])));
269
1308
  } finally {
270
- await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
271
- if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_discovery.log.warn("collections", "temp JSONL cleanup failed", {
272
- path: jsonlPath,
273
- error: String(err)
274
- });
275
- });
276
- }
277
- }
278
- //#endregion
279
- //#region src/collection/server/derive.ts
280
- async function loadTarget(slug, opts) {
281
- const target = await require_discovery.loadCollection(slug, opts);
282
- if (!target) return null;
283
- const items = await require_discovery.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
284
- const byId = {};
285
- for (const item of items) {
286
- const itemId = item[target.schema.primaryKey];
287
- if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
288
- }
289
- return {
290
- schema: target.schema,
291
- byId
292
- };
293
- }
294
- /** Load every ref/embed target and backlink source collection once.
295
- * Unknown / unloadable targets are simply absent — downstream derefs
296
- * resolve to null (em-dash) and backlinks to an empty row set, the
297
- * same fail-soft the UI renders. */
298
- async function loadLinkedTargets(schema, opts) {
299
- const slugs = [.../* @__PURE__ */ new Set([
300
- ...require_promptSafety.uniqueRefTargets(schema),
301
- ...require_promptSafety.uniqueEmbedTargets(schema),
302
- ...require_promptSafety.uniqueBacklinkSources(schema)
303
- ])];
304
- const loaded = {};
305
- for (const slug of slugs) {
306
- const target = await loadTarget(slug, opts);
307
- if (target) loaded[slug] = target;
308
- }
309
- return loaded;
310
- }
311
- function toRefRecords(linked) {
312
- return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
313
- }
314
- /** The matching source rows for one `backlinks` field, projected to the
315
- * source primaryKey + `display` columns — so getItems on a
316
- * heavily-referenced record stays a summary, not a dump of the source
317
- * collection. Missing source ⇒ [] (fail-soft). The rows come from the
318
- * DERIVED source records (`byId`), so `display`/`filter` on a derived
319
- * source column (an invoice `total`) works. */
320
- function projectBacklinks(field, schema, enriched, linked) {
321
- const source = linked[field.from];
322
- if (!source) return [];
323
- const selfId = require_calendarGrid.fieldText(enriched[schema.primaryKey]);
324
- return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
325
- }
326
- /** Project the computed (never-stored) field kinds onto one derived
327
- * record: `toggle` → boolean off its enum, `embed` → the target record
328
- * (fixed `id` or per-record `idField`), or null when missing,
329
- * `backlinks` → the matching source rows (see `projectBacklinks`). */
330
- function projectComputed(schema, enriched, linked) {
331
- for (const [key, field] of Object.entries(schema.fields)) {
332
- if (field.type === "toggle" && field.field) enriched[key] = require_calendarGrid.fieldText(enriched[field.field]) === field.onValue;
333
- if (field.type === "embed" && field.to) {
334
- const targetId = require_calendarGrid.embedTargetId(field, enriched);
335
- const target = require_promptSafety.ownProp(linked, field.to);
336
- enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
337
- }
338
- if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
339
- }
340
- return enriched;
341
- }
342
- /** Resolve every rollup field onto a COPY of the record, BEFORE the
343
- * formula pass — a `derived` formula may reference rollup values as
344
- * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
345
- * machinery as backlinks, collapsed to a number: an unresolvable source
346
- * is null (a formula reading it fails soft to em-dash); an empty match
347
- * set is a real 0. Returns `record` unchanged when the schema declares
348
- * no rollups. */
349
- function projectRollups(schema, record, linked) {
350
- let out = record;
351
- for (const [key, field] of Object.entries(schema.fields)) {
352
- if (field.type !== "rollup") continue;
353
- if (out === record) out = { ...record };
354
- const source = linked[field.from];
355
- const selfId = require_calendarGrid.fieldText(record[schema.primaryKey]);
356
- out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
357
- }
358
- return out;
359
- }
360
- /** Enrich records with every host-computed field: derived formulas
361
- * evaluated (cross-collection derefs included), toggles projected,
362
- * embeds resolved. Loads each linked collection ONCE per call. Input
363
- * records are not mutated. */
364
- async function enrichItems(collection, items, opts = {}) {
365
- const { schema } = collection;
366
- const linked = await loadLinkedTargets(schema, opts);
367
- const refRecords = toRefRecords(linked);
368
- return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
369
- }
370
- //#endregion
371
- //#region src/collection/server/queryRunner.ts
372
- async function runCollectionQuery(collection, query, opts = {}) {
373
- const store = require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
374
- if (store.query) return store.query(query);
375
- return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
376
- }
377
- //#endregion
378
- //#region src/collection/core/recordZ.ts
379
- /** The emptiness rule shared by `required` and the "only check present
380
- * values" gate. NOT a truthiness check — `0` and `false` are filled. */
381
- var isEmptyValue = (value) => value === void 0 || value === null || value === "";
382
- /** The historical write-gate checks, verbatim: required non-empty, enum
383
- * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
384
- function enforcedProblem(key, spec, value) {
385
- const empty = isEmptyValue(value);
386
- if (spec.required && empty) return `missing required field '${key}'`;
387
- if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
388
- return null;
389
- }
390
- /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
391
- * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
392
- * the lint flags exactly the values the calendar / trigger / spawn code
393
- * would silently drop — impossible days like `2026-02-30`, and datetimes
394
- * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
395
- * which the day view can't place). `string`-backed types accept anything
396
- * stringifiable; `ref` existence is out of scope. */
397
- function strictTypeProblem(key, spec, value) {
398
- switch (spec.type) {
399
- case "number":
400
- case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
401
- case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
402
- case "date": return require_calendarGrid.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
403
- case "datetime": return require_calendarGrid.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
404
- default: return null;
405
- }
406
- }
407
- /** Strict check for a PRESENT `table` value: an array of row objects, each
408
- * row conforming to the sub-schema (required / enum / typed sub-values).
409
- * First row problem wins, prefixed with the row number so the fix is
410
- * locatable. */
411
- function strictTableProblem(key, spec, value) {
412
- if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
413
- for (let index = 0; index < value.length; index++) {
414
- const row = value[index];
415
- if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
416
- for (const [subKey, subSpec] of Object.entries(spec.of)) {
417
- const subValue = row[subKey];
418
- const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
419
- if (problem) return `'${key}' row ${index + 1}: ${problem}`;
420
- }
421
- }
422
- return null;
423
- }
424
- /** First problem for one field's stored value under `tier`, or null.
425
- * Enforced checks always run (and their messages never vary by tier — the
426
- * scan and the write gate must agree on them); strict adds the per-type
427
- * layer on present values only. */
428
- function recordFieldProblem(key, spec, value, tier) {
429
- const enforced = enforcedProblem(key, spec, value);
430
- if (enforced || tier === "enforced") return enforced;
431
- if (isEmptyValue(value)) return null;
432
- if (spec.type === "table") return strictTableProblem(key, spec, value);
433
- return strictTypeProblem(key, spec, value);
434
- }
435
- var compiled = /* @__PURE__ */ new WeakMap();
436
- /** Compile `schema.fields` into a zod validator for a stored record.
437
- * Loose object: unknown keys are allowed and any declared key may be
438
- * absent (records are user files, not parse-and-rewrite targets —
439
- * callers validate, they never persist the parse output). The checks run
440
- * as ONE object-level refine iterating fields in declaration order —
441
- * per-key shape schemas can't express "key may be absent BUT its absence
442
- * must still reach the required check", and the single loop keeps the
443
- * first reported issue identical to the historical first-problem-wins
444
- * contract. */
445
- function compileRecordZ(schema, tier) {
446
- const cached = compiled.get(schema)?.[tier];
447
- if (cached) return cached;
448
- const stored = Object.entries(schema.fields).filter(([, spec]) => !require_calendarGrid.COMPUTED_TYPES.has(spec.type));
449
- const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
450
- for (const [key, spec] of stored) {
451
- const problem = recordFieldProblem(key, spec, record[key], tier);
452
- if (problem) ctx.addIssue({
453
- code: "custom",
454
- message: problem,
455
- path: [key]
456
- });
457
- }
458
- });
459
- const entry = compiled.get(schema) ?? {};
460
- entry[tier] = validator;
461
- compiled.set(schema, entry);
462
- return validator;
463
- }
464
- /** First schema problem on an in-memory record under `tier`, or null. One
465
- * issue per record keeps the report short and the fix obvious (the
466
- * historical contract of `validateRecordObject`). */
467
- function firstRecordProblem(record, schema, tier) {
468
- const result = compileRecordZ(schema, tier).safeParse(record);
469
- if (result.success) return null;
470
- return result.error.issues[0]?.message ?? "record failed schema validation";
1309
+ await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
1310
+ if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_store.log.warn("collections", "temp JSONL cleanup failed", {
1311
+ path: jsonlPath,
1312
+ error: String(err)
1313
+ });
1314
+ });
1315
+ }
471
1316
  }
472
1317
  //#endregion
473
- //#region src/collection/server/validate.ts
474
- var MAX_ISSUES = 25;
475
- /** Read every `<id>.json` under the collection's dataDir and report the
476
- * ones that won't load or violate the schema. An empty list means every
477
- * record is fine. */
478
- /** List entries under the data dir, guarding realpath containment (against a
479
- * symlinked dir swapped in after discovery, like `listItems`) and treating a
480
- * missing dir as empty while surfacing real I/O faults. */
481
- async function listRecordFilenames(dataDir, workspaceRoot) {
482
- if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) {
483
- require_discovery.log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
484
- return [];
485
- }
486
- try {
487
- return await (0, node_fs_promises.readdir)(dataDir);
488
- } catch (err) {
489
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
490
- throw err;
1318
+ //#region src/collection/server/derive.ts
1319
+ async function loadTarget(slug, opts) {
1320
+ const target = await require_store.loadCollection(slug, opts);
1321
+ if (!target) return null;
1322
+ const items = await require_store.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
1323
+ const byId = {};
1324
+ for (const item of items) {
1325
+ const itemId = item[target.schema.primaryKey];
1326
+ if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
491
1327
  }
1328
+ return {
1329
+ schema: target.schema,
1330
+ byId
1331
+ };
492
1332
  }
493
- async function validateCollectionRecords(collection, opts = {}) {
494
- if (collection.schema.dataSource !== void 0) return [];
495
- if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
496
- const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
497
- const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
498
- const issues = [];
499
- for (const name of entries.sort()) {
500
- if (!name.endsWith(".json") || name.startsWith(".")) continue;
501
- if (issues.length >= MAX_ISSUES) break;
502
- const issue = await inspectRecord(node_path.default.join(collection.dataDir, name), name, collection.schema);
503
- if (issue) issues.push(issue);
1333
+ /** Load every ref/embed target and backlink source collection once.
1334
+ * Unknown / unloadable targets are simply absent — downstream derefs
1335
+ * resolve to null (em-dash) and backlinks to an empty row set, the
1336
+ * same fail-soft the UI renders. */
1337
+ async function loadLinkedTargets(schema, opts) {
1338
+ const slugs = [.../* @__PURE__ */ new Set([
1339
+ ...require_promptSafety.uniqueRefTargets(schema),
1340
+ ...require_promptSafety.uniqueEmbedTargets(schema),
1341
+ ...require_promptSafety.uniqueBacklinkSources(schema)
1342
+ ])];
1343
+ const loaded = {};
1344
+ for (const slug of slugs) {
1345
+ const target = await loadTarget(slug, opts);
1346
+ if (target) loaded[slug] = target;
504
1347
  }
505
- return issues;
1348
+ return loaded;
506
1349
  }
507
- /** Store-backed twin of the file scan: list every record through the
508
- * collection's store and lint it with the same "strict" report-only tier.
509
- * A row the store can't even parse is invisible here (the store skips
510
- * it), so the read/parse classifications of the file scan don't apply —
511
- * schema violations are what this catches. `file` carries the record id
512
- * (there is no per-record filename). */
513
- async function validateStoreRecords(collection, opts) {
514
- let items;
515
- try {
516
- items = await require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
517
- } catch (err) {
518
- return [{
519
- file: "(store)",
520
- problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
521
- }];
522
- }
523
- const issues = [];
524
- for (const item of items) {
525
- if (issues.length >= MAX_ISSUES) break;
526
- const itemId = require_calendarGrid.fieldText(item[collection.schema.primaryKey]);
527
- const problem = validateRecordObject(item, itemId, collection.schema, "strict");
528
- if (problem) issues.push({
529
- file: itemId,
530
- problem
531
- });
532
- }
533
- return issues;
1350
+ function toRefRecords(linked) {
1351
+ return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
534
1352
  }
535
- async function readRecordText(fullPath, name) {
536
- try {
537
- if (!(await (0, node_fs_promises.lstat)(fullPath)).isFile()) return {
538
- file: name,
539
- problem: "not a regular file (symlink?) — skipped, won't appear"
540
- };
541
- return { raw: await (0, node_fs_promises.readFile)(fullPath, "utf-8") };
542
- } catch {
543
- return {
544
- file: name,
545
- problem: "could not be read — skipped, won't appear"
546
- };
1353
+ /** The matching source rows for one `backlinks` field, projected to the
1354
+ * source primaryKey + `display` columns — so getItems on a
1355
+ * heavily-referenced record stays a summary, not a dump of the source
1356
+ * collection. Missing source ⇒ [] (fail-soft). The rows come from the
1357
+ * DERIVED source records (`byId`), so `display`/`filter` on a derived
1358
+ * source column (an invoice `total`) works. */
1359
+ function projectBacklinks(field, schema, enriched, linked) {
1360
+ const source = linked[field.from];
1361
+ if (!source) return [];
1362
+ const selfId = require_calendarGrid.fieldText(enriched[schema.primaryKey]);
1363
+ return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
1364
+ }
1365
+ /** Project the computed (never-stored) field kinds onto one derived
1366
+ * record: `toggle` → boolean off its enum, `embed` → the target record
1367
+ * (fixed `id` or per-record `idField`), or null when missing,
1368
+ * `backlinks` → the matching source rows (see `projectBacklinks`). */
1369
+ function projectComputed(schema, enriched, linked) {
1370
+ for (const [key, field] of Object.entries(schema.fields)) {
1371
+ if (field.type === "toggle" && field.field) enriched[key] = require_calendarGrid.fieldText(enriched[field.field]) === field.onValue;
1372
+ if (field.type === "embed" && field.to) {
1373
+ const targetId = require_calendarGrid.embedTargetId(field, enriched);
1374
+ const target = require_promptSafety.ownProp(linked, field.to);
1375
+ enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
1376
+ }
1377
+ if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
547
1378
  }
1379
+ return enriched;
548
1380
  }
549
- /** Classify a single record file: unreadable / unparseable / non-object /
550
- * schema violation, or null when it's fine. */
551
- async function inspectRecord(fullPath, name, schema) {
552
- const read = await readRecordText(fullPath, name);
553
- if ("problem" in read) return read;
554
- let parsed;
555
- try {
556
- parsed = JSON.parse(read.raw);
557
- } catch (err) {
558
- return {
559
- file: name,
560
- 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.`
561
- };
1381
+ /** Resolve every rollup field onto a COPY of the record, BEFORE the
1382
+ * formula pass — a `derived` formula may reference rollup values as
1383
+ * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
1384
+ * machinery as backlinks, collapsed to a number: an unresolvable source
1385
+ * is null (a formula reading it fails soft to em-dash); an empty match
1386
+ * set is a real 0. Returns `record` unchanged when the schema declares
1387
+ * no rollups. */
1388
+ function projectRollups(schema, record, linked) {
1389
+ let out = record;
1390
+ for (const [key, field] of Object.entries(schema.fields)) {
1391
+ if (field.type !== "rollup") continue;
1392
+ if (out === record) out = { ...record };
1393
+ const source = linked[field.from];
1394
+ const selfId = require_calendarGrid.fieldText(record[schema.primaryKey]);
1395
+ out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
562
1396
  }
563
- if (!require_dist.isRecord(parsed)) return {
564
- file: name,
565
- problem: "not a JSON object — skipped, won't appear"
566
- };
567
- const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
568
- return problem ? {
569
- file: name,
570
- problem
571
- } : null;
1397
+ return out;
572
1398
  }
573
- /** What a non-string primary key actually is, for the error message. Names the
574
- * shape rather than stringifying the value — "[object Object]" tells the reader
575
- * nothing about what is wrong. */
576
- function describeIdType(value) {
577
- if (value === null) return "null";
578
- if (value === void 0) return "missing";
579
- if (Array.isArray(value)) return "an array";
580
- return `a ${typeof value}`;
1399
+ /** Enrich records with every host-computed field: derived formulas
1400
+ * evaluated (cross-collection derefs included), toggles projected,
1401
+ * embeds resolved. Loads each linked collection ONCE per call. Input
1402
+ * records are not mutated. */
1403
+ async function enrichItems(collection, items, opts = {}) {
1404
+ const { schema } = collection;
1405
+ const linked = await loadLinkedTargets(schema, opts);
1406
+ const refRecords = toRefRecords(linked);
1407
+ return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
581
1408
  }
582
- /** First schema problem on an in-memory record (primaryKey↔id mismatch,
583
- * then the compiled per-field checks — see `../core/recordZ` for the two
584
- * tiers), or null when it's fine. One issue per record keeps the report
585
- * short and the fix obvious. Pure + exported so write paths
586
- * (manageCollection putItems) can gate on the SAME enforced rules the
587
- * post-hoc file scan reports — `itemId` is the id the record is (or
588
- * would be) stored under. The default `"enforced"` tier keeps every
589
- * write gate on the historical three checks; only pass `"strict"` from
590
- * report-only surfaces. */
591
- function validateRecordObject(record, itemId, schema, tier = "enforced") {
592
- const idValue = record[schema.primaryKey];
593
- 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`;
594
- if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
595
- return firstRecordProblem(record, schema, tier);
1409
+ //#endregion
1410
+ //#region src/collection/server/queryRunner.ts
1411
+ async function runCollectionQuery(collection, query, opts = {}) {
1412
+ const store = require_store.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
1413
+ if (store.query) return store.query(query);
1414
+ return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
596
1415
  }
597
1416
  //#endregion
598
1417
  //#region src/collection/server/mutate.ts
@@ -619,7 +1438,7 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
619
1438
  status: "invalid-params",
620
1439
  problem: paramProblem
621
1440
  };
622
- const store = require_discovery.storeFor(collection, opts);
1441
+ const store = require_store.storeFor(collection, opts);
623
1442
  if (!store.write) return {
624
1443
  ok: false,
625
1444
  status: "write-refused",
@@ -642,7 +1461,7 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
642
1461
  });
643
1462
  const merged = {
644
1463
  ...Object.fromEntries(stored),
645
- ...require_discovery.resolveMutateSet(action.set, params)
1464
+ ...require_store.resolveMutateSet(action.set, params)
646
1465
  };
647
1466
  const invalid = validateRecordObject(merged, itemId, collection.schema);
648
1467
  if (invalid) return {
@@ -700,10 +1519,10 @@ function schemaRelations(schema) {
700
1519
  * Fail-soft: a missing dir or a dataDir escaping the workspace via
701
1520
  * symlink counts 0. */
702
1521
  async function countRecordFiles(dataDir, workspaceRoot) {
703
- if (!require_discovery.isContainedInRoot(dataDir, workspaceRoot)) return 0;
1522
+ if (!require_store.isContainedInRoot(dataDir, workspaceRoot)) return 0;
704
1523
  try {
705
1524
  const names = (await (0, node_fs_promises.readdir)(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
706
- return (await Promise.all(names.map((name) => require_discovery.isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
1525
+ return (await Promise.all(names.map((name) => require_store.isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
707
1526
  } catch {
708
1527
  return 0;
709
1528
  }
@@ -716,9 +1535,9 @@ async function countRecordFiles(dataDir, workspaceRoot) {
716
1535
  * break the whole ontology). */
717
1536
  async function countRecords(collection, workspaceRoot) {
718
1537
  if (require_calendarGrid.storageKindFor(collection.schema) !== "file") try {
719
- return (await require_discovery.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
1538
+ return (await require_store.storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
720
1539
  } catch (err) {
721
- if (require_discovery.isBackendUnavailable(err)) return null;
1540
+ if (require_store.isBackendUnavailable(err)) return null;
722
1541
  return 0;
723
1542
  }
724
1543
  return countRecordFiles(collection.dataDir, workspaceRoot);
@@ -739,8 +1558,8 @@ async function toOntologyEntry(collection, workspaceRoot) {
739
1558
  * discovery's order), each with its outbound relations and a
740
1559
  * readdir-cheap record count — fine to call on demand. */
741
1560
  async function buildWorkspaceOntology(opts = {}) {
742
- const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
743
- const collections = await require_discovery.discoverCollections(opts);
1561
+ const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
1562
+ const collections = await require_store.discoverCollections(opts);
744
1563
  return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
745
1564
  }
746
1565
  //#endregion
@@ -774,15 +1593,15 @@ async function computeCollectionIcon(collection, opts = {}) {
774
1593
  const spec = schema.dynamicIcon;
775
1594
  if (!spec) return schema.icon;
776
1595
  try {
777
- const source = await require_discovery.loadCollection(spec.source.collection, opts);
1596
+ const source = await require_store.loadCollection(spec.source.collection, opts);
778
1597
  if (!source) return spec.fallback ?? schema.icon;
779
- const ordered = sortByPrimaryKey(await require_discovery.storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
1598
+ const ordered = sortByPrimaryKey(await require_store.storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
780
1599
  const orderBy = spec.source.orderBy ?? require_promptSafety.firstDateField(source.schema);
781
1600
  const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
782
1601
  const record = require_promptSafety.selectDynamicRecord(ordered, spec.source, orderBy, recordsById);
783
1602
  return require_promptSafety.resolveIcon(record, spec, schema.icon, recordsById);
784
1603
  } catch (err) {
785
- require_discovery.log.warn("collections", "dynamic icon compute failed, falling back", {
1604
+ require_store.log.warn("collections", "dynamic icon compute failed, falling back", {
786
1605
  slug: collection.slug,
787
1606
  source: spec.source.collection,
788
1607
  error: String(err)
@@ -944,7 +1763,7 @@ function computeSuccessor(schema, sourceItem, sourceId) {
944
1763
  * predates a map/enum edit — discovery rejects this statically otherwise). */
945
1764
  function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
946
1765
  if (parseCivil(sourceItem[triggerField]) === null) {
947
- require_discovery.log.warn("collections", "spawn skipped: source trigger date unparseable", {
1766
+ require_store.log.warn("collections", "spawn skipped: source trigger date unparseable", {
948
1767
  slug,
949
1768
  sourceId,
950
1769
  triggerField
@@ -952,7 +1771,7 @@ function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
952
1771
  return;
953
1772
  }
954
1773
  const fromField = require_calendarGrid.isFieldDrivenEvery(every) ? every.fromField : void 0;
955
- require_discovery.log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
1774
+ require_store.log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
956
1775
  slug,
957
1776
  sourceId,
958
1777
  fromField,
@@ -975,7 +1794,7 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
975
1794
  return;
976
1795
  }
977
1796
  if (matchesWhen(spawn.when, schema, computed.record)) {
978
- require_discovery.log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
1797
+ require_store.log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
979
1798
  slug,
980
1799
  sourceId,
981
1800
  successorId: computed.id
@@ -983,28 +1802,28 @@ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}
983
1802
  return;
984
1803
  }
985
1804
  try {
986
- const { write } = require_discovery.storeFor(collection, ioOpts);
1805
+ const { write } = require_store.storeFor(collection, ioOpts);
987
1806
  if (!write) {
988
- require_discovery.log.warn("collections", "spawn skipped: collection store is read-only", {
1807
+ require_store.log.warn("collections", "spawn skipped: collection store is read-only", {
989
1808
  slug,
990
1809
  sourceId
991
1810
  });
992
1811
  return;
993
1812
  }
994
1813
  const result = await write(computed.id, computed.record, { refuseOverwrite: true });
995
- if (result.kind === "ok") require_discovery.log.info("collections", "spawned successor", {
1814
+ if (result.kind === "ok") require_store.log.info("collections", "spawned successor", {
996
1815
  slug,
997
1816
  sourceId,
998
1817
  successorId: computed.id
999
1818
  });
1000
- else if (result.kind !== "conflict") require_discovery.log.warn("collections", "spawn write failed", {
1819
+ else if (result.kind !== "conflict") require_store.log.warn("collections", "spawn write failed", {
1001
1820
  slug,
1002
1821
  sourceId,
1003
1822
  successorId: computed.id,
1004
1823
  kind: result.kind
1005
1824
  });
1006
1825
  } catch (err) {
1007
- require_discovery.log.warn("collections", "spawn write threw", {
1826
+ require_store.log.warn("collections", "spawn write threw", {
1008
1827
  slug,
1009
1828
  sourceId,
1010
1829
  successorId: computed.id,
@@ -1043,7 +1862,7 @@ function todayStamp() {
1043
1862
  /** Every directory the delete will touch must resolve under the
1044
1863
  * workspace root — guards against a symlinked ancestor escaping it. */
1045
1864
  function deleteTargets(collection, workspaceRoot) {
1046
- const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
1865
+ const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
1047
1866
  return [
1048
1867
  ...staging === null ? [] : [staging],
1049
1868
  collection.skillDir,
@@ -1155,12 +1974,12 @@ function recordLocationLabel(schema) {
1155
1974
  }
1156
1975
  /** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
1157
1976
  async function writeArchive(collection, archiveDir, workspaceRoot) {
1158
- const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
1977
+ const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
1159
1978
  const skillSrc = staging !== null && await pathExists(staging) ? staging : collection.skillDir;
1160
1979
  await (0, node_fs_promises.cp)(skillSrc, node_path.default.join(archiveDir, "skill"), { recursive: true });
1161
1980
  if (await pathExists(collection.dataDir)) await (0, node_fs_promises.cp)(collection.dataDir, node_path.default.join(archiveDir, "records"), { recursive: true });
1162
1981
  if (collection.storageFile !== void 0 && await pathExists(collection.storageFile)) {
1163
- const checkpointed = await require_discovery.checkpointSqliteDatabase(collection.storageFile);
1982
+ const checkpointed = await require_store.checkpointSqliteDatabase(collection.storageFile);
1164
1983
  await (0, node_fs_promises.cp)(collection.storageFile, node_path.default.join(archiveDir, node_path.default.basename(collection.storageFile)));
1165
1984
  if (!checkpointed) for (const suffix of [
1166
1985
  "-wal",
@@ -1177,7 +1996,7 @@ async function writeArchive(collection, archiveDir, workspaceRoot) {
1177
1996
  * is a no-op; the now-empty data parent (`data/<slug>/` after its
1178
1997
  * `items/` is gone) is swept too, but only when empty. */
1179
1998
  async function removeLocations(collection, workspaceRoot) {
1180
- const staging = require_discovery.stagingSkillDir(workspaceRoot, collection.slug);
1999
+ const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
1181
2000
  if (staging !== null) await (0, node_fs_promises.rm)(staging, {
1182
2001
  recursive: true,
1183
2002
  force: true
@@ -1201,24 +2020,24 @@ async function removeLocations(collection, workspaceRoot) {
1201
2020
  }
1202
2021
  async function deleteCollection(collection, opts = {}) {
1203
2022
  const { slug } = collection;
1204
- const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
2023
+ const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
1205
2024
  if (collection.source === "user") return {
1206
2025
  kind: "user-scope",
1207
2026
  slug
1208
2027
  };
1209
- if (require_discovery.isPresetSlug(slug)) return {
2028
+ if (require_store.isPresetSlug(slug)) return {
1210
2029
  kind: "preset",
1211
2030
  slug
1212
2031
  };
1213
2032
  if (collection.schema.storage?.type === "firestore") {
1214
- require_discovery.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
2033
+ require_store.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
1215
2034
  return {
1216
2035
  kind: "unsupported-backend",
1217
2036
  slug
1218
2037
  };
1219
2038
  }
1220
2039
  if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
1221
- require_discovery.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
2040
+ require_store.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
1222
2041
  slug,
1223
2042
  dataDir: collection.dataDir
1224
2043
  });
@@ -1227,19 +2046,19 @@ async function deleteCollection(collection, opts = {}) {
1227
2046
  slug
1228
2047
  };
1229
2048
  }
1230
- if (deleteTargets(collection, workspaceRoot).some((target) => !require_discovery.isContainedInRoot(target, workspaceRoot))) {
1231
- require_discovery.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
2049
+ if (deleteTargets(collection, workspaceRoot).some((target) => !require_store.isContainedInRoot(target, workspaceRoot))) {
2050
+ require_store.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
1232
2051
  return {
1233
2052
  kind: "path-escape",
1234
2053
  slug
1235
2054
  };
1236
2055
  }
1237
- const archiveRel = node_path.default.join(require_discovery.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
2056
+ const archiveRel = node_path.default.join(require_store.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
1238
2057
  const archiveDir$1 = node_path.default.join(workspaceRoot, archiveRel);
1239
2058
  await (0, node_fs_promises.mkdir)(archiveDir$1, { recursive: true });
1240
2059
  await writeArchive(collection, archiveDir$1, workspaceRoot);
1241
2060
  await removeLocations(collection, workspaceRoot);
1242
- require_discovery.log.info("collections", "collection deleted + archived", {
2061
+ require_store.log.info("collections", "collection deleted + archived", {
1243
2062
  slug,
1244
2063
  archive: archiveRel
1245
2064
  });
@@ -1269,7 +2088,7 @@ async function fileExists(target) {
1269
2088
  * deletes agree on both layouts. */
1270
2089
  async function canonicalBase(collection, workspaceRoot, safeSlug) {
1271
2090
  if (collection.source !== "project") return collection.skillDir;
1272
- const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
2091
+ const staging = require_store.stagingSkillDir(workspaceRoot, safeSlug);
1273
2092
  if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) return staging;
1274
2093
  return collection.skillDir;
1275
2094
  }
@@ -1279,11 +2098,11 @@ async function canonicalBase(collection, workspaceRoot, safeSlug) {
1279
2098
  * project collection (no staging mirror) doesn't have an empty staging tree
1280
2099
  * materialised by a side effect of the delete. */
1281
2100
  async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
1282
- const active = node_path.default.join(collection.skillDir, require_discovery.SCHEMA_FILE);
2101
+ const active = node_path.default.join(collection.skillDir, require_store.SCHEMA_FILE);
1283
2102
  if (collection.source !== "project") return [active];
1284
- const staging = require_discovery.stagingSkillDir(workspaceRoot, safeSlug);
2103
+ const staging = require_store.stagingSkillDir(workspaceRoot, safeSlug);
1285
2104
  const targets = [];
1286
- if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_discovery.SCHEMA_FILE));
2105
+ if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_store.SCHEMA_FILE));
1287
2106
  targets.push(active);
1288
2107
  return targets;
1289
2108
  }
@@ -1302,7 +2121,7 @@ async function unlinkIfPresent(target) {
1302
2121
  * preserved verbatim. */
1303
2122
  async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
1304
2123
  const base = await canonicalBase(collection, workspaceRoot, safeSlug);
1305
- const canonical = node_path.default.join(base, require_discovery.SCHEMA_FILE);
2124
+ const canonical = node_path.default.join(base, require_store.SCHEMA_FILE);
1306
2125
  const parsed = JSON.parse(await (0, node_fs_promises.readFile)(canonical, "utf-8"));
1307
2126
  const next = require_dist.isRecord(parsed) && require_dist.isUnknownArray(parsed.views) ? {
1308
2127
  ...parsed,
@@ -1316,8 +2135,8 @@ async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug
1316
2135
  * refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
1317
2136
  async function deleteCustomView(collection, viewId, opts = {}) {
1318
2137
  if (collection.source === "user") return { kind: "user-scope" };
1319
- if (require_discovery.isPresetSlug(collection.slug)) return { kind: "preset" };
1320
- const safeSlug = require_discovery.safeSlugName(collection.slug);
2138
+ if (require_store.isPresetSlug(collection.slug)) return { kind: "preset" };
2139
+ const safeSlug = require_store.safeSlugName(collection.slug);
1321
2140
  if (safeSlug === null) return {
1322
2141
  kind: "unsafe-path",
1323
2142
  viewId
@@ -1328,8 +2147,8 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1328
2147
  kind: "not-found",
1329
2148
  viewId
1330
2149
  };
1331
- const workspaceRoot = opts.workspaceRoot ?? require_discovery.getWorkspaceRoot();
1332
- const htmlPath = require_discovery.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
2150
+ const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
2151
+ const htmlPath = require_store.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
1333
2152
  if (htmlPath === null) return {
1334
2153
  kind: "unsafe-path",
1335
2154
  viewId
@@ -1556,7 +2375,7 @@ var SCHEMA_DOCS_PROBE_SLUG = "_";
1556
2375
  /** Resolve the workspace root the same way every collections call does:
1557
2376
  * the injected override (tests) or the configured collection host. */
1558
2377
  function resolveBase(deps) {
1559
- return deps.workspaceRoot ?? require_discovery.getWorkspaceRoot();
2378
+ return deps.workspaceRoot ?? require_store.getWorkspaceRoot();
1560
2379
  }
1561
2380
  /** Where a collection skill is authored in THIS root, and therefore both which
1562
2381
  * authoring guide `schemaDocs` serves and where `getSchema` / `putSchema`
@@ -1570,7 +2389,7 @@ function resolveBase(deps) {
1570
2389
  * from there, so the tool would silently contradict its own documentation.
1571
2390
  * Staged requires BOTH to agree; anything else is direct. */
1572
2391
  function authoringTarget(deps, slug) {
1573
- const stagingDir = deps.stagedSkillAuthoring === false ? null : require_discovery.stagingSkillDir(resolveBase(deps), slug);
2392
+ const stagingDir = deps.stagedSkillAuthoring === false ? null : require_store.stagingSkillDir(resolveBase(deps), slug);
1574
2393
  return {
1575
2394
  variant: stagingDir === null ? "direct" : "staged",
1576
2395
  stagingDir
@@ -1613,7 +2432,7 @@ async function recordIssuesWarning(collection, deps) {
1613
2432
  return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
1614
2433
  }
1615
2434
  async function loadRequestedItems(collection, ids, deps) {
1616
- const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2435
+ const store = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
1617
2436
  if (!ids) return {
1618
2437
  items: await store.list(),
1619
2438
  missing: []
@@ -1622,7 +2441,7 @@ async function loadRequestedItems(collection, ids, deps) {
1622
2441
  const missing = [];
1623
2442
  for (const recordId of ids) {
1624
2443
  const item = await store.read(recordId).catch((err) => {
1625
- if (require_discovery.isBackendUnavailable(err)) throw err;
2444
+ if (require_store.isBackendUnavailable(err)) throw err;
1626
2445
  return null;
1627
2446
  });
1628
2447
  if (item) items.push(item);
@@ -1687,7 +2506,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
1687
2506
  try {
1688
2507
  existing = await store.read(itemId);
1689
2508
  } catch (err) {
1690
- if (require_discovery.isBackendUnavailable(err)) throw err;
2509
+ if (require_store.isBackendUnavailable(err)) throw err;
1691
2510
  return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
1692
2511
  }
1693
2512
  if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
@@ -1702,7 +2521,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
1702
2521
  }
1703
2522
  async function putOneItem(collection, store, write, record, mode, deps) {
1704
2523
  const { schema } = collection;
1705
- const itemId = require_discovery.resolveCreateItemId(schema, record);
2524
+ const itemId = require_store.resolveCreateItemId(schema, record);
1706
2525
  const reject = (about, problem) => ({ rejected: {
1707
2526
  id: require_promptSafety.defangForPrompt(about),
1708
2527
  problem: require_promptSafety.defangForPrompt(problem)
@@ -1733,7 +2552,7 @@ async function putOneItem(collection, store, write, record, mode, deps) {
1733
2552
  * `runCollectionQuery`, shared with the desktop custom view's `/query`
1734
2553
  * route so the two surfaces can never drift. */
1735
2554
  async function handleQueryItems(collection, queryArg, deps) {
1736
- const parsed = require_discovery.CollectionQueryZ.safeParse(queryArg);
2555
+ const parsed = require_store.CollectionQueryZ.safeParse(queryArg);
1737
2556
  if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${require_promptSafety.defangForPrompt(issue.message)}`).join("\n")}`;
1738
2557
  const rows = await runCollectionQuery(collection, parsed.data, { workspaceRoot: deps.workspaceRoot });
1739
2558
  return JSON.stringify({
@@ -1743,9 +2562,9 @@ async function handleQueryItems(collection, queryArg, deps) {
1743
2562
  });
1744
2563
  }
1745
2564
  async function handlePutItems(collection, args, deps) {
1746
- const store = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2565
+ const store = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
1747
2566
  const { write } = store;
1748
- if (!write) return `manageCollection: ${require_discovery.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
2567
+ if (!write) return `manageCollection: ${require_store.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
1749
2568
  const written = [];
1750
2569
  const rejected = [];
1751
2570
  for (const record of args.items) {
@@ -1764,8 +2583,8 @@ async function handlePutItems(collection, args, deps) {
1764
2583
  * absent `delete` IS the refusal), and the same per-id result shape so a
1765
2584
  * partially-bad batch reports per id instead of failing whole. */
1766
2585
  async function handleDeleteItems(collection, ids, deps) {
1767
- const { delete: removeItem } = require_discovery.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
1768
- if (!removeItem) return `manageCollection: ${require_discovery.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
2586
+ const { delete: removeItem } = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
2587
+ if (!removeItem) return `manageCollection: ${require_store.readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
1769
2588
  const deleted = [];
1770
2589
  const rejected = [];
1771
2590
  for (const itemId of ids) {
@@ -1834,6 +2653,32 @@ async function handleGetOntology(deps) {
1834
2653
  collections
1835
2654
  });
1836
2655
  }
2656
+ /** Publish this repository's `app.json` + shared schemas to its Firestore app.
2657
+ *
2658
+ * Named as a whole-app action because it IS one: publish takes the repository
2659
+ * as its unit (one roster, one public config, every shared collection), so it
2660
+ * carries no `slug` and refusing one is not a limitation to work around.
2661
+ *
2662
+ * The reply is prose rather than a status code on purpose. This is the one
2663
+ * operation in the collection surface that changes what every member sees the
2664
+ * moment it lands, and the two things the caller has to relay — what will
2665
+ * break, and that `confirm` is how it proceeds anyway — are sentences, not
2666
+ * fields. */
2667
+ async function handlePublishApp(deps, confirm) {
2668
+ const result = await publishApp({
2669
+ ...deps,
2670
+ confirm
2671
+ });
2672
+ if (!result.ok) {
2673
+ const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
2674
+ return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
2675
+ }
2676
+ const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
2677
+ const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
2678
+ const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
2679
+ const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
2680
+ return `${result.created ? "Created" : "Updated"} app '${result.aid}' and published ${result.cids.length} collection(s): ${result.cids.join(", ")}. Signed ${stamp}. The previous app document is kept in \`previousPublished\` for rollback.${forced}`;
2681
+ }
1837
2682
  /** Return the collection-authoring reference (`collection-skills.md`),
1838
2683
  * rendered by `renderSchemaDocs` — the full doc overflows the agent's
1839
2684
  * per-result limit, so the default reply is the core guide + a table of
@@ -1854,7 +2699,7 @@ async function handleSchemaDocs(deps, topic) {
1854
2699
  * fallback for user-scope skills that have no staging copy. Raw text —
1855
2700
  * not the parsed schema — so the agent edits the true on-disk source. */
1856
2701
  async function handleGetSchema(slug, deps) {
1857
- const collection = await require_discovery.loadCollection(slug, deps);
2702
+ const collection = await require_store.loadCollection(slug, deps);
1858
2703
  if (!collection) return unknownCollection(slug);
1859
2704
  const { stagingDir } = authoringTarget(deps, collection.slug);
1860
2705
  const candidates = [...stagingDir === null ? [] : [node_path.default.join(stagingDir, SCHEMA_FILE)], node_path.default.join(collection.skillDir, SCHEMA_FILE)];
@@ -1916,11 +2761,11 @@ async function writeAndMirrorSchema(slug, skillDir, schema, deps) {
1916
2761
  * gate doesn't apply. Returns a one-line reason, or null when the schema
1917
2762
  * would be accepted. */
1918
2763
  function schemaDiscoveryGate(schema, base) {
1919
- const primaryField = require_discovery.resolvePrimaryField(schema.fields, schema.primaryKey);
2764
+ const primaryField = require_store.resolvePrimaryField(schema.fields, schema.primaryKey);
1920
2765
  if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
1921
2766
  if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
1922
- if (schema.dataPath !== void 0 && require_discovery.resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
1923
- if (schema.dataSource !== void 0 && require_discovery.resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
2767
+ if (schema.dataPath !== void 0 && require_store.resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
2768
+ if (schema.dataSource !== void 0 && require_store.resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
1924
2769
  return null;
1925
2770
  }
1926
2771
  /** Validate a schema against CollectionSchemaZ and, on success, persist it.
@@ -1928,11 +2773,11 @@ function schemaDiscoveryGate(schema, base) {
1928
2773
  * the root's authoring dir (the normal create flow), not through here. */
1929
2774
  async function handlePutSchema(slug, schemaArg, deps) {
1930
2775
  if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
1931
- const collection = await require_discovery.loadCollection(slug, deps);
2776
+ const collection = await require_store.loadCollection(slug, deps);
1932
2777
  if (!collection) return `manageCollection: unknown collection '${require_promptSafety.defangForPrompt(slug)}' — create it by writing SKILL.md + ${SCHEMA_FILE} under ${authoringDirLabel(deps, require_promptSafety.defangForPrompt(slug))}, then edit it here.`;
1933
2778
  const refusal = schemaEditRefusal(collection, slug, deps);
1934
2779
  if (refusal) return refusal;
1935
- const parsed = require_discovery.CollectionSchemaZ.safeParse(schemaArg);
2780
+ const parsed = require_store.CollectionSchemaZ.safeParse(schemaArg);
1936
2781
  if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
1937
2782
  const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
1938
2783
  if (gate) return `manageCollection: schema rejected — ${gate} (call schemaDocs for the field reference). It passes basic validation but discovery would skip it, hiding the collection.`;
@@ -1942,7 +2787,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
1942
2787
  written: true
1943
2788
  });
1944
2789
  }
1945
- 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.";
2790
+ var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. `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.";
1946
2791
  /** Validate getItems' optional `ids`/`fields` args, then delegate. */
1947
2792
  async function dispatchGetItems(collection, args, deps) {
1948
2793
  const ids = optionalStringArray(args.ids, "ids");
@@ -1979,7 +2824,7 @@ async function manageCollectionHandler(deps, args) {
1979
2824
  try {
1980
2825
  return await dispatchManageCollection(deps, args);
1981
2826
  } catch (err) {
1982
- if (require_discovery.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
2827
+ if (require_store.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
1983
2828
  throw err;
1984
2829
  }
1985
2830
  }
@@ -1987,18 +2832,19 @@ async function dispatchManageCollection(deps, args) {
1987
2832
  const action = typeof args.action === "string" ? args.action : "";
1988
2833
  if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
1989
2834
  if (action === "getOntology") return handleGetOntology(deps);
2835
+ if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
1990
2836
  const slug = typeof args.slug === "string" ? args.slug.trim() : "";
1991
2837
  if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
1992
2838
  if (action === "getSchema") return handleGetSchema(slug, deps);
1993
2839
  if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
1994
- if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
1995
- const collection = await require_discovery.loadCollection(slug, deps);
2840
+ if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", \"putSchema\", or \"publishApp\".";
2841
+ const collection = await require_store.loadCollection(slug, deps);
1996
2842
  if (!collection) return unknownCollection(slug);
1997
2843
  return dispatchRecordAction(action, collection, args, deps);
1998
2844
  }
1999
2845
  var MANAGE_COLLECTION_DEFINITION = {
2000
2846
  name: "manageCollection",
2001
- 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.",
2847
+ description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. 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.",
2002
2848
  inputSchema: {
2003
2849
  type: "object",
2004
2850
  properties: {
@@ -2012,7 +2858,8 @@ var MANAGE_COLLECTION_DEFINITION = {
2012
2858
  "getOntology",
2013
2859
  "schemaDocs",
2014
2860
  "getSchema",
2015
- "putSchema"
2861
+ "putSchema",
2862
+ "publishApp"
2016
2863
  ],
2017
2864
  description: "What to do."
2018
2865
  },
@@ -2048,6 +2895,10 @@ var MANAGE_COLLECTION_DEFINITION = {
2048
2895
  type: "object",
2049
2896
  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."
2050
2897
  },
2898
+ confirm: {
2899
+ type: "boolean",
2900
+ description: "publishApp: publish even though existing records fail the schemas being published. Omit it first — the refusal lists what would break, and that list is the thing to show the user before asking."
2901
+ },
2051
2902
  topic: {
2052
2903
  type: "string",
2053
2904
  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)."
@@ -2069,6 +2920,30 @@ function makeManageCollectionTool(deps = {}) {
2069
2920
  };
2070
2921
  }
2071
2922
  //#endregion
2923
+ Object.defineProperty(exports, "APPS_COLLECTION", {
2924
+ enumerable: true,
2925
+ get: function() {
2926
+ return APPS_COLLECTION;
2927
+ }
2928
+ });
2929
+ Object.defineProperty(exports, "APP_ROLES", {
2930
+ enumerable: true,
2931
+ get: function() {
2932
+ return APP_ROLES;
2933
+ }
2934
+ });
2935
+ Object.defineProperty(exports, "AuthoredAppZ", {
2936
+ enumerable: true,
2937
+ get: function() {
2938
+ return AuthoredAppZ;
2939
+ }
2940
+ });
2941
+ Object.defineProperty(exports, "MAX_RECORD_ISSUES", {
2942
+ enumerable: true,
2943
+ get: function() {
2944
+ return MAX_RECORD_ISSUES;
2945
+ }
2946
+ });
2072
2947
  Object.defineProperty(exports, "MAX_SCHEMA_ISSUES", {
2073
2948
  enumerable: true,
2074
2949
  get: function() {
@@ -2087,18 +2962,48 @@ Object.defineProperty(exports, "ONE_SECOND_MS", {
2087
2962
  return ONE_SECOND_MS;
2088
2963
  }
2089
2964
  });
2965
+ Object.defineProperty(exports, "PUBLIC_CONFIG_DOC", {
2966
+ enumerable: true,
2967
+ get: function() {
2968
+ return PUBLIC_CONFIG_DOC;
2969
+ }
2970
+ });
2971
+ Object.defineProperty(exports, "STORE_UNREADABLE", {
2972
+ enumerable: true,
2973
+ get: function() {
2974
+ return STORE_UNREADABLE;
2975
+ }
2976
+ });
2090
2977
  Object.defineProperty(exports, "advanceTriggerDate", {
2091
2978
  enumerable: true,
2092
2979
  get: function() {
2093
2980
  return advanceTriggerDate;
2094
2981
  }
2095
2982
  });
2983
+ Object.defineProperty(exports, "appConfigPath", {
2984
+ enumerable: true,
2985
+ get: function() {
2986
+ return appConfigPath;
2987
+ }
2988
+ });
2989
+ Object.defineProperty(exports, "appSchemasPath", {
2990
+ enumerable: true,
2991
+ get: function() {
2992
+ return appSchemasPath;
2993
+ }
2994
+ });
2096
2995
  Object.defineProperty(exports, "applyMutateAction", {
2097
2996
  enumerable: true,
2098
2997
  get: function() {
2099
2998
  return applyMutateAction;
2100
2999
  }
2101
3000
  });
3001
+ Object.defineProperty(exports, "bindsSubmitterIdentity", {
3002
+ enumerable: true,
3003
+ get: function() {
3004
+ return bindsSubmitterIdentity;
3005
+ }
3006
+ });
2102
3007
  Object.defineProperty(exports, "buildActionSeedPrompt", {
2103
3008
  enumerable: true,
2104
3009
  get: function() {
@@ -2195,18 +3100,42 @@ Object.defineProperty(exports, "maybeSpawnSuccessor", {
2195
3100
  return maybeSpawnSuccessor;
2196
3101
  }
2197
3102
  });
3103
+ Object.defineProperty(exports, "parseAuthoredApp", {
3104
+ enumerable: true,
3105
+ get: function() {
3106
+ return parseAuthoredApp;
3107
+ }
3108
+ });
2198
3109
  Object.defineProperty(exports, "parseCivil", {
2199
3110
  enumerable: true,
2200
3111
  get: function() {
2201
3112
  return parseCivil;
2202
3113
  }
2203
3114
  });
3115
+ Object.defineProperty(exports, "projectApp", {
3116
+ enumerable: true,
3117
+ get: function() {
3118
+ return projectApp;
3119
+ }
3120
+ });
2204
3121
  Object.defineProperty(exports, "promptPathsFor", {
2205
3122
  enumerable: true,
2206
3123
  get: function() {
2207
3124
  return promptPathsFor;
2208
3125
  }
2209
3126
  });
3127
+ Object.defineProperty(exports, "publishApp", {
3128
+ enumerable: true,
3129
+ get: function() {
3130
+ return publishApp;
3131
+ }
3132
+ });
3133
+ Object.defineProperty(exports, "publishProblems", {
3134
+ enumerable: true,
3135
+ get: function() {
3136
+ return publishProblems;
3137
+ }
3138
+ });
2210
3139
  Object.defineProperty(exports, "readCustomViewHtml", {
2211
3140
  enumerable: true,
2212
3141
  get: function() {
@@ -2274,4 +3203,4 @@ Object.defineProperty(exports, "validateRecordObject", {
2274
3203
  }
2275
3204
  });
2276
3205
 
2277
- //# sourceMappingURL=server-9mFaGLdq.cjs.map
3206
+ //# sourceMappingURL=server-CWZyg8fn.cjs.map