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