@mulmoclaude/core 3.5.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/collection-skills.md +49 -1
- package/assets/helps/error-recovery.md +53 -0
- package/dist/calendarGrid-CQ8MVSRb.js.map +1 -1
- package/dist/calendarGrid-DGILaVxI.cjs.map +1 -1
- package/dist/collection/core/schema.d.ts +8 -1
- package/dist/collection/core/schemaZ.d.ts +36 -26
- package/dist/collection/firestore.cjs +51 -0
- package/dist/collection/firestore.cjs.map +1 -0
- package/dist/collection/firestore.d.ts +1 -0
- package/dist/collection/firestore.js +50 -0
- package/dist/collection/firestore.js.map +1 -0
- package/dist/collection/registry/server/index.cjs +19 -19
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/appManifest.d.ts +53 -0
- package/dist/collection/server/delete.d.ts +10 -0
- package/dist/collection/server/discoveredCollection.d.ts +10 -0
- package/dist/collection/server/discovery.d.ts +1 -0
- package/dist/collection/server/firestoreDocs.d.ts +39 -0
- package/dist/collection/server/firestoreStore.d.ts +14 -0
- package/dist/collection/server/host.d.ts +52 -0
- package/dist/collection/server/index.cjs +72 -52
- package/dist/collection/server/index.d.ts +8 -1
- package/dist/collection/server/index.js +3 -3
- package/dist/collection/server/manageTool.d.ts +4 -0
- package/dist/collection/server/publish.d.ts +56 -0
- package/dist/collection/server/publishChecks.d.ts +29 -0
- package/dist/collection/server/publishManifest.d.ts +181 -0
- package/dist/collection/server/publishProject.d.ts +86 -0
- package/dist/collection/server/validate.d.ts +12 -0
- package/dist/collection-watchers/index.cjs +143 -52
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +132 -41
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/collection-watchers/reconciler.d.ts +1 -1
- package/dist/feeds/server/index.cjs +10 -10
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +2 -2
- package/dist/google/index.cjs +12 -12
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +1 -1
- package/dist/{server-BiRLLMpW.js → server-B48Jyxcj.js} +1100 -230
- package/dist/server-B48Jyxcj.js.map +1 -0
- package/dist/{server-5EMj3naj.cjs → server-CWZyg8fn.cjs} +1333 -385
- package/dist/server-CWZyg8fn.cjs.map +1 -0
- package/dist/{discovery-Ck4AqikY.cjs → store-5_P_NsGa.cjs} +2108 -1755
- package/dist/store-5_P_NsGa.cjs.map +1 -0
- package/dist/{discovery-DH9wweuj.js → store-_61sO8K8.js} +2333 -2022
- package/dist/store-_61sO8K8.js.map +1 -0
- package/dist/whisper/index.cjs +1 -1
- package/dist/whisper/index.js +1 -1
- package/package.json +7 -1
- package/dist/discovery-Ck4AqikY.cjs.map +0 -1
- package/dist/discovery-DH9wweuj.js.map +0 -1
- package/dist/server-5EMj3naj.cjs.map +0 -1
- package/dist/server-BiRLLMpW.js.map +0 -1
|
@@ -1,15 +1,1054 @@
|
|
|
1
1
|
import { a as isErrorWithCode, c as isStringArray, l as isUnknownArray, s as isRecord, t as errorMessage } from "./dist-D8zokgGo.js";
|
|
2
2
|
import { n as writeFileAtomic } from "./root-BMroU_mB.js";
|
|
3
3
|
import { n as toPosixRelPath } from "./relPath-DW8MC8VO.js";
|
|
4
|
-
import { F as isFieldDrivenEvery, L as storageKindFor, P as embedTargetId, R as fieldText, j as COMPUTED_TYPES, l as parseIsoDate, u as parseIsoDateTime, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
|
|
4
|
+
import { F as isFieldDrivenEvery, L as storageKindFor, P as embedTargetId, R as fieldText, j as COMPUTED_TYPES, l as parseIsoDate, u as parseIsoDateTime, y as isValidCollectionName, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
|
|
5
5
|
import { C as actionVisible, _ as uniqueRefTargets, b as projectBacklinkRow, c as selectDynamicRecord, f as itemIsDone, g as uniqueEmbedTargets, h as uniqueBacklinkSources, i as ownProp, n as deriveAll, o as firstDateField, s as resolveIcon, t as defangForPrompt, v as backlinkRows, x as rollupValue, y as coerceNumeric } from "./promptSafety-CpiME8pj.js";
|
|
6
|
-
import { $ as
|
|
6
|
+
import { B as SCHEMA_FILE$1, C as resolveCreateItemId, D as loadCollection, E as discoverCollections, I as parseAppManifest, J as isBackendUnavailable, K as safeSlugName, M as resolveMutateSet, N as APP_MANIFEST_FILE, O as resolvePrimaryField, U as resolveDataDir, V as isContainedInRoot, W as resolveTemplatePath, X as archiveDir, at as log, b as isRegularFile, d as normalizeCsvValue, f as queryCsv, h as CollectionQueryZ, i as checkpointSqliteDatabase, j as CollectionSchemaZ, m as compileJsonlQuery, n as readOnlyRefusal, nt as getWorkspaceRoot, o as cacheDir, pt as stagingSkillDir, r as storeFor, rt as isPresetSlug$1, tt as firestoreHandle } from "./store-_61sO8K8.js";
|
|
7
7
|
import { ingestStatePath } from "./feeds/paths.js";
|
|
8
8
|
import { mirrorSkillWrite } from "./skill-bridge/index.js";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
//#region src/collection/server/publishManifest.ts
|
|
16
|
+
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
17
|
+
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
18
|
+
* cannot be a way around it. */
|
|
19
|
+
var NameZ = z.string().refine(isValidCollectionName, { message: "is not a valid id (letters, digits, '-' and '_' only)" });
|
|
20
|
+
/** An address on the roster. Not validated as an email beyond "has an @":
|
|
21
|
+
* the rules compare it to `request.auth.token.email` verbatim, so any
|
|
22
|
+
* narrowing here would refuse addresses Firebase itself accepts. */
|
|
23
|
+
var EmailZ = z.string().trim().min(3).includes("@");
|
|
24
|
+
/** The four roles the deployed rules understand. `participant` is the layer
|
|
25
|
+
* that is NAMED but reads only its own rows — see `readerOf` vs `listedIn`. */
|
|
26
|
+
var APP_ROLES = [
|
|
27
|
+
"owner",
|
|
28
|
+
"editor",
|
|
29
|
+
"viewer",
|
|
30
|
+
"participant"
|
|
31
|
+
];
|
|
32
|
+
var RoleZ = z.enum(APP_ROLES);
|
|
33
|
+
/** `{ email: { "*" | cid: role } }`. The `"*"` key is the app-wide role; a
|
|
34
|
+
* member may hold per-collection roles only (the stylist who is editor of
|
|
35
|
+
* bookings and viewer of everything else). */
|
|
36
|
+
var MembersZ = z.record(EmailZ, z.record(z.union([z.literal("*"), NameZ]), RoleZ));
|
|
37
|
+
/** The declarative mail queue, as the rules re-derive it: a transition of the
|
|
38
|
+
* status field, a recipient read off the RECORD, and a fixed template. */
|
|
39
|
+
var MailZ = z.object({
|
|
40
|
+
toField: z.string().trim().min(1),
|
|
41
|
+
on: z.record(z.string().trim().min(1), z.object({
|
|
42
|
+
from: z.array(z.string().trim().min(1)).min(1),
|
|
43
|
+
to: z.string().trim().min(1)
|
|
44
|
+
}).strict()),
|
|
45
|
+
dataFields: z.array(z.string().trim().min(1)).optional()
|
|
46
|
+
}).strict();
|
|
47
|
+
/** What the rules read out of `collections[cid]`. NOT the schema — the schema
|
|
48
|
+
* is published beside it, untouched, for clients to render from. */
|
|
49
|
+
var CollectionConfigZ = z.object({
|
|
50
|
+
statusField: z.string().trim().min(1).optional(),
|
|
51
|
+
/** `{ initial: [...], <status>: [<status>...] }`. Binds writers too, and
|
|
52
|
+
* binds `create` — that is the point of publishing it. */
|
|
53
|
+
transitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
54
|
+
immutable: z.boolean().optional(),
|
|
55
|
+
submitOnly: z.boolean().optional(),
|
|
56
|
+
peerVisibility: z.enum(["public", "hidden"]).optional(),
|
|
57
|
+
revealGated: z.boolean().optional(),
|
|
58
|
+
gatedFrom: NameZ.optional(),
|
|
59
|
+
revealBy: z.string().trim().min(1).optional(),
|
|
60
|
+
mail: MailZ.optional(),
|
|
61
|
+
/** Which fields an aggregate groups by. Declared here rather than in the
|
|
62
|
+
* schema for the same reason as everything else in this file — the schema
|
|
63
|
+
* has no `aggregate` key yet — and it is here at all because the
|
|
64
|
+
* invariant that guards it ("every aggregation key is a CHECKED field")
|
|
65
|
+
* is about `public.submit`, which is an app-level declaration. Published
|
|
66
|
+
* as-is; the rules never read it. */
|
|
67
|
+
aggregate: z.object({ by: z.array(z.string().trim().min(1)).min(1) }).strict().optional()
|
|
68
|
+
}).strict();
|
|
69
|
+
/** An authored submit window. ISO strings, because `app.json` is JSON and a
|
|
70
|
+
* Firestore `Timestamp` has no JSON form. Publish lowers it to epoch millis —
|
|
71
|
+
* the rules do not coerce strings, so an ISO string reaching Firestore is a
|
|
72
|
+
* type error that fails CLOSED (`inWindow` refuses every submission and the
|
|
73
|
+
* author sees "nobody can submit", not an error). */
|
|
74
|
+
var WindowZ = z.object({
|
|
75
|
+
from: z.iso.datetime().optional(),
|
|
76
|
+
until: z.iso.datetime().optional()
|
|
77
|
+
}).strict();
|
|
78
|
+
var ValidateZ = z.object({
|
|
79
|
+
required: z.array(z.string().trim().min(1)).optional(),
|
|
80
|
+
/** Capped at two by the rules themselves: rules have no iteration, so
|
|
81
|
+
* `keyFieldsOk` is unrolled. A third would be accepted here and silently
|
|
82
|
+
* unchecked there. */
|
|
83
|
+
keyFields: z.array(z.object({
|
|
84
|
+
field: z.string().trim().min(1),
|
|
85
|
+
values: z.array(z.union([
|
|
86
|
+
z.string(),
|
|
87
|
+
z.number(),
|
|
88
|
+
z.boolean()
|
|
89
|
+
])).min(1)
|
|
90
|
+
}).strict()).optional()
|
|
91
|
+
}).strict();
|
|
92
|
+
var SubmitZ = z.object({
|
|
93
|
+
auth: z.enum([
|
|
94
|
+
"none",
|
|
95
|
+
"anonymous",
|
|
96
|
+
"verifiedEmail"
|
|
97
|
+
]),
|
|
98
|
+
emailField: z.string().trim().min(1).optional(),
|
|
99
|
+
createFields: z.array(z.string().trim().min(1)).min(1),
|
|
100
|
+
initialStatus: z.string().trim().min(1).optional(),
|
|
101
|
+
idFrom: z.enum([
|
|
102
|
+
"auto",
|
|
103
|
+
"auth.uid",
|
|
104
|
+
"auth.uid+field"
|
|
105
|
+
]).optional(),
|
|
106
|
+
idField: z.string().trim().min(1).optional(),
|
|
107
|
+
validate: ValidateZ.optional(),
|
|
108
|
+
window: WindowZ.optional(),
|
|
109
|
+
/** Per CURRENT STATUS, never a flat list: a flat list lets a customer move
|
|
110
|
+
* an approved booking's `startAt` without anyone re-approving it. */
|
|
111
|
+
selfUpdate: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
112
|
+
selfTransitions: z.record(z.string().trim().min(1), z.array(z.string().trim().min(1))).optional(),
|
|
113
|
+
finalize: z.boolean().optional(),
|
|
114
|
+
audience: z.literal("participant").optional(),
|
|
115
|
+
gateOn: z.object({
|
|
116
|
+
phase: z.string().trim().min(1),
|
|
117
|
+
match: z.string().trim().min(1)
|
|
118
|
+
}).strict().optional()
|
|
119
|
+
}).strict();
|
|
120
|
+
var PublicZ = z.object({
|
|
121
|
+
/** The master switch. Anonymous submission (`auth: "none"`) needs it as
|
|
122
|
+
* well as its own declaration. */
|
|
123
|
+
enabled: z.boolean().optional(),
|
|
124
|
+
read: z.array(NameZ).optional(),
|
|
125
|
+
submit: z.record(NameZ, SubmitZ).optional()
|
|
126
|
+
}).strict();
|
|
127
|
+
/** The whole authored declaration.
|
|
128
|
+
*
|
|
129
|
+
* `owner` is accepted but is NOT the published value — publish stamps the
|
|
130
|
+
* publisher's uid (or carries the existing one forward, which is what the
|
|
131
|
+
* rules require on update) and refuses a declaration that disagrees. It is
|
|
132
|
+
* accepted rather than banned because the sample app.json in the design note
|
|
133
|
+
* shows it, and a hard refusal on a key the samples contain would be a worse
|
|
134
|
+
* first experience than a message naming the mismatch. */
|
|
135
|
+
var AuthoredAppZ = z.object({
|
|
136
|
+
aid: NameZ,
|
|
137
|
+
name: z.string().trim().min(1).optional(),
|
|
138
|
+
/** Per-worktree app id (design D6, implementation order 7). Accepted so a
|
|
139
|
+
* repository already carrying it parses; nothing reads it yet. */
|
|
140
|
+
aidEnv: z.string().trim().min(1).optional(),
|
|
141
|
+
owner: z.string().trim().min(1).optional(),
|
|
142
|
+
members: MembersZ,
|
|
143
|
+
collections: z.record(NameZ, CollectionConfigZ).optional(),
|
|
144
|
+
participantRead: z.array(NameZ).optional(),
|
|
145
|
+
public: PublicZ.optional()
|
|
146
|
+
}).strict();
|
|
147
|
+
/** Parse the authored declaration out of `app.json`'s text.
|
|
148
|
+
*
|
|
149
|
+
* Returns a LIST of problems rather than throwing, for the same reason
|
|
150
|
+
* `loadAppManifest` returns a failure: the caller is a gate whose entire job
|
|
151
|
+
* is to hand the author something to act on. Every problem is reported at
|
|
152
|
+
* once — publish is a manual step, and a parser that stops at the first key
|
|
153
|
+
* makes it N round trips. */
|
|
154
|
+
function parseAuthoredApp(raw) {
|
|
155
|
+
const manifest = parseAppManifest(raw);
|
|
156
|
+
if (!manifest.ok) return {
|
|
157
|
+
ok: false,
|
|
158
|
+
problems: [manifest.kind === "missing" ? "app.json is missing" : manifest.detail]
|
|
159
|
+
};
|
|
160
|
+
const parsed = AuthoredAppZ.safeParse(JSON.parse(raw));
|
|
161
|
+
if (!parsed.success) return {
|
|
162
|
+
ok: false,
|
|
163
|
+
problems: authoredProblems(parsed.error)
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
app: parsed.data
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/** zod issues as one actionable line each: `public.submit.responses.auth: …`. */
|
|
171
|
+
function authoredProblems(error) {
|
|
172
|
+
return error.issues.map((issue) => {
|
|
173
|
+
return `${issue.path.length > 0 ? issue.path.join(".") : "app.json"}: ${issue.message}`;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/collection/server/publishProject.ts
|
|
178
|
+
/** The document id under `apps/{aid}/config`. One document, named, rather than
|
|
179
|
+
* a spread of them: a second public document is a second thing to keep in
|
|
180
|
+
* step, and nothing yet needs one. */
|
|
181
|
+
var PUBLIC_CONFIG_DOC = "public";
|
|
182
|
+
/** Drop keys whose value is `undefined`. Firestore rejects an undefined field
|
|
183
|
+
* value outright, and `"k" in c` — which every optional key in the rules is
|
|
184
|
+
* read through — must mean "the author declared it". */
|
|
185
|
+
function compact(entries) {
|
|
186
|
+
return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== void 0));
|
|
187
|
+
}
|
|
188
|
+
/** ISO → epoch millis, the one conversion the rules cannot do for themselves.
|
|
189
|
+
* The caller has already refused an unparseable string (the authored parser
|
|
190
|
+
* requires `z.iso.datetime()`), so a NaN here would be a programming error;
|
|
191
|
+
* it is still checked, because a NaN written to Firestore fails closed in the
|
|
192
|
+
* same silent way an ISO string does. */
|
|
193
|
+
function windowMillis(window) {
|
|
194
|
+
if (!window) return void 0;
|
|
195
|
+
const out = {};
|
|
196
|
+
if (window.from !== void 0) out.fromMs = Date.parse(window.from);
|
|
197
|
+
if (window.until !== void 0) out.untilMs = Date.parse(window.until);
|
|
198
|
+
if (Object.values(out).some((value) => !Number.isFinite(value))) throw new Error(`publish: window bound is not a parseable timestamp (${JSON.stringify(window)})`);
|
|
199
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
200
|
+
}
|
|
201
|
+
/** One `public.submit[cid]`, with its window lowered. Everything else passes
|
|
202
|
+
* through: the rules read these keys by the names the author wrote. */
|
|
203
|
+
function projectSubmit(submit) {
|
|
204
|
+
const { window, ...rest } = submit;
|
|
205
|
+
return compact({
|
|
206
|
+
...rest,
|
|
207
|
+
window: windowMillis(window)
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
/** The roster's addresses as a set, in a stable order.
|
|
211
|
+
*
|
|
212
|
+
* Sorted so two publishes of the same declaration produce the same document
|
|
213
|
+
* — idempotence is a property this step is tested for, and Firestore compares
|
|
214
|
+
* arrays by ORDER. `membersConsistent()` compares as sets and would accept
|
|
215
|
+
* any order; the test that would notice is the one asserting a second publish
|
|
216
|
+
* changes nothing but `publishedAt`. */
|
|
217
|
+
function memberEmailsOf(members) {
|
|
218
|
+
return Object.keys(members).sort();
|
|
219
|
+
}
|
|
220
|
+
/** The previous document, kept for rollback, with its OWN `previousPublished`
|
|
221
|
+
* stripped.
|
|
222
|
+
*
|
|
223
|
+
* One level, deliberately. Chaining would make every publish carry the entire
|
|
224
|
+
* history of the app inside a single document, which grows without bound and
|
|
225
|
+
* meets Firestore's 1 MiB document limit as a permission-shaped failure at
|
|
226
|
+
* some unpredictable publish. One level answers the question rollback
|
|
227
|
+
* actually asks — "put back what was there before I broke it" — and the
|
|
228
|
+
* further history is in git, which is where the declaration came from. */
|
|
229
|
+
function previousOf(existing) {
|
|
230
|
+
if (!existing) return void 0;
|
|
231
|
+
const { previousPublished: __dropped, ...rest } = existing;
|
|
232
|
+
return rest;
|
|
233
|
+
}
|
|
234
|
+
/** Project the authored declaration into the documents publish writes.
|
|
235
|
+
*
|
|
236
|
+
* `existing` is the app document as it is in Firestore right now, or null on
|
|
237
|
+
* a first publish. Two things need it, both required by the rules:
|
|
238
|
+
* - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
|
|
239
|
+
* would be refused for any app whose owner ever signed in as a different
|
|
240
|
+
* account, and would silently transfer ownership if it were not.
|
|
241
|
+
* - `previousPublished` is that document, so a rollback has something to
|
|
242
|
+
* put back.
|
|
243
|
+
*
|
|
244
|
+
* Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
|
|
245
|
+
* a parameter, which is what makes the conversion table testable as a table. */
|
|
246
|
+
function projectApp(authored, schemas, stamp, existing) {
|
|
247
|
+
const owner = typeof existing?.owner === "string" ? existing.owner : stamp.uid;
|
|
248
|
+
const submit = Object.fromEntries(Object.entries(authored.public?.submit ?? {}).map(([cid, spec]) => [cid, projectSubmit(spec)]));
|
|
249
|
+
const publicBlock = authored.public ? compact({
|
|
250
|
+
enabled: authored.public.enabled,
|
|
251
|
+
read: authored.public.read,
|
|
252
|
+
submit: Object.keys(submit).length > 0 ? submit : void 0
|
|
253
|
+
}) : void 0;
|
|
254
|
+
const app = compact({
|
|
255
|
+
aid: authored.aid,
|
|
256
|
+
name: authored.name,
|
|
257
|
+
owner,
|
|
258
|
+
members: authored.members,
|
|
259
|
+
memberEmails: memberEmailsOf(authored.members),
|
|
260
|
+
collections: authored.collections,
|
|
261
|
+
participantRead: authored.participantRead,
|
|
262
|
+
public: publicBlock,
|
|
263
|
+
publishedAt: stamp.publishedAt,
|
|
264
|
+
publishedBy: stamp.email,
|
|
265
|
+
publishedCommit: stamp.commit,
|
|
266
|
+
previousPublished: previousOf(existing)
|
|
267
|
+
});
|
|
268
|
+
const config = {
|
|
269
|
+
enabled: authored.public?.enabled === true,
|
|
270
|
+
read: authored.public?.read ?? [],
|
|
271
|
+
submit,
|
|
272
|
+
publishedAt: stamp.publishedAt
|
|
273
|
+
};
|
|
274
|
+
if (authored.name !== void 0) config.name = authored.name;
|
|
275
|
+
return {
|
|
276
|
+
app,
|
|
277
|
+
schemas: schemas.map(({ cid, schema }) => ({
|
|
278
|
+
cid,
|
|
279
|
+
doc: schemaDoc(schema, stamp)
|
|
280
|
+
})),
|
|
281
|
+
config
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/** One published schema document. Written key by key rather than through
|
|
285
|
+
* `compact`, so the declared type is the type — an optional commit is the
|
|
286
|
+
* only variable part. */
|
|
287
|
+
function schemaDoc(schema, stamp) {
|
|
288
|
+
const doc = {
|
|
289
|
+
publishedSchema: schema,
|
|
290
|
+
publishedAt: stamp.publishedAt,
|
|
291
|
+
publishedBy: stamp.email
|
|
292
|
+
};
|
|
293
|
+
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
294
|
+
return doc;
|
|
295
|
+
}
|
|
296
|
+
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
297
|
+
* collection path plus a document id, and the app document's id is the aid. */
|
|
298
|
+
var APPS_COLLECTION = "apps";
|
|
299
|
+
/** The collection (schema) documents' parent path. */
|
|
300
|
+
var appSchemasPath = (aid) => `apps/${aid}/collections`;
|
|
301
|
+
/** The public-config documents' parent path. */
|
|
302
|
+
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/collection/server/publishChecks.ts
|
|
305
|
+
/** Does this submit declaration bind a record to the submitter's identity?
|
|
306
|
+
*
|
|
307
|
+
* The condition for requiring `submitOnly`, and deliberately NOT "declares an
|
|
308
|
+
* `audience`": `audience` appears only in the rules' public-create branch, so
|
|
309
|
+
* an owner or editor never meets it and can add records freely. `immutable`
|
|
310
|
+
* is the wrong condition too — a survey's responses are not immutable and
|
|
311
|
+
* can be padded exactly the same way.
|
|
312
|
+
*
|
|
313
|
+
* What these four have in common is that each one makes the record MEAN "the
|
|
314
|
+
* person who submitted it said this": a per-uid id, a per-uid+field id, a
|
|
315
|
+
* row stamped with the submitter's verified address, or a submission
|
|
316
|
+
* restricted to a named participant. A record created through the writer
|
|
317
|
+
* branch carries the same shape and none of that meaning. */
|
|
318
|
+
function bindsSubmitterIdentity(submit) {
|
|
319
|
+
return submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field" || submit.emailField !== void 0 || submit.audience === "participant";
|
|
320
|
+
}
|
|
321
|
+
/** The fields a rule actually CHECKS the value of, for one collection.
|
|
322
|
+
*
|
|
323
|
+
* `keyFields` pins a value against a declared set, `gateOn.match` pins it
|
|
324
|
+
* against the session's current question, and the status field is pinned by
|
|
325
|
+
* the transition machine. An aggregation grouped by anything else is grouped
|
|
326
|
+
* by a field any submitter may write anything into — so the published
|
|
327
|
+
* aggregate is whatever the noisiest respondent decided it should be. */
|
|
328
|
+
function checkedFields(collection, submit) {
|
|
329
|
+
const fields = /* @__PURE__ */ new Set();
|
|
330
|
+
for (const keyField of submit?.validate?.keyFields ?? []) fields.add(keyField.field);
|
|
331
|
+
if (submit?.gateOn) fields.add(submit.gateOn.match);
|
|
332
|
+
if (collection?.statusField) fields.add(collection.statusField);
|
|
333
|
+
return fields;
|
|
334
|
+
}
|
|
335
|
+
/** INVARIANT 1 — a submission bound to its submitter needs `submitOnly`. */
|
|
336
|
+
function submitOnlyProblems(app) {
|
|
337
|
+
const problems = [];
|
|
338
|
+
for (const [cid, submit] of Object.entries(app.public?.submit ?? {})) {
|
|
339
|
+
if (!bindsSubmitterIdentity(submit)) continue;
|
|
340
|
+
if (app.collections?.[cid]?.submitOnly === true) continue;
|
|
341
|
+
problems.push(`collections.${cid}.submitOnly must be true: public.submit.${cid} binds each record to its submitter (${identityBindings(submit).join(", ")}), so a record created any other way would carry that meaning without having earned it. Without submitOnly the rules let an owner or editor write rows directly into ${cid}.`);
|
|
342
|
+
}
|
|
343
|
+
return problems;
|
|
344
|
+
}
|
|
345
|
+
function identityBindings(submit) {
|
|
346
|
+
const bindings = [];
|
|
347
|
+
if (submit.idFrom === "auth.uid" || submit.idFrom === "auth.uid+field") bindings.push(`idFrom: "${submit.idFrom}"`);
|
|
348
|
+
if (submit.emailField !== void 0) bindings.push(`emailField: "${submit.emailField}"`);
|
|
349
|
+
if (submit.audience === "participant") bindings.push(`audience: "participant"`);
|
|
350
|
+
return bindings;
|
|
351
|
+
}
|
|
352
|
+
/** INVARIANT 2 — every aggregation key is a field some rule checks. */
|
|
353
|
+
function aggregateProblems(app) {
|
|
354
|
+
const problems = [];
|
|
355
|
+
for (const [cid, collection] of Object.entries(app.collections ?? {})) {
|
|
356
|
+
const keys = collection.aggregate?.by;
|
|
357
|
+
if (!keys) continue;
|
|
358
|
+
const checked = checkedFields(collection, app.public?.submit?.[cid]);
|
|
359
|
+
const loose = keys.filter((field) => !checked.has(field));
|
|
360
|
+
const spelled = loose.map((field) => `'${field}'`).join(", ");
|
|
361
|
+
if (loose.length > 0) problems.push(`collections.${cid}.aggregate.by names ${spelled}, which no rule checks the value of. An aggregation key must appear in public.submit.${cid}.validate.keyFields, in gateOn.match, or be the statusField — otherwise a submitter chooses their own bucket and the published aggregate is not a count of anything.`);
|
|
362
|
+
}
|
|
363
|
+
return problems;
|
|
364
|
+
}
|
|
365
|
+
/** INVARIANT 3 — `auth: "verifiedEmail"` only.
|
|
366
|
+
*
|
|
367
|
+
* A product decision, not a rules limitation: the rules keep all three stages
|
|
368
|
+
* and the emulator tests keep exercising them, because deleting a stage from
|
|
369
|
+
* the rules turns a change of mind into a cross-repo deploy. Publish is where
|
|
370
|
+
* the current decision is expressed, and it is one line to move. */
|
|
371
|
+
function authProblems(app) {
|
|
372
|
+
return Object.entries(app.public?.submit ?? {}).filter(([, submit]) => submit.auth !== "verifiedEmail").map(([cid, submit]) => `public.submit.${cid}.auth is "${submit.auth}": only "verifiedEmail" may be published. The rules still implement "none" and "anonymous" — this is a product decision, and lifting it is a change here, not a rules deploy.`);
|
|
373
|
+
}
|
|
374
|
+
/** INVARIANT 5 — a mail transition's origins and destination must be disjoint.
|
|
375
|
+
*
|
|
376
|
+
* Overlap means the same write can satisfy the same template twice over, and
|
|
377
|
+
* the deterministic mail id is the only other thing stopping a duplicate
|
|
378
|
+
* send. The rules also require the status to have CHANGED, so an overlapping
|
|
379
|
+
* declaration is not merely redundant: `from` containing `to` is a transition
|
|
380
|
+
* that can never fire, which is a mail nobody ever receives. */
|
|
381
|
+
function mailProblems(app) {
|
|
382
|
+
return Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => collectionMailProblems(cid, collection));
|
|
383
|
+
}
|
|
384
|
+
function collectionMailProblems(cid, collection) {
|
|
385
|
+
const { mail } = collection;
|
|
386
|
+
if (!mail) return [];
|
|
387
|
+
const problems = [];
|
|
388
|
+
if (!collection.statusField) problems.push(`collections.${cid}.mail needs collections.${cid}.statusField: the rules read the status before and after the write to decide the mail is warranted.`);
|
|
389
|
+
for (const [template, transition] of Object.entries(mail.on)) problems.push(...templateMailProblems(cid, collection, template, transition));
|
|
390
|
+
return problems;
|
|
391
|
+
}
|
|
392
|
+
function templateMailProblems(cid, collection, template, transition) {
|
|
393
|
+
const problems = [];
|
|
394
|
+
if (transition.from.includes(transition.to)) problems.push(`collections.${cid}.mail.on.${template} lists "${transition.to}" in both \`from\` and \`to\`. The rules require the status to CHANGE in the same write, so this template can never send.`);
|
|
395
|
+
const allowed = collection.transitions;
|
|
396
|
+
if (allowed) {
|
|
397
|
+
const unreachable = transition.from.filter((from) => !(allowed[from] ?? []).includes(transition.to));
|
|
398
|
+
const spelled = unreachable.map((from) => `'${from}' -> '${transition.to}'`).join(", ");
|
|
399
|
+
if (unreachable.length > 0) problems.push(`collections.${cid}.mail.on.${template} sends on ${spelled}, which collections.${cid}.transitions does not allow. The record write is refused first, so the mail never fires.`);
|
|
400
|
+
}
|
|
401
|
+
return problems;
|
|
402
|
+
}
|
|
403
|
+
/** INVARIANTS 6 and 7 — the window is a real interval, and `keyFields` fits
|
|
404
|
+
* the unrolled check in the rules. */
|
|
405
|
+
function submitShapeProblems(app) {
|
|
406
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => [...windowProblems(cid, submit), ...keyFieldCountProblems(cid, submit)]);
|
|
407
|
+
}
|
|
408
|
+
function windowProblems(cid, submit) {
|
|
409
|
+
const { window } = submit;
|
|
410
|
+
if (window?.from === void 0 || window.until === void 0) return [];
|
|
411
|
+
if (Date.parse(window.until) > Date.parse(window.from)) return [];
|
|
412
|
+
return [`public.submit.${cid}.window closes at or before it opens (${window.from} -> ${window.until}): nothing could ever be submitted.`];
|
|
413
|
+
}
|
|
414
|
+
function keyFieldCountProblems(cid, submit) {
|
|
415
|
+
const keyFields = submit.validate?.keyFields ?? [];
|
|
416
|
+
if (keyFields.length <= 2) return [];
|
|
417
|
+
return [`public.submit.${cid}.validate.keyFields declares ${keyFields.length}; the rules check at most 2. Rules have no iteration, so the check is unrolled — a third would be published and never enforced.`];
|
|
418
|
+
}
|
|
419
|
+
/** The fail-closed traps: declarations the rules read together, where the
|
|
420
|
+
* missing half denies every write instead of loosening one. */
|
|
421
|
+
function coherenceProblems(app) {
|
|
422
|
+
const fromSubmits = Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => submitCoherenceProblems(app, cid, submit));
|
|
423
|
+
const fromCollections = Object.entries(app.collections ?? {}).flatMap(([cid, collection]) => gateCoherenceProblems(cid, collection));
|
|
424
|
+
return [...fromSubmits, ...fromCollections];
|
|
425
|
+
}
|
|
426
|
+
/** `initialStatus` is read together with the collection's `statusField` and
|
|
427
|
+
* with `createFields`; miss either and every submission is refused. */
|
|
428
|
+
function statusCoherenceProblems(cid, submit, collection) {
|
|
429
|
+
if (submit.initialStatus === void 0) return [];
|
|
430
|
+
if (!collection?.statusField) return [`public.submit.${cid}.initialStatus needs collections.${cid}.statusField: the rules look the status up by that name, and refuse every submission without it.`];
|
|
431
|
+
if (new Set(submit.createFields).has(collection.statusField)) return [];
|
|
432
|
+
return [`public.submit.${cid}.createFields must include "${collection.statusField}": a submission may carry ONLY the createFields, and the rules also require the status field to be present and equal to initialStatus. As written, every submission is refused.`];
|
|
433
|
+
}
|
|
434
|
+
/** Every field a RULE reads off a submitted record, other than the status
|
|
435
|
+
* field (which `statusCoherenceProblems` words for itself).
|
|
436
|
+
*
|
|
437
|
+
* `emailField` and `idField` belong here for exactly the reason `required`
|
|
438
|
+
* and `keyFields` do, and forgetting them was the same oversight twice: the
|
|
439
|
+
* rules read `request.resource.data[s.emailField]` and rebuild the document
|
|
440
|
+
* id from `s.idField`, while `hasOnly(createFields)` decides what a
|
|
441
|
+
* submission may carry at all. A field in one list and not the other is a
|
|
442
|
+
* contradiction the submitter cannot resolve — including it is refused,
|
|
443
|
+
* omitting it fails the check. */
|
|
444
|
+
function ruleReadFields(submit) {
|
|
445
|
+
const fields = [];
|
|
446
|
+
if (submit.emailField !== void 0) fields.push({
|
|
447
|
+
field: submit.emailField,
|
|
448
|
+
why: `public.submit.<cid>.emailField — the rules compare it to the submitter's verified address`
|
|
449
|
+
});
|
|
450
|
+
if (submit.idFrom === "auth.uid+field" && submit.idField !== void 0) fields.push({
|
|
451
|
+
field: submit.idField,
|
|
452
|
+
why: `public.submit.<cid>.idField — the rules rebuild the document id from it`
|
|
453
|
+
});
|
|
454
|
+
return fields;
|
|
455
|
+
}
|
|
456
|
+
/** A checked field a submission is not allowed to carry can never be
|
|
457
|
+
* satisfied: carrying it fails `hasOnly`, omitting it fails the check. */
|
|
458
|
+
function createFieldProblems(cid, submit) {
|
|
459
|
+
const createFields = new Set(submit.createFields);
|
|
460
|
+
const ruleRead = ruleReadFields(submit).filter((entry) => !createFields.has(entry.field)).map((entry) => `public.submit.${cid}.createFields must include "${entry.field}" (${entry.why.replace("<cid>", cid)}): a submission may carry only the createFields, so as written every submission is refused whether or not it carries the field.`);
|
|
461
|
+
const required = (submit.validate?.required ?? []).filter((field) => !createFields.has(field)).map((field) => `public.submit.${cid}.validate.required names "${field}", which is not in createFields: a submission may carry only the createFields, so the requirement can never be met.`);
|
|
462
|
+
const keyFields = (submit.validate?.keyFields ?? []).filter((keyField) => !createFields.has(keyField.field)).map((keyField) => `public.submit.${cid}.validate.keyFields checks "${keyField.field}", which is not in createFields: a submission carrying it is refused, and one omitting it fails the check.`);
|
|
463
|
+
return [
|
|
464
|
+
...ruleRead,
|
|
465
|
+
...required,
|
|
466
|
+
...keyFields
|
|
467
|
+
];
|
|
468
|
+
}
|
|
469
|
+
function submitCoherenceProblems(app, cid, submit) {
|
|
470
|
+
const collection = app.collections?.[cid];
|
|
471
|
+
const problems = [...statusCoherenceProblems(cid, submit, collection), ...createFieldProblems(cid, submit)];
|
|
472
|
+
if (submit.idFrom === "auth.uid+field" && submit.idField === void 0) problems.push(`public.submit.${cid}.idFrom is "auth.uid+field" but no idField is declared: the rules rebuild the document id from that field and refuse every create.`);
|
|
473
|
+
if ((submit.selfUpdate !== void 0 || submit.selfTransitions !== void 0) && !collection?.statusField) problems.push(`public.submit.${cid}.selfUpdate / selfTransitions are declared per CURRENT STATUS, but collections.${cid} declares no statusField: the rules read the current status first and refuse every self-edit without it.`);
|
|
474
|
+
if (submit.audience === "participant" && Object.keys(app.members).length === 0) problems.push(`public.submit.${cid}.audience is "participant" but the roster is empty: the rules resolve the submitter's role from members, so every submission is refused.`);
|
|
475
|
+
return problems;
|
|
476
|
+
}
|
|
477
|
+
/** The staged reveal reads its flag off the PARENT record, so the path to that
|
|
478
|
+
* parent is not optional decoration — without it the gate never opens. */
|
|
479
|
+
function gateCoherenceProblems(cid, collection) {
|
|
480
|
+
if (collection.revealGated !== true) return [];
|
|
481
|
+
if (collection.gatedFrom !== void 0 && collection.revealBy !== void 0) return [];
|
|
482
|
+
return [`collections.${cid}.revealGated needs both gatedFrom and revealBy: the flag is read off the PARENT record, and without the path the gate never opens.`];
|
|
483
|
+
}
|
|
484
|
+
/** The publisher must be able to write what they are about to write.
|
|
485
|
+
*
|
|
486
|
+
* On a first publish the rules require the creator to name themselves owner,
|
|
487
|
+
* in the roster, under `'*'`. Getting this wrong produces a bare permission
|
|
488
|
+
* error from Firestore with nothing in it about rosters — worth one line
|
|
489
|
+
* here instead. */
|
|
490
|
+
function publisherProblems(app, publisherEmail) {
|
|
491
|
+
if (app.members[publisherEmail]?.["*"] === "owner") return [];
|
|
492
|
+
return [`members must give you app-wide owner: add "${publisherEmail}": { "*": "owner" }. The rules require the publisher to hold that role (and to name themselves owner when the app is first created); otherwise the write is refused with no explanation.`];
|
|
493
|
+
}
|
|
494
|
+
/** Every cid the declaration mentions must be a collection that exists.
|
|
495
|
+
*
|
|
496
|
+
* A typo'd cid is not an error anywhere else: the app document simply carries
|
|
497
|
+
* a configuration for a collection nobody publishes, and the collection the
|
|
498
|
+
* author meant is published with no configuration at all — i.e. with the
|
|
499
|
+
* status machine and the submit path silently absent. */
|
|
500
|
+
function unknownCidProblems(app, collections) {
|
|
501
|
+
const known = new Set(collections.map((collection) => collection.cid));
|
|
502
|
+
return [
|
|
503
|
+
["collections", Object.keys(app.collections ?? {})],
|
|
504
|
+
["public.read", app.public?.read ?? []],
|
|
505
|
+
["public.submit", Object.keys(app.public?.submit ?? {})],
|
|
506
|
+
["participantRead", app.participantRead ?? []]
|
|
507
|
+
].flatMap(([where, cids]) => cids.filter((cid) => !known.has(cid)).map((cid) => `${where} names '${cid}', which is not a shared collection in this repository. Shared collections here: ${known.size > 0 ? [...known].sort().join(", ") : "(none - a schema needs storage.type \"firestore\")"}.`));
|
|
508
|
+
}
|
|
509
|
+
/** Everything publish refuses, as lines the author can act on.
|
|
510
|
+
*
|
|
511
|
+
* All of them, every time. Publish is a manual step with a human waiting on
|
|
512
|
+
* it; stopping at the first problem turns one review into five. */
|
|
513
|
+
function publishProblems(app, collections, publisherEmail) {
|
|
514
|
+
return [
|
|
515
|
+
...unknownCidProblems(app, collections),
|
|
516
|
+
...publisherProblems(app, publisherEmail),
|
|
517
|
+
...submitOnlyProblems(app),
|
|
518
|
+
...aggregateProblems(app),
|
|
519
|
+
...authProblems(app),
|
|
520
|
+
...mailProblems(app),
|
|
521
|
+
...submitShapeProblems(app),
|
|
522
|
+
...coherenceProblems(app),
|
|
523
|
+
...primaryKeyProblems(app, collections)
|
|
524
|
+
];
|
|
525
|
+
}
|
|
526
|
+
/** A public submission must be able to produce a record the HOST can read.
|
|
527
|
+
*
|
|
528
|
+
* The rules and the engine disagree about what identifies a record, and the
|
|
529
|
+
* gap is invisible from either side alone. The rules bind the DOCUMENT ID
|
|
530
|
+
* (`idFrom`) and let a submission carry only `createFields`; the engine
|
|
531
|
+
* identifies a record by its schema's `primaryKey` FIELD, and the firestore
|
|
532
|
+
* store hands back the document's fields verbatim — `toItem` does not
|
|
533
|
+
* synthesize the key from the document id. So a submit path whose
|
|
534
|
+
* `createFields` omits the primary key writes rows that Firestore accepts and
|
|
535
|
+
* every reader rejects: `validateRecordObject` fails them, the collection
|
|
536
|
+
* renders empty-ish, and the next publish's own pre-check reports them as
|
|
537
|
+
* broken records the publisher never wrote.
|
|
538
|
+
*
|
|
539
|
+
* Not checkable by the rules (they have never heard of a schema) and not
|
|
540
|
+
* catchable at write time (nothing is wrong with the write). Publish is the
|
|
541
|
+
* only place that holds both halves. */
|
|
542
|
+
function primaryKeyProblems(app, collections) {
|
|
543
|
+
const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
|
|
544
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
545
|
+
const primaryKey = primaryKeyOf.get(cid);
|
|
546
|
+
if (primaryKey === void 0 || submit.createFields.includes(primaryKey)) return [];
|
|
547
|
+
return [`public.submit.${cid}.createFields must include "${primaryKey}", the schema's primaryKey: a submission may carry only the createFields, and a shared record is stored as exactly the fields it was written with — the document id is not copied into the record. Without it every submission is accepted by the rules and then rejected by every reader.`];
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/collection/core/recordZ.ts
|
|
552
|
+
/** The emptiness rule shared by `required` and the "only check present
|
|
553
|
+
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
554
|
+
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
555
|
+
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
556
|
+
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
557
|
+
function enforcedProblem(key, spec, value) {
|
|
558
|
+
const empty = isEmptyValue(value);
|
|
559
|
+
if (spec.required && empty) return `missing required field '${key}'`;
|
|
560
|
+
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
564
|
+
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
565
|
+
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
566
|
+
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
567
|
+
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
568
|
+
* which the day view can't place). `string`-backed types accept anything
|
|
569
|
+
* stringifiable; `ref` existence is out of scope. */
|
|
570
|
+
function strictTypeProblem(key, spec, value) {
|
|
571
|
+
switch (spec.type) {
|
|
572
|
+
case "number":
|
|
573
|
+
case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
574
|
+
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
575
|
+
case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
576
|
+
case "datetime": return parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
577
|
+
default: return null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
581
|
+
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
582
|
+
* First row problem wins, prefixed with the row number so the fix is
|
|
583
|
+
* locatable. */
|
|
584
|
+
function strictTableProblem(key, spec, value) {
|
|
585
|
+
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
586
|
+
for (let index = 0; index < value.length; index++) {
|
|
587
|
+
const row = value[index];
|
|
588
|
+
if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
589
|
+
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
590
|
+
const subValue = row[subKey];
|
|
591
|
+
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
592
|
+
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
/** First problem for one field's stored value under `tier`, or null.
|
|
598
|
+
* Enforced checks always run (and their messages never vary by tier — the
|
|
599
|
+
* scan and the write gate must agree on them); strict adds the per-type
|
|
600
|
+
* layer on present values only. */
|
|
601
|
+
function recordFieldProblem(key, spec, value, tier) {
|
|
602
|
+
const enforced = enforcedProblem(key, spec, value);
|
|
603
|
+
if (enforced || tier === "enforced") return enforced;
|
|
604
|
+
if (isEmptyValue(value)) return null;
|
|
605
|
+
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
606
|
+
return strictTypeProblem(key, spec, value);
|
|
607
|
+
}
|
|
608
|
+
var compiled = /* @__PURE__ */ new WeakMap();
|
|
609
|
+
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
610
|
+
* Loose object: unknown keys are allowed and any declared key may be
|
|
611
|
+
* absent (records are user files, not parse-and-rewrite targets —
|
|
612
|
+
* callers validate, they never persist the parse output). The checks run
|
|
613
|
+
* as ONE object-level refine iterating fields in declaration order —
|
|
614
|
+
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
615
|
+
* must still reach the required check", and the single loop keeps the
|
|
616
|
+
* first reported issue identical to the historical first-problem-wins
|
|
617
|
+
* contract. */
|
|
618
|
+
function compileRecordZ(schema, tier) {
|
|
619
|
+
const cached = compiled.get(schema)?.[tier];
|
|
620
|
+
if (cached) return cached;
|
|
621
|
+
const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
|
|
622
|
+
const validator = z.looseObject({}).superRefine((record, ctx) => {
|
|
623
|
+
for (const [key, spec] of stored) {
|
|
624
|
+
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
625
|
+
if (problem) ctx.addIssue({
|
|
626
|
+
code: "custom",
|
|
627
|
+
message: problem,
|
|
628
|
+
path: [key]
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
const entry = compiled.get(schema) ?? {};
|
|
633
|
+
entry[tier] = validator;
|
|
634
|
+
compiled.set(schema, entry);
|
|
635
|
+
return validator;
|
|
636
|
+
}
|
|
637
|
+
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
638
|
+
* issue per record keeps the report short and the fix obvious (the
|
|
639
|
+
* historical contract of `validateRecordObject`). */
|
|
640
|
+
function firstRecordProblem(record, schema, tier) {
|
|
641
|
+
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
642
|
+
if (result.success) return null;
|
|
643
|
+
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/collection/server/validate.ts
|
|
647
|
+
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
648
|
+
* because a caller that REPORTS a count has to know the count is a floor —
|
|
649
|
+
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
650
|
+
var MAX_RECORD_ISSUES = 25;
|
|
651
|
+
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
652
|
+
* at all.
|
|
653
|
+
*
|
|
654
|
+
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
655
|
+
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
656
|
+
* direction that matters: `publish` lets the user override invalid records,
|
|
657
|
+
* and overriding this one would mean publishing without ever having looked. */
|
|
658
|
+
var STORE_UNREADABLE = "(store)";
|
|
659
|
+
var MAX_ISSUES = 25;
|
|
660
|
+
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
661
|
+
* ones that won't load or violate the schema. An empty list means every
|
|
662
|
+
* record is fine. */
|
|
663
|
+
/** List entries under the data dir, guarding realpath containment (against a
|
|
664
|
+
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
665
|
+
* missing dir as empty while surfacing real I/O faults. */
|
|
666
|
+
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
667
|
+
if (!isContainedInRoot(dataDir, workspaceRoot)) {
|
|
668
|
+
log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
669
|
+
return [];
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
return await readdir(dataDir);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
675
|
+
throw err;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
async function validateCollectionRecords(collection, opts = {}) {
|
|
679
|
+
if (collection.schema.dataSource !== void 0) return [];
|
|
680
|
+
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
681
|
+
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
682
|
+
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
683
|
+
const issues = [];
|
|
684
|
+
for (const name of entries.sort()) {
|
|
685
|
+
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
686
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
687
|
+
const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
|
|
688
|
+
if (issue) issues.push(issue);
|
|
689
|
+
}
|
|
690
|
+
return issues;
|
|
691
|
+
}
|
|
692
|
+
/** Store-backed twin of the file scan: list every record through the
|
|
693
|
+
* collection's store and lint it with the same "strict" report-only tier.
|
|
694
|
+
* A row the store can't even parse is invisible here (the store skips
|
|
695
|
+
* it), so the read/parse classifications of the file scan don't apply —
|
|
696
|
+
* schema violations are what this catches. `file` carries the record id
|
|
697
|
+
* (there is no per-record filename). */
|
|
698
|
+
async function validateStoreRecords(collection, opts) {
|
|
699
|
+
let items;
|
|
700
|
+
try {
|
|
701
|
+
items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
702
|
+
} catch (err) {
|
|
703
|
+
return [{
|
|
704
|
+
file: STORE_UNREADABLE,
|
|
705
|
+
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
706
|
+
}];
|
|
707
|
+
}
|
|
708
|
+
const issues = [];
|
|
709
|
+
for (const item of items) {
|
|
710
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
711
|
+
const itemId = fieldText(item[collection.schema.primaryKey]);
|
|
712
|
+
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
713
|
+
if (problem) issues.push({
|
|
714
|
+
file: itemId,
|
|
715
|
+
problem
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return issues;
|
|
719
|
+
}
|
|
720
|
+
async function readRecordText(fullPath, name) {
|
|
721
|
+
try {
|
|
722
|
+
if (!(await lstat(fullPath)).isFile()) return {
|
|
723
|
+
file: name,
|
|
724
|
+
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
725
|
+
};
|
|
726
|
+
return { raw: await readFile(fullPath, "utf-8") };
|
|
727
|
+
} catch {
|
|
728
|
+
return {
|
|
729
|
+
file: name,
|
|
730
|
+
problem: "could not be read — skipped, won't appear"
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
735
|
+
* schema violation, or null when it's fine. */
|
|
736
|
+
async function inspectRecord(fullPath, name, schema) {
|
|
737
|
+
const read = await readRecordText(fullPath, name);
|
|
738
|
+
if ("problem" in read) return read;
|
|
739
|
+
let parsed;
|
|
740
|
+
try {
|
|
741
|
+
parsed = JSON.parse(read.raw);
|
|
742
|
+
} catch (err) {
|
|
743
|
+
return {
|
|
744
|
+
file: name,
|
|
745
|
+
problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
if (!isRecord(parsed)) return {
|
|
749
|
+
file: name,
|
|
750
|
+
problem: "not a JSON object — skipped, won't appear"
|
|
751
|
+
};
|
|
752
|
+
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
753
|
+
return problem ? {
|
|
754
|
+
file: name,
|
|
755
|
+
problem
|
|
756
|
+
} : null;
|
|
757
|
+
}
|
|
758
|
+
/** What a non-string primary key actually is, for the error message. Names the
|
|
759
|
+
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
760
|
+
* nothing about what is wrong. */
|
|
761
|
+
function describeIdType(value) {
|
|
762
|
+
if (value === null) return "null";
|
|
763
|
+
if (value === void 0) return "missing";
|
|
764
|
+
if (Array.isArray(value)) return "an array";
|
|
765
|
+
return `a ${typeof value}`;
|
|
766
|
+
}
|
|
767
|
+
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
768
|
+
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
769
|
+
* tiers), or null when it's fine. One issue per record keeps the report
|
|
770
|
+
* short and the fix obvious. Pure + exported so write paths
|
|
771
|
+
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
772
|
+
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
773
|
+
* would be) stored under. The default `"enforced"` tier keeps every
|
|
774
|
+
* write gate on the historical three checks; only pass `"strict"` from
|
|
775
|
+
* report-only surfaces. */
|
|
776
|
+
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
777
|
+
const idValue = record[schema.primaryKey];
|
|
778
|
+
if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
779
|
+
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
780
|
+
return firstRecordProblem(record, schema, tier);
|
|
781
|
+
}
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region src/collection/server/publish.ts
|
|
784
|
+
var execFileAsync = promisify(execFile);
|
|
785
|
+
/** How many broken records to name before summarising. A publish that would
|
|
786
|
+
* break a thousand rows is answered by the count and a sample; dumping all of
|
|
787
|
+
* them buries the number, which is the part the decision turns on. */
|
|
788
|
+
var MAX_LISTED_ISSUES = 10;
|
|
789
|
+
/** `git rev-parse HEAD` plus a dirty check, or nothing.
|
|
790
|
+
*
|
|
791
|
+
* A missing git, a repository with no commits and a non-repository are all
|
|
792
|
+
* the same answer here — no commit — because the stamp is attribution, not a
|
|
793
|
+
* requirement. What is NOT acceptable is a stamp that lies, which is why the
|
|
794
|
+
* dirty flag exists: publishing from a modified tree records a commit that
|
|
795
|
+
* does not describe what was published, and the flag is the only thing that
|
|
796
|
+
* would ever tell a reader so. */
|
|
797
|
+
async function gitStamp(root) {
|
|
798
|
+
try {
|
|
799
|
+
const { stdout } = await execFileAsync("git", [
|
|
800
|
+
"-C",
|
|
801
|
+
root,
|
|
802
|
+
"rev-parse",
|
|
803
|
+
"HEAD"
|
|
804
|
+
]);
|
|
805
|
+
const commit = stdout.trim();
|
|
806
|
+
const { stdout: status } = await execFileAsync("git", [
|
|
807
|
+
"-C",
|
|
808
|
+
root,
|
|
809
|
+
"status",
|
|
810
|
+
"--porcelain"
|
|
811
|
+
]);
|
|
812
|
+
return {
|
|
813
|
+
commit: commit.length > 0 ? commit : void 0,
|
|
814
|
+
dirty: status.trim().length > 0
|
|
815
|
+
};
|
|
816
|
+
} catch {
|
|
817
|
+
return {};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** The shared collections of THIS REPOSITORY, by cid.
|
|
821
|
+
*
|
|
822
|
+
* `userSkillsDir: null` — not a test convenience, a boundary. Discovery
|
|
823
|
+
* resolves every schema it finds against the WORKSPACE root, user-scope
|
|
824
|
+
* included, so a globally installed skill under `~/.claude/skills` carrying
|
|
825
|
+
* `storage.type: "firestore"` picks up whichever repository's `aid` it
|
|
826
|
+
* happens to be discovered from. Left in, publish would write that schema
|
|
827
|
+
* into this app — and into every other app the same user publishes, since the
|
|
828
|
+
* skill is installed once per machine and the repositories are not.
|
|
829
|
+
*
|
|
830
|
+
* An app is a REPOSITORY (design D1): its collections are the ones committed
|
|
831
|
+
* beside its `app.json`, which is what makes a clone resolve the same
|
|
832
|
+
* collections and an invitation a matter of authorization rather than
|
|
833
|
+
* discovery. A schema that is not in the repository has no claim on a cid
|
|
834
|
+
* there. And because a view is HTML, publishing one is not a tidiness
|
|
835
|
+
* question: it is the machine's own skills reaching every member's browser.
|
|
836
|
+
*
|
|
837
|
+
* The consequence is deliberate: a cid named in `app.json` that exists only
|
|
838
|
+
* in user scope is now an unknown cid, and publish says so by name instead of
|
|
839
|
+
* quietly publishing a schema from outside the repository. */
|
|
840
|
+
async function sharedCollections(opts, root) {
|
|
841
|
+
return (await discoverCollections({
|
|
842
|
+
...opts,
|
|
843
|
+
workspaceRoot: root,
|
|
844
|
+
userSkillsDir: null
|
|
845
|
+
})).filter((collection) => collection.appId !== void 0);
|
|
846
|
+
}
|
|
847
|
+
/** Existing records that would not satisfy the schema about to be published.
|
|
848
|
+
*
|
|
849
|
+
* Read from FIRESTORE, not from disk: a shared collection's records live in
|
|
850
|
+
* the app, and the question this answers is "what does the live data look
|
|
851
|
+
* like under the new schema" — which is the migration question. Reported as
|
|
852
|
+
* a refusal the publisher can override, because a breaking change is
|
|
853
|
+
* sometimes exactly what is intended and the point is that it is a decision
|
|
854
|
+
* rather than a discovery. */
|
|
855
|
+
async function recordProblems(collections, opts) {
|
|
856
|
+
const lines = [];
|
|
857
|
+
const unreadable = [];
|
|
858
|
+
let records = 0;
|
|
859
|
+
let cappedAnywhere = false;
|
|
860
|
+
for (const collection of collections) {
|
|
861
|
+
const issues = await validateCollectionRecords(collection, opts);
|
|
862
|
+
if (issues.length === 0) continue;
|
|
863
|
+
const unread = issues.filter((issue) => issue.file === STORE_UNREADABLE);
|
|
864
|
+
if (unread.length > 0) {
|
|
865
|
+
unreadable.push(`${collection.slug}: ${unread.map((issue) => issue.problem).join("; ")}`);
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
records += issues.length;
|
|
869
|
+
const capped = issues.length >= 25;
|
|
870
|
+
cappedAnywhere = cappedAnywhere || capped;
|
|
871
|
+
const count = capped ? `at least ${issues.length}` : String(issues.length);
|
|
872
|
+
const plural = issues.length === 1 ? "" : "s";
|
|
873
|
+
const note = capped ? " (the scan stops there)" : "";
|
|
874
|
+
lines.push(`${collection.slug}: ${count} existing record${plural} would not satisfy the schema about to be published${note}`);
|
|
875
|
+
for (const issue of issues.slice(0, MAX_LISTED_ISSUES)) lines.push(` - ${issue.file}: ${issue.problem}`);
|
|
876
|
+
if (issues.length > MAX_LISTED_ISSUES) lines.push(` - … and ${issues.length - MAX_LISTED_ISSUES} more`);
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
lines,
|
|
880
|
+
records,
|
|
881
|
+
capped: cappedAnywhere,
|
|
882
|
+
unreadable
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function schemasOf(collections) {
|
|
886
|
+
return collections.map((collection) => ({
|
|
887
|
+
cid: collection.slug,
|
|
888
|
+
schema: collection.schema
|
|
889
|
+
})).sort((left, right) => left.cid < right.cid ? -1 : left.cid > right.cid ? 1 : 0);
|
|
890
|
+
}
|
|
891
|
+
/** Read and parse `<root>/app.json`'s full declaration. */
|
|
892
|
+
async function readAuthored(root) {
|
|
893
|
+
let raw;
|
|
894
|
+
try {
|
|
895
|
+
raw = await readFile(path.join(root, APP_MANIFEST_FILE), "utf-8");
|
|
896
|
+
} catch (err) {
|
|
897
|
+
return {
|
|
898
|
+
ok: false,
|
|
899
|
+
problems: [`cannot read ${path.join(root, APP_MANIFEST_FILE)}: ${String(err)}`]
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
return parseAuthoredApp(raw);
|
|
903
|
+
}
|
|
904
|
+
/** Everything wrong with the declaration itself, publisher included. */
|
|
905
|
+
function declarationProblems(app, collections, handle) {
|
|
906
|
+
const problems = publishProblems(app, collections.map((collection) => ({
|
|
907
|
+
cid: collection.slug,
|
|
908
|
+
primaryKey: collection.schema.primaryKey
|
|
909
|
+
})), handle.email);
|
|
910
|
+
if (app.owner !== void 0 && app.owner !== handle.uid) problems.push(`app.json declares owner "${app.owner}", which is not your uid (${handle.uid}). \`owner\` is stamped by publish and carried forward unchanged afterwards — remove it from app.json rather than maintaining it by hand.`);
|
|
911
|
+
return problems;
|
|
912
|
+
}
|
|
913
|
+
/** Put the three kinds of document, in the order the rules require, and turn a
|
|
914
|
+
* rejected write into the result type instead of letting it escape.
|
|
915
|
+
*
|
|
916
|
+
* Returns null when everything was written; a failure result otherwise.
|
|
917
|
+
*
|
|
918
|
+
* A raw rejection here would reach the agent as a tool crash rather than the
|
|
919
|
+
* actionable text this tool promises. But "actionable" is a strong claim for
|
|
920
|
+
* a half-finished publish, so the message ENUMERATES what landed rather than
|
|
921
|
+
* summarising it: the order is app → every schema → config, and a summary
|
|
922
|
+
* written for one failure point is wrong at the others. Saying "the roster
|
|
923
|
+
* and configuration are live" after a SCHEMA write failed names a config
|
|
924
|
+
* document this publish never wrote — which still holds whatever the last
|
|
925
|
+
* publish left, and is exactly the state the caller is trying to repair. */
|
|
926
|
+
async function writeDocuments(handle, aid, published) {
|
|
927
|
+
const steps = [
|
|
928
|
+
{
|
|
929
|
+
what: `the app document (apps/${aid})`,
|
|
930
|
+
run: () => handle.docs.set(APPS_COLLECTION, aid, published.app)
|
|
931
|
+
},
|
|
932
|
+
...published.schemas.map(({ cid, doc }) => ({
|
|
933
|
+
what: `the published schema for '${cid}'`,
|
|
934
|
+
run: () => handle.docs.set(appSchemasPath(aid), cid, doc)
|
|
935
|
+
})),
|
|
936
|
+
{
|
|
937
|
+
what: `the public config document (apps/${aid}/config/${PUBLIC_CONFIG_DOC})`,
|
|
938
|
+
run: () => handle.docs.set(appConfigPath(aid), PUBLIC_CONFIG_DOC, published.config)
|
|
939
|
+
}
|
|
940
|
+
];
|
|
941
|
+
const landed = [];
|
|
942
|
+
for (const [index, step] of steps.entries()) try {
|
|
943
|
+
await step.run();
|
|
944
|
+
landed.push(step.what);
|
|
945
|
+
} catch (err) {
|
|
946
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
947
|
+
return {
|
|
948
|
+
ok: false,
|
|
949
|
+
partial: index > 0,
|
|
950
|
+
problems: [`publish failed while writing ${step.what}: ${reason}`, ...partialState(landed, [step.what, ...steps.slice(index + 1).map((rest) => rest.what)])]
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
/** What is live and what is not, listed rather than summarised.
|
|
956
|
+
*
|
|
957
|
+
* Two facts, and both matter for the repair: a document this publish wrote is
|
|
958
|
+
* live NOW, and a document it did not write still holds what the LAST publish
|
|
959
|
+
* left — which is not the same as being absent, and not the same as matching
|
|
960
|
+
* the declaration that was just half-applied. */
|
|
961
|
+
function partialState(landed, notWritten) {
|
|
962
|
+
const repair = "Publishing again is the repair: the write is idempotent, and it re-does every step, including the ones that did land.";
|
|
963
|
+
if (landed.length === 0) return [`Nothing was written. ${repair}`];
|
|
964
|
+
return [
|
|
965
|
+
`Written by this publish, and live now: ${landed.join("; ")}.`,
|
|
966
|
+
`NOT written: ${notWritten.join("; ")} — ${notWritten.length === 1 ? "it still holds" : "they still hold"} whatever the previous publish left.`,
|
|
967
|
+
repair
|
|
968
|
+
];
|
|
969
|
+
}
|
|
970
|
+
/** Publish this repository's declaration to its app.
|
|
971
|
+
*
|
|
972
|
+
* Everything variable is a parameter or comes from the host binding, so the
|
|
973
|
+
* whole path is exercisable against an in-memory `FirestoreDocs` with no
|
|
974
|
+
* network and no API key — which is the only way the conversion table gets
|
|
975
|
+
* tested as a table. */
|
|
976
|
+
async function publishApp(opts = {}) {
|
|
977
|
+
const root = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
978
|
+
const handle = firestoreHandle();
|
|
979
|
+
if (!handle) return {
|
|
980
|
+
ok: false,
|
|
981
|
+
partial: false,
|
|
982
|
+
problems: ["publish needs a signed-in Firestore session: connect remote-host first. Publishing writes the app's roster and configuration as the app's owner, which is an authenticated write."]
|
|
983
|
+
};
|
|
984
|
+
const authored = await readAuthored(root);
|
|
985
|
+
if (!authored.ok) return {
|
|
986
|
+
...authored,
|
|
987
|
+
partial: false
|
|
988
|
+
};
|
|
989
|
+
const collections = await sharedCollections(opts, root);
|
|
990
|
+
const problems = declarationProblems(authored.app, collections, handle);
|
|
991
|
+
if (problems.length > 0) return {
|
|
992
|
+
ok: false,
|
|
993
|
+
partial: false,
|
|
994
|
+
problems
|
|
995
|
+
};
|
|
996
|
+
const issues = await recordProblems(collections, {
|
|
997
|
+
...opts,
|
|
998
|
+
workspaceRoot: root
|
|
999
|
+
});
|
|
1000
|
+
if (issues.unreadable.length > 0) return {
|
|
1001
|
+
ok: false,
|
|
1002
|
+
partial: false,
|
|
1003
|
+
problems: [...issues.unreadable, "publish stopped: the live records could not be read, so nothing checked whether the schemas about to be published still fit them. This is not something `confirm` overrides — confirming means accepting a known breakage, and here there is no reading at all. Fix the access (or the connection) and publish again."]
|
|
1004
|
+
};
|
|
1005
|
+
if (issues.records > 0 && opts.confirm !== true) return {
|
|
1006
|
+
ok: false,
|
|
1007
|
+
partial: false,
|
|
1008
|
+
problems: [...issues.lines, "publish stopped: these records are live and members are reading them. Migrate them first, or re-run with confirm to publish the schema anyway and repair the records afterwards."]
|
|
1009
|
+
};
|
|
1010
|
+
return writePublished(authored.app, collections, handle, opts, root, issues);
|
|
1011
|
+
}
|
|
1012
|
+
/** The write half: stamp, project, and put the documents in the order the
|
|
1013
|
+
* rules require. Split from the gate above so neither half hides the other —
|
|
1014
|
+
* everything up to here can refuse, and nothing from here on does. */
|
|
1015
|
+
async function writePublished(authored, collections, handle, opts, root, issues) {
|
|
1016
|
+
const { aid } = authored;
|
|
1017
|
+
let existing;
|
|
1018
|
+
try {
|
|
1019
|
+
existing = await handle.docs.get(APPS_COLLECTION, aid);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
return {
|
|
1022
|
+
ok: false,
|
|
1023
|
+
partial: false,
|
|
1024
|
+
problems: [`publish failed while reading the current app document (apps/${aid}): ${err instanceof Error ? err.message : String(err)}`, "Nothing was written. Publishing again is safe — this read only decides whether the app is created or updated."]
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
const stampSource = await (opts.resolveCommit ?? gitStamp)(root);
|
|
1028
|
+
const stamp = {
|
|
1029
|
+
uid: handle.uid,
|
|
1030
|
+
email: handle.email,
|
|
1031
|
+
publishedAt: (opts.now ?? Date.now)(),
|
|
1032
|
+
commit: stampSource.commit
|
|
1033
|
+
};
|
|
1034
|
+
const existingApp = isRecord(existing) ? existing : null;
|
|
1035
|
+
const published = projectApp(authored, schemasOf(collections), stamp, existingApp);
|
|
1036
|
+
if (stampSource.dirty === true) published.app.publishedDirty = true;
|
|
1037
|
+
const written = await writeDocuments(handle, aid, published);
|
|
1038
|
+
if (written !== null) return written;
|
|
1039
|
+
return {
|
|
1040
|
+
ok: true,
|
|
1041
|
+
aid,
|
|
1042
|
+
cids: published.schemas.map((entry) => entry.cid),
|
|
1043
|
+
created: existingApp === null,
|
|
1044
|
+
commit: stamp.commit,
|
|
1045
|
+
dirty: stampSource.dirty === true,
|
|
1046
|
+
recordIssues: issues.records,
|
|
1047
|
+
recordIssuesCapped: issues.capped,
|
|
1048
|
+
published
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
//#endregion
|
|
13
1052
|
//#region src/collection/server/skillAssets.ts
|
|
14
1053
|
/** Read a collection's custom-view HTML, path-safely. `viewFile` is a
|
|
15
1054
|
* schema-validated `views/*.html` path, resolved with realpath containment.
|
|
@@ -373,226 +1412,6 @@ async function runCollectionQuery(collection, query, opts = {}) {
|
|
|
373
1412
|
return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
|
|
374
1413
|
}
|
|
375
1414
|
//#endregion
|
|
376
|
-
//#region src/collection/core/recordZ.ts
|
|
377
|
-
/** The emptiness rule shared by `required` and the "only check present
|
|
378
|
-
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
379
|
-
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
380
|
-
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
381
|
-
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
382
|
-
function enforcedProblem(key, spec, value) {
|
|
383
|
-
const empty = isEmptyValue(value);
|
|
384
|
-
if (spec.required && empty) return `missing required field '${key}'`;
|
|
385
|
-
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
386
|
-
return null;
|
|
387
|
-
}
|
|
388
|
-
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
389
|
-
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
390
|
-
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
391
|
-
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
392
|
-
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
393
|
-
* which the day view can't place). `string`-backed types accept anything
|
|
394
|
-
* stringifiable; `ref` existence is out of scope. */
|
|
395
|
-
function strictTypeProblem(key, spec, value) {
|
|
396
|
-
switch (spec.type) {
|
|
397
|
-
case "number":
|
|
398
|
-
case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
399
|
-
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
400
|
-
case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
401
|
-
case "datetime": return parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
402
|
-
default: return null;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
406
|
-
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
407
|
-
* First row problem wins, prefixed with the row number so the fix is
|
|
408
|
-
* locatable. */
|
|
409
|
-
function strictTableProblem(key, spec, value) {
|
|
410
|
-
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
411
|
-
for (let index = 0; index < value.length; index++) {
|
|
412
|
-
const row = value[index];
|
|
413
|
-
if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
414
|
-
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
415
|
-
const subValue = row[subKey];
|
|
416
|
-
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
417
|
-
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return null;
|
|
421
|
-
}
|
|
422
|
-
/** First problem for one field's stored value under `tier`, or null.
|
|
423
|
-
* Enforced checks always run (and their messages never vary by tier — the
|
|
424
|
-
* scan and the write gate must agree on them); strict adds the per-type
|
|
425
|
-
* layer on present values only. */
|
|
426
|
-
function recordFieldProblem(key, spec, value, tier) {
|
|
427
|
-
const enforced = enforcedProblem(key, spec, value);
|
|
428
|
-
if (enforced || tier === "enforced") return enforced;
|
|
429
|
-
if (isEmptyValue(value)) return null;
|
|
430
|
-
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
431
|
-
return strictTypeProblem(key, spec, value);
|
|
432
|
-
}
|
|
433
|
-
var compiled = /* @__PURE__ */ new WeakMap();
|
|
434
|
-
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
435
|
-
* Loose object: unknown keys are allowed and any declared key may be
|
|
436
|
-
* absent (records are user files, not parse-and-rewrite targets —
|
|
437
|
-
* callers validate, they never persist the parse output). The checks run
|
|
438
|
-
* as ONE object-level refine iterating fields in declaration order —
|
|
439
|
-
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
440
|
-
* must still reach the required check", and the single loop keeps the
|
|
441
|
-
* first reported issue identical to the historical first-problem-wins
|
|
442
|
-
* contract. */
|
|
443
|
-
function compileRecordZ(schema, tier) {
|
|
444
|
-
const cached = compiled.get(schema)?.[tier];
|
|
445
|
-
if (cached) return cached;
|
|
446
|
-
const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
|
|
447
|
-
const validator = z.looseObject({}).superRefine((record, ctx) => {
|
|
448
|
-
for (const [key, spec] of stored) {
|
|
449
|
-
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
450
|
-
if (problem) ctx.addIssue({
|
|
451
|
-
code: "custom",
|
|
452
|
-
message: problem,
|
|
453
|
-
path: [key]
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
});
|
|
457
|
-
const entry = compiled.get(schema) ?? {};
|
|
458
|
-
entry[tier] = validator;
|
|
459
|
-
compiled.set(schema, entry);
|
|
460
|
-
return validator;
|
|
461
|
-
}
|
|
462
|
-
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
463
|
-
* issue per record keeps the report short and the fix obvious (the
|
|
464
|
-
* historical contract of `validateRecordObject`). */
|
|
465
|
-
function firstRecordProblem(record, schema, tier) {
|
|
466
|
-
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
467
|
-
if (result.success) return null;
|
|
468
|
-
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
469
|
-
}
|
|
470
|
-
//#endregion
|
|
471
|
-
//#region src/collection/server/validate.ts
|
|
472
|
-
var MAX_ISSUES = 25;
|
|
473
|
-
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
474
|
-
* ones that won't load or violate the schema. An empty list means every
|
|
475
|
-
* record is fine. */
|
|
476
|
-
/** List entries under the data dir, guarding realpath containment (against a
|
|
477
|
-
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
478
|
-
* missing dir as empty while surfacing real I/O faults. */
|
|
479
|
-
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
480
|
-
if (!isContainedInRoot(dataDir, workspaceRoot)) {
|
|
481
|
-
log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
482
|
-
return [];
|
|
483
|
-
}
|
|
484
|
-
try {
|
|
485
|
-
return await readdir(dataDir);
|
|
486
|
-
} catch (err) {
|
|
487
|
-
if (isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
488
|
-
throw err;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
async function validateCollectionRecords(collection, opts = {}) {
|
|
492
|
-
if (collection.schema.dataSource !== void 0) return [];
|
|
493
|
-
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
494
|
-
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
495
|
-
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
496
|
-
const issues = [];
|
|
497
|
-
for (const name of entries.sort()) {
|
|
498
|
-
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
499
|
-
if (issues.length >= MAX_ISSUES) break;
|
|
500
|
-
const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
|
|
501
|
-
if (issue) issues.push(issue);
|
|
502
|
-
}
|
|
503
|
-
return issues;
|
|
504
|
-
}
|
|
505
|
-
/** Store-backed twin of the file scan: list every record through the
|
|
506
|
-
* collection's store and lint it with the same "strict" report-only tier.
|
|
507
|
-
* A row the store can't even parse is invisible here (the store skips
|
|
508
|
-
* it), so the read/parse classifications of the file scan don't apply —
|
|
509
|
-
* schema violations are what this catches. `file` carries the record id
|
|
510
|
-
* (there is no per-record filename). */
|
|
511
|
-
async function validateStoreRecords(collection, opts) {
|
|
512
|
-
let items;
|
|
513
|
-
try {
|
|
514
|
-
items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
515
|
-
} catch (err) {
|
|
516
|
-
return [{
|
|
517
|
-
file: "(store)",
|
|
518
|
-
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
519
|
-
}];
|
|
520
|
-
}
|
|
521
|
-
const issues = [];
|
|
522
|
-
for (const item of items) {
|
|
523
|
-
if (issues.length >= MAX_ISSUES) break;
|
|
524
|
-
const itemId = fieldText(item[collection.schema.primaryKey]);
|
|
525
|
-
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
526
|
-
if (problem) issues.push({
|
|
527
|
-
file: itemId,
|
|
528
|
-
problem
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
return issues;
|
|
532
|
-
}
|
|
533
|
-
async function readRecordText(fullPath, name) {
|
|
534
|
-
try {
|
|
535
|
-
if (!(await lstat(fullPath)).isFile()) return {
|
|
536
|
-
file: name,
|
|
537
|
-
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
538
|
-
};
|
|
539
|
-
return { raw: await readFile(fullPath, "utf-8") };
|
|
540
|
-
} catch {
|
|
541
|
-
return {
|
|
542
|
-
file: name,
|
|
543
|
-
problem: "could not be read — skipped, won't appear"
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
548
|
-
* schema violation, or null when it's fine. */
|
|
549
|
-
async function inspectRecord(fullPath, name, schema) {
|
|
550
|
-
const read = await readRecordText(fullPath, name);
|
|
551
|
-
if ("problem" in read) return read;
|
|
552
|
-
let parsed;
|
|
553
|
-
try {
|
|
554
|
-
parsed = JSON.parse(read.raw);
|
|
555
|
-
} catch (err) {
|
|
556
|
-
return {
|
|
557
|
-
file: name,
|
|
558
|
-
problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
if (!isRecord(parsed)) return {
|
|
562
|
-
file: name,
|
|
563
|
-
problem: "not a JSON object — skipped, won't appear"
|
|
564
|
-
};
|
|
565
|
-
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
566
|
-
return problem ? {
|
|
567
|
-
file: name,
|
|
568
|
-
problem
|
|
569
|
-
} : null;
|
|
570
|
-
}
|
|
571
|
-
/** What a non-string primary key actually is, for the error message. Names the
|
|
572
|
-
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
573
|
-
* nothing about what is wrong. */
|
|
574
|
-
function describeIdType(value) {
|
|
575
|
-
if (value === null) return "null";
|
|
576
|
-
if (value === void 0) return "missing";
|
|
577
|
-
if (Array.isArray(value)) return "an array";
|
|
578
|
-
return `a ${typeof value}`;
|
|
579
|
-
}
|
|
580
|
-
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
581
|
-
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
582
|
-
* tiers), or null when it's fine. One issue per record keeps the report
|
|
583
|
-
* short and the fix obvious. Pure + exported so write paths
|
|
584
|
-
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
585
|
-
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
586
|
-
* would be) stored under. The default `"enforced"` tier keeps every
|
|
587
|
-
* write gate on the historical three checks; only pass `"strict"` from
|
|
588
|
-
* report-only surfaces. */
|
|
589
|
-
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
590
|
-
const idValue = record[schema.primaryKey];
|
|
591
|
-
if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
592
|
-
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
593
|
-
return firstRecordProblem(record, schema, tier);
|
|
594
|
-
}
|
|
595
|
-
//#endregion
|
|
596
1415
|
//#region src/collection/server/mutate.ts
|
|
597
1416
|
/** First problem with the submitted params, or null. Every declared param
|
|
598
1417
|
* is checked by the shared record-field validator; keys the action never
|
|
@@ -1022,7 +1841,8 @@ function deleteCollectionRefusalMessage(result) {
|
|
|
1022
1841
|
"user-scope": `collection '${slug}' is user-scope (~/.claude/skills/) and is read-only from MulmoClaude`,
|
|
1023
1842
|
preset: `collection '${slug}' is a preset (mc-*) and re-seeds on restart; unstar it from the catalog instead`,
|
|
1024
1843
|
"unsafe-data-path": `collection '${slug}' declares a dataPath outside its own data/${slug}/ subtree; refusing to delete`,
|
|
1025
|
-
"path-escape": `a directory for collection '${slug}' escapes the workspace
|
|
1844
|
+
"path-escape": `a directory for collection '${slug}' escapes the workspace`,
|
|
1845
|
+
"unsupported-backend": `collection '${slug}' is a shared collection — its records are documents of its app, which this delete can neither archive nor remove, and other members read the same documents. Removing its records first does NOT unlock it. To retire the whole app, a Firestore project administrator deletes it recursively (\`firebase firestore:delete "apps/<aid>" --recursive\`, children first); the app owner's client credentials cannot do it.`
|
|
1026
1846
|
}[result.kind];
|
|
1027
1847
|
}
|
|
1028
1848
|
async function pathExists(target) {
|
|
@@ -1078,6 +1898,10 @@ function isDataDirSafe(dataDir, slug, workspaceRoot) {
|
|
|
1078
1898
|
* `dataSource` collection has no record files to copy (its rows live in
|
|
1079
1899
|
* the external data file, which the delete never touches). */
|
|
1080
1900
|
function restoreRecordsStep(schema) {
|
|
1901
|
+
if (schema.storage?.type === "firestore") return `2. Records: NOT archived. This is a shared collection: its records are
|
|
1902
|
+
documents at \`apps/<aid>/collections/<cid>/items\`, which this delete did
|
|
1903
|
+
not touch or export. They are still there, and other members still read
|
|
1904
|
+
them.`;
|
|
1081
1905
|
if (schema.storage !== void 0) return `2. Records: copy the archived database file
|
|
1082
1906
|
\`${path.basename(schema.storage.path)}\` (next to this document) back to
|
|
1083
1907
|
\`${schema.storage.path}\` (workspace-relative, \`cp\`). It holds every
|
|
@@ -1136,9 +1960,16 @@ ${restoreRecordsStep(schema)}
|
|
|
1136
1960
|
|
|
1137
1961
|
- slug: \`${slug}\`
|
|
1138
1962
|
- title: ${schema.title}
|
|
1139
|
-
- dataPath: \`${schema.dataPath ?? (schema
|
|
1963
|
+
- dataPath: \`${schema.dataPath ?? recordLocationLabel(schema)}\`
|
|
1140
1964
|
`;
|
|
1141
1965
|
}
|
|
1966
|
+
/** Where a non-`dataPath` collection's records live, for the restore doc's
|
|
1967
|
+
* header line. */
|
|
1968
|
+
function recordLocationLabel(schema) {
|
|
1969
|
+
if (schema.storage?.type === "firestore") return "(storage) shared app";
|
|
1970
|
+
if (schema.storage !== void 0) return `(storage) ${schema.storage.path}`;
|
|
1971
|
+
return `(dataSource) ${schema.dataSource?.path}`;
|
|
1972
|
+
}
|
|
1142
1973
|
/** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
|
|
1143
1974
|
async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
1144
1975
|
const staging = stagingSkillDir(workspaceRoot, collection.slug);
|
|
@@ -1196,6 +2027,13 @@ async function deleteCollection(collection, opts = {}) {
|
|
|
1196
2027
|
kind: "preset",
|
|
1197
2028
|
slug
|
|
1198
2029
|
};
|
|
2030
|
+
if (collection.schema.storage?.type === "firestore") {
|
|
2031
|
+
log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
|
|
2032
|
+
return {
|
|
2033
|
+
kind: "unsupported-backend",
|
|
2034
|
+
slug
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
1199
2037
|
if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
|
|
1200
2038
|
log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
|
|
1201
2039
|
slug,
|
|
@@ -1813,6 +2651,32 @@ async function handleGetOntology(deps) {
|
|
|
1813
2651
|
collections
|
|
1814
2652
|
});
|
|
1815
2653
|
}
|
|
2654
|
+
/** Publish this repository's `app.json` + shared schemas to its Firestore app.
|
|
2655
|
+
*
|
|
2656
|
+
* Named as a whole-app action because it IS one: publish takes the repository
|
|
2657
|
+
* as its unit (one roster, one public config, every shared collection), so it
|
|
2658
|
+
* carries no `slug` and refusing one is not a limitation to work around.
|
|
2659
|
+
*
|
|
2660
|
+
* The reply is prose rather than a status code on purpose. This is the one
|
|
2661
|
+
* operation in the collection surface that changes what every member sees the
|
|
2662
|
+
* moment it lands, and the two things the caller has to relay — what will
|
|
2663
|
+
* break, and that `confirm` is how it proceeds anyway — are sentences, not
|
|
2664
|
+
* fields. */
|
|
2665
|
+
async function handlePublishApp(deps, confirm) {
|
|
2666
|
+
const result = await publishApp({
|
|
2667
|
+
...deps,
|
|
2668
|
+
confirm
|
|
2669
|
+
});
|
|
2670
|
+
if (!result.ok) {
|
|
2671
|
+
const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
|
|
2672
|
+
return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
|
|
2673
|
+
}
|
|
2674
|
+
const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
|
|
2675
|
+
const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
|
|
2676
|
+
const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
|
|
2677
|
+
const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
|
|
2678
|
+
return `${result.created ? "Created" : "Updated"} app '${result.aid}' and published ${result.cids.length} collection(s): ${result.cids.join(", ")}. Signed ${stamp}. The previous app document is kept in \`previousPublished\` for rollback.${forced}`;
|
|
2679
|
+
}
|
|
1816
2680
|
/** Return the collection-authoring reference (`collection-skills.md`),
|
|
1817
2681
|
* rendered by `renderSchemaDocs` — the full doc overflows the agent's
|
|
1818
2682
|
* per-result limit, so the default reply is the core guide + a table of
|
|
@@ -1921,7 +2785,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
1921
2785
|
written: true
|
|
1922
2786
|
});
|
|
1923
2787
|
}
|
|
1924
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
2788
|
+
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. `publishApp` publishes the whole repository — its `app.json` (member roster, public read/submit configuration) and every shared collection's schema — to the app's Firestore. It is the ONE operation here that changes what every member sees the moment it runs, and it cannot be undone by reverting a commit: publishing again is the only undo (the previous app document is kept as `previousPublished`). Call it when the user asks to publish, invite, or open an app; never as a follow-up to an edit the user did not ask you to ship. Its refusals are the product, not an obstacle — relay them verbatim, and do not work around one by rewriting `app.json` until the user has said what they actually want. If it reports live records the new schemas would break, show the user that list and ask before re-running with `confirm`. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
1925
2789
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
1926
2790
|
async function dispatchGetItems(collection, args, deps) {
|
|
1927
2791
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -1966,18 +2830,19 @@ async function dispatchManageCollection(deps, args) {
|
|
|
1966
2830
|
const action = typeof args.action === "string" ? args.action : "";
|
|
1967
2831
|
if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
|
|
1968
2832
|
if (action === "getOntology") return handleGetOntology(deps);
|
|
2833
|
+
if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
|
|
1969
2834
|
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
1970
2835
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
1971
2836
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
1972
2837
|
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
1973
|
-
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"
|
|
2838
|
+
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", \"putSchema\", or \"publishApp\".";
|
|
1974
2839
|
const collection = await loadCollection(slug, deps);
|
|
1975
2840
|
if (!collection) return unknownCollection(slug);
|
|
1976
2841
|
return dispatchRecordAction(action, collection, args, deps);
|
|
1977
2842
|
}
|
|
1978
2843
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
1979
2844
|
name: "manageCollection",
|
|
1980
|
-
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
2845
|
+
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. publishApp publishes the repository's app.json + shared schemas to Firestore, where every member sees them immediately -- it refuses declarations that would be silently permissive or silently deny everyone, and refuses to publish over records the new schemas would break unless `confirm` is set. Prefer it over raw file I/O on collections.",
|
|
1981
2846
|
inputSchema: {
|
|
1982
2847
|
type: "object",
|
|
1983
2848
|
properties: {
|
|
@@ -1991,7 +2856,8 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
1991
2856
|
"getOntology",
|
|
1992
2857
|
"schemaDocs",
|
|
1993
2858
|
"getSchema",
|
|
1994
|
-
"putSchema"
|
|
2859
|
+
"putSchema",
|
|
2860
|
+
"publishApp"
|
|
1995
2861
|
],
|
|
1996
2862
|
description: "What to do."
|
|
1997
2863
|
},
|
|
@@ -2027,6 +2893,10 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2027
2893
|
type: "object",
|
|
2028
2894
|
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
2029
2895
|
},
|
|
2896
|
+
confirm: {
|
|
2897
|
+
type: "boolean",
|
|
2898
|
+
description: "publishApp: publish even though existing records fail the schemas being published. Omit it first — the refusal lists what would break, and that list is the thing to show the user before asking."
|
|
2899
|
+
},
|
|
2030
2900
|
topic: {
|
|
2031
2901
|
type: "string",
|
|
2032
2902
|
description: "schemaDocs: fetch one section of the reference by heading (case-insensitive substring — e.g. \"field types\", \"kanban\", \"calendar\", \"dataSource\"). Omit for the core authoring guide plus a table of contents of every section; \"all\" returns the full document (large — it can exceed your tool-result limit)."
|
|
@@ -2048,6 +2918,6 @@ function makeManageCollectionTool(deps = {}) {
|
|
|
2048
2918
|
};
|
|
2049
2919
|
}
|
|
2050
2920
|
//#endregion
|
|
2051
|
-
export {
|
|
2921
|
+
export { readSkillTemplate as A, APPS_COLLECTION as B, enrichItems as C, promptPathsFor as D, buildCollectionActionSeedPrompt as E, validateRecordObject as F, APP_ROLES as G, appConfigPath as H, compileRecordZ as I, AuthoredAppZ as K, recordFieldProblem as L, MAX_RECORD_ISSUES as M, STORE_UNREADABLE as N, readCustomViewHtml as O, validateCollectionRecords as P, bindsSubmitterIdentity as R, runCollectionQuery as S, buildActionSeedPrompt as T, appSchemasPath as U, PUBLIC_CONFIG_DOC as V, projectApp as W, computeCollectionIcon as _, deleteCollection as a, applyMutateAction as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, publishApp as j, readCustomViewI18n as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, parseAuthoredApp as q, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, buildWorkspaceOntology as v, runQueryOverRows as w, firstMutateParamProblem as x, schemaRelations as y, publishProblems as z };
|
|
2052
2922
|
|
|
2053
|
-
//# sourceMappingURL=server-
|
|
2923
|
+
//# sourceMappingURL=server-B48Jyxcj.js.map
|