@mulmoclaude/core 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/host.d.ts +11 -0
- package/dist/collection/server/index.cjs +72 -59
- package/dist/collection/server/index.d.ts +4 -0
- 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 +15 -15
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +2 -2
- 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-DdHaX_Uu.js → server-B48Jyxcj.js} +1079 -228
- package/dist/server-B48Jyxcj.js.map +1 -0
- package/dist/{server-9mFaGLdq.cjs → server-CWZyg8fn.cjs} +1318 -389
- package/dist/server-CWZyg8fn.cjs.map +1 -0
- package/dist/{discovery-Cw5xWzZD.cjs → store-5_P_NsGa.cjs} +1947 -1947
- package/dist/store-5_P_NsGa.cjs.map +1 -0
- package/dist/{discovery-CdkaURVY.js → store-_61sO8K8.js} +1948 -1948
- 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 +1 -1
- package/dist/discovery-CdkaURVY.js.map +0 -1
- package/dist/discovery-Cw5xWzZD.cjs.map +0 -1
- package/dist/server-9mFaGLdq.cjs.map +0 -1
- package/dist/server-DdHaX_Uu.js.map +0 -1
|
@@ -4,7 +4,7 @@ const require_root = require("./root-rPH6FGDT.cjs");
|
|
|
4
4
|
const require_relPath = require("./relPath-CTAjGdCL.cjs");
|
|
5
5
|
const require_calendarGrid = require("./calendarGrid-DGILaVxI.cjs");
|
|
6
6
|
const require_promptSafety = require("./promptSafety-NNGViiCr.cjs");
|
|
7
|
-
const
|
|
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
|
-
await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
|
|
271
|
-
if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT")
|
|
272
|
-
path: jsonlPath,
|
|
273
|
-
error: String(err)
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
//#endregion
|
|
279
|
-
//#region src/collection/server/derive.ts
|
|
280
|
-
async function loadTarget(slug, opts) {
|
|
281
|
-
const target = await require_discovery.loadCollection(slug, opts);
|
|
282
|
-
if (!target) return null;
|
|
283
|
-
const items = await require_discovery.storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
|
|
284
|
-
const byId = {};
|
|
285
|
-
for (const item of items) {
|
|
286
|
-
const itemId = item[target.schema.primaryKey];
|
|
287
|
-
if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = require_promptSafety.deriveAll(target.schema, item, {});
|
|
288
|
-
}
|
|
289
|
-
return {
|
|
290
|
-
schema: target.schema,
|
|
291
|
-
byId
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
/** Load every ref/embed target and backlink source collection once.
|
|
295
|
-
* Unknown / unloadable targets are simply absent — downstream derefs
|
|
296
|
-
* resolve to null (em-dash) and backlinks to an empty row set, the
|
|
297
|
-
* same fail-soft the UI renders. */
|
|
298
|
-
async function loadLinkedTargets(schema, opts) {
|
|
299
|
-
const slugs = [.../* @__PURE__ */ new Set([
|
|
300
|
-
...require_promptSafety.uniqueRefTargets(schema),
|
|
301
|
-
...require_promptSafety.uniqueEmbedTargets(schema),
|
|
302
|
-
...require_promptSafety.uniqueBacklinkSources(schema)
|
|
303
|
-
])];
|
|
304
|
-
const loaded = {};
|
|
305
|
-
for (const slug of slugs) {
|
|
306
|
-
const target = await loadTarget(slug, opts);
|
|
307
|
-
if (target) loaded[slug] = target;
|
|
308
|
-
}
|
|
309
|
-
return loaded;
|
|
310
|
-
}
|
|
311
|
-
function toRefRecords(linked) {
|
|
312
|
-
return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
|
|
313
|
-
}
|
|
314
|
-
/** The matching source rows for one `backlinks` field, projected to the
|
|
315
|
-
* source primaryKey + `display` columns — so getItems on a
|
|
316
|
-
* heavily-referenced record stays a summary, not a dump of the source
|
|
317
|
-
* collection. Missing source ⇒ [] (fail-soft). The rows come from the
|
|
318
|
-
* DERIVED source records (`byId`), so `display`/`filter` on a derived
|
|
319
|
-
* source column (an invoice `total`) works. */
|
|
320
|
-
function projectBacklinks(field, schema, enriched, linked) {
|
|
321
|
-
const source = linked[field.from];
|
|
322
|
-
if (!source) return [];
|
|
323
|
-
const selfId = require_calendarGrid.fieldText(enriched[schema.primaryKey]);
|
|
324
|
-
return require_promptSafety.backlinkRows(field, selfId, Object.values(source.byId)).map((row) => require_promptSafety.projectBacklinkRow(row, field.display, source.schema.primaryKey));
|
|
325
|
-
}
|
|
326
|
-
/** Project the computed (never-stored) field kinds onto one derived
|
|
327
|
-
* record: `toggle` → boolean off its enum, `embed` → the target record
|
|
328
|
-
* (fixed `id` or per-record `idField`), or null when missing,
|
|
329
|
-
* `backlinks` → the matching source rows (see `projectBacklinks`). */
|
|
330
|
-
function projectComputed(schema, enriched, linked) {
|
|
331
|
-
for (const [key, field] of Object.entries(schema.fields)) {
|
|
332
|
-
if (field.type === "toggle" && field.field) enriched[key] = require_calendarGrid.fieldText(enriched[field.field]) === field.onValue;
|
|
333
|
-
if (field.type === "embed" && field.to) {
|
|
334
|
-
const targetId = require_calendarGrid.embedTargetId(field, enriched);
|
|
335
|
-
const target = require_promptSafety.ownProp(linked, field.to);
|
|
336
|
-
enriched[key] = targetId && target && require_promptSafety.ownProp(target.byId, targetId) || null;
|
|
337
|
-
}
|
|
338
|
-
if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
|
|
339
|
-
}
|
|
340
|
-
return enriched;
|
|
341
|
-
}
|
|
342
|
-
/** Resolve every rollup field onto a COPY of the record, BEFORE the
|
|
343
|
-
* formula pass — a `derived` formula may reference rollup values as
|
|
344
|
-
* plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
|
|
345
|
-
* machinery as backlinks, collapsed to a number: an unresolvable source
|
|
346
|
-
* is null (a formula reading it fails soft to em-dash); an empty match
|
|
347
|
-
* set is a real 0. Returns `record` unchanged when the schema declares
|
|
348
|
-
* no rollups. */
|
|
349
|
-
function projectRollups(schema, record, linked) {
|
|
350
|
-
let out = record;
|
|
351
|
-
for (const [key, field] of Object.entries(schema.fields)) {
|
|
352
|
-
if (field.type !== "rollup") continue;
|
|
353
|
-
if (out === record) out = { ...record };
|
|
354
|
-
const source = linked[field.from];
|
|
355
|
-
const selfId = require_calendarGrid.fieldText(record[schema.primaryKey]);
|
|
356
|
-
out[key] = source ? require_promptSafety.rollupValue(field, selfId, Object.values(source.byId)) : null;
|
|
357
|
-
}
|
|
358
|
-
return out;
|
|
359
|
-
}
|
|
360
|
-
/** Enrich records with every host-computed field: derived formulas
|
|
361
|
-
* evaluated (cross-collection derefs included), toggles projected,
|
|
362
|
-
* embeds resolved. Loads each linked collection ONCE per call. Input
|
|
363
|
-
* records are not mutated. */
|
|
364
|
-
async function enrichItems(collection, items, opts = {}) {
|
|
365
|
-
const { schema } = collection;
|
|
366
|
-
const linked = await loadLinkedTargets(schema, opts);
|
|
367
|
-
const refRecords = toRefRecords(linked);
|
|
368
|
-
return items.map((item) => projectComputed(schema, require_promptSafety.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
|
|
369
|
-
}
|
|
370
|
-
//#endregion
|
|
371
|
-
//#region src/collection/server/queryRunner.ts
|
|
372
|
-
async function runCollectionQuery(collection, query, opts = {}) {
|
|
373
|
-
const store = require_discovery.storeFor(collection, { workspaceRoot: opts.workspaceRoot });
|
|
374
|
-
if (store.query) return store.query(query);
|
|
375
|
-
return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
|
|
376
|
-
}
|
|
377
|
-
//#endregion
|
|
378
|
-
//#region src/collection/core/recordZ.ts
|
|
379
|
-
/** The emptiness rule shared by `required` and the "only check present
|
|
380
|
-
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
381
|
-
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
382
|
-
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
383
|
-
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
384
|
-
function enforcedProblem(key, spec, value) {
|
|
385
|
-
const empty = isEmptyValue(value);
|
|
386
|
-
if (spec.required && empty) return `missing required field '${key}'`;
|
|
387
|
-
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
391
|
-
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
392
|
-
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
393
|
-
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
394
|
-
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
395
|
-
* which the day view can't place). `string`-backed types accept anything
|
|
396
|
-
* stringifiable; `ref` existence is out of scope. */
|
|
397
|
-
function strictTypeProblem(key, spec, value) {
|
|
398
|
-
switch (spec.type) {
|
|
399
|
-
case "number":
|
|
400
|
-
case "money": return Number.isFinite(require_promptSafety.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
401
|
-
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
402
|
-
case "date": return require_calendarGrid.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
403
|
-
case "datetime": return require_calendarGrid.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
404
|
-
default: return null;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
408
|
-
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
409
|
-
* First row problem wins, prefixed with the row number so the fix is
|
|
410
|
-
* locatable. */
|
|
411
|
-
function strictTableProblem(key, spec, value) {
|
|
412
|
-
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
413
|
-
for (let index = 0; index < value.length; index++) {
|
|
414
|
-
const row = value[index];
|
|
415
|
-
if (!require_dist.isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
416
|
-
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
417
|
-
const subValue = row[subKey];
|
|
418
|
-
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
419
|
-
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
return null;
|
|
423
|
-
}
|
|
424
|
-
/** First problem for one field's stored value under `tier`, or null.
|
|
425
|
-
* Enforced checks always run (and their messages never vary by tier — the
|
|
426
|
-
* scan and the write gate must agree on them); strict adds the per-type
|
|
427
|
-
* layer on present values only. */
|
|
428
|
-
function recordFieldProblem(key, spec, value, tier) {
|
|
429
|
-
const enforced = enforcedProblem(key, spec, value);
|
|
430
|
-
if (enforced || tier === "enforced") return enforced;
|
|
431
|
-
if (isEmptyValue(value)) return null;
|
|
432
|
-
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
433
|
-
return strictTypeProblem(key, spec, value);
|
|
434
|
-
}
|
|
435
|
-
var compiled = /* @__PURE__ */ new WeakMap();
|
|
436
|
-
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
437
|
-
* Loose object: unknown keys are allowed and any declared key may be
|
|
438
|
-
* absent (records are user files, not parse-and-rewrite targets —
|
|
439
|
-
* callers validate, they never persist the parse output). The checks run
|
|
440
|
-
* as ONE object-level refine iterating fields in declaration order —
|
|
441
|
-
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
442
|
-
* must still reach the required check", and the single loop keeps the
|
|
443
|
-
* first reported issue identical to the historical first-problem-wins
|
|
444
|
-
* contract. */
|
|
445
|
-
function compileRecordZ(schema, tier) {
|
|
446
|
-
const cached = compiled.get(schema)?.[tier];
|
|
447
|
-
if (cached) return cached;
|
|
448
|
-
const stored = Object.entries(schema.fields).filter(([, spec]) => !require_calendarGrid.COMPUTED_TYPES.has(spec.type));
|
|
449
|
-
const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
|
|
450
|
-
for (const [key, spec] of stored) {
|
|
451
|
-
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
452
|
-
if (problem) ctx.addIssue({
|
|
453
|
-
code: "custom",
|
|
454
|
-
message: problem,
|
|
455
|
-
path: [key]
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
const entry = compiled.get(schema) ?? {};
|
|
460
|
-
entry[tier] = validator;
|
|
461
|
-
compiled.set(schema, entry);
|
|
462
|
-
return validator;
|
|
463
|
-
}
|
|
464
|
-
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
465
|
-
* issue per record keeps the report short and the fix obvious (the
|
|
466
|
-
* historical contract of `validateRecordObject`). */
|
|
467
|
-
function firstRecordProblem(record, schema, tier) {
|
|
468
|
-
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
469
|
-
if (result.success) return null;
|
|
470
|
-
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
1309
|
+
await (0, node_fs_promises.unlink)(jsonlPath).catch((err) => {
|
|
1310
|
+
if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") require_store.log.warn("collections", "temp JSONL cleanup failed", {
|
|
1311
|
+
path: jsonlPath,
|
|
1312
|
+
error: String(err)
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
471
1316
|
}
|
|
472
1317
|
//#endregion
|
|
473
|
-
//#region src/collection/server/
|
|
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,
|
|
@@ -1043,7 +1862,7 @@ function todayStamp() {
|
|
|
1043
1862
|
/** Every directory the delete will touch must resolve under the
|
|
1044
1863
|
* workspace root — guards against a symlinked ancestor escaping it. */
|
|
1045
1864
|
function deleteTargets(collection, workspaceRoot) {
|
|
1046
|
-
const staging =
|
|
1865
|
+
const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1047
1866
|
return [
|
|
1048
1867
|
...staging === null ? [] : [staging],
|
|
1049
1868
|
collection.skillDir,
|
|
@@ -1155,12 +1974,12 @@ function recordLocationLabel(schema) {
|
|
|
1155
1974
|
}
|
|
1156
1975
|
/** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
|
|
1157
1976
|
async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
1158
|
-
const staging =
|
|
1977
|
+
const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1159
1978
|
const skillSrc = staging !== null && await pathExists(staging) ? staging : collection.skillDir;
|
|
1160
1979
|
await (0, node_fs_promises.cp)(skillSrc, node_path.default.join(archiveDir, "skill"), { recursive: true });
|
|
1161
1980
|
if (await pathExists(collection.dataDir)) await (0, node_fs_promises.cp)(collection.dataDir, node_path.default.join(archiveDir, "records"), { recursive: true });
|
|
1162
1981
|
if (collection.storageFile !== void 0 && await pathExists(collection.storageFile)) {
|
|
1163
|
-
const checkpointed = await
|
|
1982
|
+
const checkpointed = await require_store.checkpointSqliteDatabase(collection.storageFile);
|
|
1164
1983
|
await (0, node_fs_promises.cp)(collection.storageFile, node_path.default.join(archiveDir, node_path.default.basename(collection.storageFile)));
|
|
1165
1984
|
if (!checkpointed) for (const suffix of [
|
|
1166
1985
|
"-wal",
|
|
@@ -1177,7 +1996,7 @@ async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
|
1177
1996
|
* is a no-op; the now-empty data parent (`data/<slug>/` after its
|
|
1178
1997
|
* `items/` is gone) is swept too, but only when empty. */
|
|
1179
1998
|
async function removeLocations(collection, workspaceRoot) {
|
|
1180
|
-
const staging =
|
|
1999
|
+
const staging = require_store.stagingSkillDir(workspaceRoot, collection.slug);
|
|
1181
2000
|
if (staging !== null) await (0, node_fs_promises.rm)(staging, {
|
|
1182
2001
|
recursive: true,
|
|
1183
2002
|
force: true
|
|
@@ -1201,24 +2020,24 @@ async function removeLocations(collection, workspaceRoot) {
|
|
|
1201
2020
|
}
|
|
1202
2021
|
async function deleteCollection(collection, opts = {}) {
|
|
1203
2022
|
const { slug } = collection;
|
|
1204
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
2023
|
+
const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
|
|
1205
2024
|
if (collection.source === "user") return {
|
|
1206
2025
|
kind: "user-scope",
|
|
1207
2026
|
slug
|
|
1208
2027
|
};
|
|
1209
|
-
if (
|
|
2028
|
+
if (require_store.isPresetSlug(slug)) return {
|
|
1210
2029
|
kind: "preset",
|
|
1211
2030
|
slug
|
|
1212
2031
|
};
|
|
1213
2032
|
if (collection.schema.storage?.type === "firestore") {
|
|
1214
|
-
|
|
2033
|
+
require_store.log.warn("collections", "deleteCollection refused: a shared collection's records can be neither archived nor removed here", { slug });
|
|
1215
2034
|
return {
|
|
1216
2035
|
kind: "unsupported-backend",
|
|
1217
2036
|
slug
|
|
1218
2037
|
};
|
|
1219
2038
|
}
|
|
1220
2039
|
if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
|
|
1221
|
-
|
|
2040
|
+
require_store.log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
|
|
1222
2041
|
slug,
|
|
1223
2042
|
dataDir: collection.dataDir
|
|
1224
2043
|
});
|
|
@@ -1227,19 +2046,19 @@ async function deleteCollection(collection, opts = {}) {
|
|
|
1227
2046
|
slug
|
|
1228
2047
|
};
|
|
1229
2048
|
}
|
|
1230
|
-
if (deleteTargets(collection, workspaceRoot).some((target) => !
|
|
1231
|
-
|
|
2049
|
+
if (deleteTargets(collection, workspaceRoot).some((target) => !require_store.isContainedInRoot(target, workspaceRoot))) {
|
|
2050
|
+
require_store.log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
|
|
1232
2051
|
return {
|
|
1233
2052
|
kind: "path-escape",
|
|
1234
2053
|
slug
|
|
1235
2054
|
};
|
|
1236
2055
|
}
|
|
1237
|
-
const archiveRel = node_path.default.join(
|
|
2056
|
+
const archiveRel = node_path.default.join(require_store.archiveDir(), `${opts.dateStamp ?? todayStamp()}-${(0, node_crypto.randomUUID)()}`);
|
|
1238
2057
|
const archiveDir$1 = node_path.default.join(workspaceRoot, archiveRel);
|
|
1239
2058
|
await (0, node_fs_promises.mkdir)(archiveDir$1, { recursive: true });
|
|
1240
2059
|
await writeArchive(collection, archiveDir$1, workspaceRoot);
|
|
1241
2060
|
await removeLocations(collection, workspaceRoot);
|
|
1242
|
-
|
|
2061
|
+
require_store.log.info("collections", "collection deleted + archived", {
|
|
1243
2062
|
slug,
|
|
1244
2063
|
archive: archiveRel
|
|
1245
2064
|
});
|
|
@@ -1269,7 +2088,7 @@ async function fileExists(target) {
|
|
|
1269
2088
|
* deletes agree on both layouts. */
|
|
1270
2089
|
async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
1271
2090
|
if (collection.source !== "project") return collection.skillDir;
|
|
1272
|
-
const staging =
|
|
2091
|
+
const staging = require_store.stagingSkillDir(workspaceRoot, safeSlug);
|
|
1273
2092
|
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) return staging;
|
|
1274
2093
|
return collection.skillDir;
|
|
1275
2094
|
}
|
|
@@ -1279,11 +2098,11 @@ async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
|
1279
2098
|
* project collection (no staging mirror) doesn't have an empty staging tree
|
|
1280
2099
|
* materialised by a side effect of the delete. */
|
|
1281
2100
|
async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
|
|
1282
|
-
const active = node_path.default.join(collection.skillDir,
|
|
2101
|
+
const active = node_path.default.join(collection.skillDir, require_store.SCHEMA_FILE);
|
|
1283
2102
|
if (collection.source !== "project") return [active];
|
|
1284
|
-
const staging =
|
|
2103
|
+
const staging = require_store.stagingSkillDir(workspaceRoot, safeSlug);
|
|
1285
2104
|
const targets = [];
|
|
1286
|
-
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging,
|
|
2105
|
+
if (staging !== null && await fileExists(node_path.default.join(staging, "schema.json"))) targets.push(node_path.default.join(staging, require_store.SCHEMA_FILE));
|
|
1287
2106
|
targets.push(active);
|
|
1288
2107
|
return targets;
|
|
1289
2108
|
}
|
|
@@ -1302,7 +2121,7 @@ async function unlinkIfPresent(target) {
|
|
|
1302
2121
|
* preserved verbatim. */
|
|
1303
2122
|
async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
|
|
1304
2123
|
const base = await canonicalBase(collection, workspaceRoot, safeSlug);
|
|
1305
|
-
const canonical = node_path.default.join(base,
|
|
2124
|
+
const canonical = node_path.default.join(base, require_store.SCHEMA_FILE);
|
|
1306
2125
|
const parsed = JSON.parse(await (0, node_fs_promises.readFile)(canonical, "utf-8"));
|
|
1307
2126
|
const next = require_dist.isRecord(parsed) && require_dist.isUnknownArray(parsed.views) ? {
|
|
1308
2127
|
...parsed,
|
|
@@ -1316,8 +2135,8 @@ async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug
|
|
|
1316
2135
|
* refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
|
|
1317
2136
|
async function deleteCustomView(collection, viewId, opts = {}) {
|
|
1318
2137
|
if (collection.source === "user") return { kind: "user-scope" };
|
|
1319
|
-
if (
|
|
1320
|
-
const safeSlug =
|
|
2138
|
+
if (require_store.isPresetSlug(collection.slug)) return { kind: "preset" };
|
|
2139
|
+
const safeSlug = require_store.safeSlugName(collection.slug);
|
|
1321
2140
|
if (safeSlug === null) return {
|
|
1322
2141
|
kind: "unsafe-path",
|
|
1323
2142
|
viewId
|
|
@@ -1328,8 +2147,8 @@ async function deleteCustomView(collection, viewId, opts = {}) {
|
|
|
1328
2147
|
kind: "not-found",
|
|
1329
2148
|
viewId
|
|
1330
2149
|
};
|
|
1331
|
-
const workspaceRoot = opts.workspaceRoot ??
|
|
1332
|
-
const htmlPath =
|
|
2150
|
+
const workspaceRoot = opts.workspaceRoot ?? require_store.getWorkspaceRoot();
|
|
2151
|
+
const htmlPath = require_store.resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
|
|
1333
2152
|
if (htmlPath === null) return {
|
|
1334
2153
|
kind: "unsafe-path",
|
|
1335
2154
|
viewId
|
|
@@ -1556,7 +2375,7 @@ var SCHEMA_DOCS_PROBE_SLUG = "_";
|
|
|
1556
2375
|
/** Resolve the workspace root the same way every collections call does:
|
|
1557
2376
|
* the injected override (tests) or the configured collection host. */
|
|
1558
2377
|
function resolveBase(deps) {
|
|
1559
|
-
return deps.workspaceRoot ??
|
|
2378
|
+
return deps.workspaceRoot ?? require_store.getWorkspaceRoot();
|
|
1560
2379
|
}
|
|
1561
2380
|
/** Where a collection skill is authored in THIS root, and therefore both which
|
|
1562
2381
|
* authoring guide `schemaDocs` serves and where `getSchema` / `putSchema`
|
|
@@ -1570,7 +2389,7 @@ function resolveBase(deps) {
|
|
|
1570
2389
|
* from there, so the tool would silently contradict its own documentation.
|
|
1571
2390
|
* Staged requires BOTH to agree; anything else is direct. */
|
|
1572
2391
|
function authoringTarget(deps, slug) {
|
|
1573
|
-
const stagingDir = deps.stagedSkillAuthoring === false ? null :
|
|
2392
|
+
const stagingDir = deps.stagedSkillAuthoring === false ? null : require_store.stagingSkillDir(resolveBase(deps), slug);
|
|
1574
2393
|
return {
|
|
1575
2394
|
variant: stagingDir === null ? "direct" : "staged",
|
|
1576
2395
|
stagingDir
|
|
@@ -1613,7 +2432,7 @@ async function recordIssuesWarning(collection, deps) {
|
|
|
1613
2432
|
return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
|
|
1614
2433
|
}
|
|
1615
2434
|
async function loadRequestedItems(collection, ids, deps) {
|
|
1616
|
-
const store =
|
|
2435
|
+
const store = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
1617
2436
|
if (!ids) return {
|
|
1618
2437
|
items: await store.list(),
|
|
1619
2438
|
missing: []
|
|
@@ -1622,7 +2441,7 @@ async function loadRequestedItems(collection, ids, deps) {
|
|
|
1622
2441
|
const missing = [];
|
|
1623
2442
|
for (const recordId of ids) {
|
|
1624
2443
|
const item = await store.read(recordId).catch((err) => {
|
|
1625
|
-
if (
|
|
2444
|
+
if (require_store.isBackendUnavailable(err)) throw err;
|
|
1626
2445
|
return null;
|
|
1627
2446
|
});
|
|
1628
2447
|
if (item) items.push(item);
|
|
@@ -1687,7 +2506,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
1687
2506
|
try {
|
|
1688
2507
|
existing = await store.read(itemId);
|
|
1689
2508
|
} catch (err) {
|
|
1690
|
-
if (
|
|
2509
|
+
if (require_store.isBackendUnavailable(err)) throw err;
|
|
1691
2510
|
return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
|
|
1692
2511
|
}
|
|
1693
2512
|
if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
|
|
@@ -1702,7 +2521,7 @@ async function mergeWithExisting(collection, store, record, itemId) {
|
|
|
1702
2521
|
}
|
|
1703
2522
|
async function putOneItem(collection, store, write, record, mode, deps) {
|
|
1704
2523
|
const { schema } = collection;
|
|
1705
|
-
const itemId =
|
|
2524
|
+
const itemId = require_store.resolveCreateItemId(schema, record);
|
|
1706
2525
|
const reject = (about, problem) => ({ rejected: {
|
|
1707
2526
|
id: require_promptSafety.defangForPrompt(about),
|
|
1708
2527
|
problem: require_promptSafety.defangForPrompt(problem)
|
|
@@ -1733,7 +2552,7 @@ async function putOneItem(collection, store, write, record, mode, deps) {
|
|
|
1733
2552
|
* `runCollectionQuery`, shared with the desktop custom view's `/query`
|
|
1734
2553
|
* route so the two surfaces can never drift. */
|
|
1735
2554
|
async function handleQueryItems(collection, queryArg, deps) {
|
|
1736
|
-
const parsed =
|
|
2555
|
+
const parsed = require_store.CollectionQueryZ.safeParse(queryArg);
|
|
1737
2556
|
if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${require_promptSafety.defangForPrompt(issue.message)}`).join("\n")}`;
|
|
1738
2557
|
const rows = await runCollectionQuery(collection, parsed.data, { workspaceRoot: deps.workspaceRoot });
|
|
1739
2558
|
return JSON.stringify({
|
|
@@ -1743,9 +2562,9 @@ async function handleQueryItems(collection, queryArg, deps) {
|
|
|
1743
2562
|
});
|
|
1744
2563
|
}
|
|
1745
2564
|
async function handlePutItems(collection, args, deps) {
|
|
1746
|
-
const store =
|
|
2565
|
+
const store = require_store.storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
1747
2566
|
const { write } = store;
|
|
1748
|
-
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).`;
|
|
1749
2568
|
const written = [];
|
|
1750
2569
|
const rejected = [];
|
|
1751
2570
|
for (const record of args.items) {
|
|
@@ -1764,8 +2583,8 @@ async function handlePutItems(collection, args, deps) {
|
|
|
1764
2583
|
* absent `delete` IS the refusal), and the same per-id result shape so a
|
|
1765
2584
|
* partially-bad batch reports per id instead of failing whole. */
|
|
1766
2585
|
async function handleDeleteItems(collection, ids, deps) {
|
|
1767
|
-
const { delete: removeItem } =
|
|
1768
|
-
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).`;
|
|
1769
2588
|
const deleted = [];
|
|
1770
2589
|
const rejected = [];
|
|
1771
2590
|
for (const itemId of ids) {
|
|
@@ -1834,6 +2653,32 @@ async function handleGetOntology(deps) {
|
|
|
1834
2653
|
collections
|
|
1835
2654
|
});
|
|
1836
2655
|
}
|
|
2656
|
+
/** Publish this repository's `app.json` + shared schemas to its Firestore app.
|
|
2657
|
+
*
|
|
2658
|
+
* Named as a whole-app action because it IS one: publish takes the repository
|
|
2659
|
+
* as its unit (one roster, one public config, every shared collection), so it
|
|
2660
|
+
* carries no `slug` and refusing one is not a limitation to work around.
|
|
2661
|
+
*
|
|
2662
|
+
* The reply is prose rather than a status code on purpose. This is the one
|
|
2663
|
+
* operation in the collection surface that changes what every member sees the
|
|
2664
|
+
* moment it lands, and the two things the caller has to relay — what will
|
|
2665
|
+
* break, and that `confirm` is how it proceeds anyway — are sentences, not
|
|
2666
|
+
* fields. */
|
|
2667
|
+
async function handlePublishApp(deps, confirm) {
|
|
2668
|
+
const result = await publishApp({
|
|
2669
|
+
...deps,
|
|
2670
|
+
confirm
|
|
2671
|
+
});
|
|
2672
|
+
if (!result.ok) {
|
|
2673
|
+
const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
|
|
2674
|
+
return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
|
|
2675
|
+
}
|
|
2676
|
+
const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
|
|
2677
|
+
const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
|
|
2678
|
+
const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
|
|
2679
|
+
const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
|
|
2680
|
+
return `${result.created ? "Created" : "Updated"} app '${result.aid}' and published ${result.cids.length} collection(s): ${result.cids.join(", ")}. Signed ${stamp}. The previous app document is kept in \`previousPublished\` for rollback.${forced}`;
|
|
2681
|
+
}
|
|
1837
2682
|
/** Return the collection-authoring reference (`collection-skills.md`),
|
|
1838
2683
|
* rendered by `renderSchemaDocs` — the full doc overflows the agent's
|
|
1839
2684
|
* per-result limit, so the default reply is the core guide + a table of
|
|
@@ -1854,7 +2699,7 @@ async function handleSchemaDocs(deps, topic) {
|
|
|
1854
2699
|
* fallback for user-scope skills that have no staging copy. Raw text —
|
|
1855
2700
|
* not the parsed schema — so the agent edits the true on-disk source. */
|
|
1856
2701
|
async function handleGetSchema(slug, deps) {
|
|
1857
|
-
const collection = await
|
|
2702
|
+
const collection = await require_store.loadCollection(slug, deps);
|
|
1858
2703
|
if (!collection) return unknownCollection(slug);
|
|
1859
2704
|
const { stagingDir } = authoringTarget(deps, collection.slug);
|
|
1860
2705
|
const candidates = [...stagingDir === null ? [] : [node_path.default.join(stagingDir, SCHEMA_FILE)], node_path.default.join(collection.skillDir, SCHEMA_FILE)];
|
|
@@ -1916,11 +2761,11 @@ async function writeAndMirrorSchema(slug, skillDir, schema, deps) {
|
|
|
1916
2761
|
* gate doesn't apply. Returns a one-line reason, or null when the schema
|
|
1917
2762
|
* would be accepted. */
|
|
1918
2763
|
function schemaDiscoveryGate(schema, base) {
|
|
1919
|
-
const primaryField =
|
|
2764
|
+
const primaryField = require_store.resolvePrimaryField(schema.fields, schema.primaryKey);
|
|
1920
2765
|
if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
|
|
1921
2766
|
if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
|
|
1922
|
-
if (schema.dataPath !== void 0 &&
|
|
1923
|
-
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`;
|
|
1924
2769
|
return null;
|
|
1925
2770
|
}
|
|
1926
2771
|
/** Validate a schema against CollectionSchemaZ and, on success, persist it.
|
|
@@ -1928,11 +2773,11 @@ function schemaDiscoveryGate(schema, base) {
|
|
|
1928
2773
|
* the root's authoring dir (the normal create flow), not through here. */
|
|
1929
2774
|
async function handlePutSchema(slug, schemaArg, deps) {
|
|
1930
2775
|
if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
|
|
1931
|
-
const collection = await
|
|
2776
|
+
const collection = await require_store.loadCollection(slug, deps);
|
|
1932
2777
|
if (!collection) return `manageCollection: unknown collection '${require_promptSafety.defangForPrompt(slug)}' — create it by writing SKILL.md + ${SCHEMA_FILE} under ${authoringDirLabel(deps, require_promptSafety.defangForPrompt(slug))}, then edit it here.`;
|
|
1933
2778
|
const refusal = schemaEditRefusal(collection, slug, deps);
|
|
1934
2779
|
if (refusal) return refusal;
|
|
1935
|
-
const parsed =
|
|
2780
|
+
const parsed = require_store.CollectionSchemaZ.safeParse(schemaArg);
|
|
1936
2781
|
if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
|
|
1937
2782
|
const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
|
|
1938
2783
|
if (gate) return `manageCollection: schema rejected — ${gate} (call schemaDocs for the field reference). It passes basic validation but discovery would skip it, hiding the collection.`;
|
|
@@ -1942,7 +2787,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
1942
2787
|
written: true
|
|
1943
2788
|
});
|
|
1944
2789
|
}
|
|
1945
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
2790
|
+
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. `publishApp` publishes the whole repository — its `app.json` (member roster, public read/submit configuration) and every shared collection's schema — to the app's Firestore. It is the ONE operation here that changes what every member sees the moment it runs, and it cannot be undone by reverting a commit: publishing again is the only undo (the previous app document is kept as `previousPublished`). Call it when the user asks to publish, invite, or open an app; never as a follow-up to an edit the user did not ask you to ship. Its refusals are the product, not an obstacle — relay them verbatim, and do not work around one by rewriting `app.json` until the user has said what they actually want. If it reports live records the new schemas would break, show the user that list and ask before re-running with `confirm`. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
1946
2791
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
1947
2792
|
async function dispatchGetItems(collection, args, deps) {
|
|
1948
2793
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -1979,7 +2824,7 @@ async function manageCollectionHandler(deps, args) {
|
|
|
1979
2824
|
try {
|
|
1980
2825
|
return await dispatchManageCollection(deps, args);
|
|
1981
2826
|
} catch (err) {
|
|
1982
|
-
if (
|
|
2827
|
+
if (require_store.isBackendUnavailable(err)) return `manageCollection: ${err.message}`;
|
|
1983
2828
|
throw err;
|
|
1984
2829
|
}
|
|
1985
2830
|
}
|
|
@@ -1987,18 +2832,19 @@ async function dispatchManageCollection(deps, args) {
|
|
|
1987
2832
|
const action = typeof args.action === "string" ? args.action : "";
|
|
1988
2833
|
if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
|
|
1989
2834
|
if (action === "getOntology") return handleGetOntology(deps);
|
|
2835
|
+
if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
|
|
1990
2836
|
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
1991
2837
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
1992
2838
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
1993
2839
|
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
1994
|
-
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"
|
|
1995
|
-
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);
|
|
1996
2842
|
if (!collection) return unknownCollection(slug);
|
|
1997
2843
|
return dispatchRecordAction(action, collection, args, deps);
|
|
1998
2844
|
}
|
|
1999
2845
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
2000
2846
|
name: "manageCollection",
|
|
2001
|
-
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
2847
|
+
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json. publishApp publishes the repository's app.json + shared schemas to Firestore, where every member sees them immediately -- it refuses declarations that would be silently permissive or silently deny everyone, and refuses to publish over records the new schemas would break unless `confirm` is set. Prefer it over raw file I/O on collections.",
|
|
2002
2848
|
inputSchema: {
|
|
2003
2849
|
type: "object",
|
|
2004
2850
|
properties: {
|
|
@@ -2012,7 +2858,8 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2012
2858
|
"getOntology",
|
|
2013
2859
|
"schemaDocs",
|
|
2014
2860
|
"getSchema",
|
|
2015
|
-
"putSchema"
|
|
2861
|
+
"putSchema",
|
|
2862
|
+
"publishApp"
|
|
2016
2863
|
],
|
|
2017
2864
|
description: "What to do."
|
|
2018
2865
|
},
|
|
@@ -2048,6 +2895,10 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2048
2895
|
type: "object",
|
|
2049
2896
|
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
2050
2897
|
},
|
|
2898
|
+
confirm: {
|
|
2899
|
+
type: "boolean",
|
|
2900
|
+
description: "publishApp: publish even though existing records fail the schemas being published. Omit it first — the refusal lists what would break, and that list is the thing to show the user before asking."
|
|
2901
|
+
},
|
|
2051
2902
|
topic: {
|
|
2052
2903
|
type: "string",
|
|
2053
2904
|
description: "schemaDocs: fetch one section of the reference by heading (case-insensitive substring — e.g. \"field types\", \"kanban\", \"calendar\", \"dataSource\"). Omit for the core authoring guide plus a table of contents of every section; \"all\" returns the full document (large — it can exceed your tool-result limit)."
|
|
@@ -2069,6 +2920,30 @@ function makeManageCollectionTool(deps = {}) {
|
|
|
2069
2920
|
};
|
|
2070
2921
|
}
|
|
2071
2922
|
//#endregion
|
|
2923
|
+
Object.defineProperty(exports, "APPS_COLLECTION", {
|
|
2924
|
+
enumerable: true,
|
|
2925
|
+
get: function() {
|
|
2926
|
+
return APPS_COLLECTION;
|
|
2927
|
+
}
|
|
2928
|
+
});
|
|
2929
|
+
Object.defineProperty(exports, "APP_ROLES", {
|
|
2930
|
+
enumerable: true,
|
|
2931
|
+
get: function() {
|
|
2932
|
+
return APP_ROLES;
|
|
2933
|
+
}
|
|
2934
|
+
});
|
|
2935
|
+
Object.defineProperty(exports, "AuthoredAppZ", {
|
|
2936
|
+
enumerable: true,
|
|
2937
|
+
get: function() {
|
|
2938
|
+
return AuthoredAppZ;
|
|
2939
|
+
}
|
|
2940
|
+
});
|
|
2941
|
+
Object.defineProperty(exports, "MAX_RECORD_ISSUES", {
|
|
2942
|
+
enumerable: true,
|
|
2943
|
+
get: function() {
|
|
2944
|
+
return MAX_RECORD_ISSUES;
|
|
2945
|
+
}
|
|
2946
|
+
});
|
|
2072
2947
|
Object.defineProperty(exports, "MAX_SCHEMA_ISSUES", {
|
|
2073
2948
|
enumerable: true,
|
|
2074
2949
|
get: function() {
|
|
@@ -2087,18 +2962,48 @@ Object.defineProperty(exports, "ONE_SECOND_MS", {
|
|
|
2087
2962
|
return ONE_SECOND_MS;
|
|
2088
2963
|
}
|
|
2089
2964
|
});
|
|
2965
|
+
Object.defineProperty(exports, "PUBLIC_CONFIG_DOC", {
|
|
2966
|
+
enumerable: true,
|
|
2967
|
+
get: function() {
|
|
2968
|
+
return PUBLIC_CONFIG_DOC;
|
|
2969
|
+
}
|
|
2970
|
+
});
|
|
2971
|
+
Object.defineProperty(exports, "STORE_UNREADABLE", {
|
|
2972
|
+
enumerable: true,
|
|
2973
|
+
get: function() {
|
|
2974
|
+
return STORE_UNREADABLE;
|
|
2975
|
+
}
|
|
2976
|
+
});
|
|
2090
2977
|
Object.defineProperty(exports, "advanceTriggerDate", {
|
|
2091
2978
|
enumerable: true,
|
|
2092
2979
|
get: function() {
|
|
2093
2980
|
return advanceTriggerDate;
|
|
2094
2981
|
}
|
|
2095
2982
|
});
|
|
2983
|
+
Object.defineProperty(exports, "appConfigPath", {
|
|
2984
|
+
enumerable: true,
|
|
2985
|
+
get: function() {
|
|
2986
|
+
return appConfigPath;
|
|
2987
|
+
}
|
|
2988
|
+
});
|
|
2989
|
+
Object.defineProperty(exports, "appSchemasPath", {
|
|
2990
|
+
enumerable: true,
|
|
2991
|
+
get: function() {
|
|
2992
|
+
return appSchemasPath;
|
|
2993
|
+
}
|
|
2994
|
+
});
|
|
2096
2995
|
Object.defineProperty(exports, "applyMutateAction", {
|
|
2097
2996
|
enumerable: true,
|
|
2098
2997
|
get: function() {
|
|
2099
2998
|
return applyMutateAction;
|
|
2100
2999
|
}
|
|
2101
3000
|
});
|
|
3001
|
+
Object.defineProperty(exports, "bindsSubmitterIdentity", {
|
|
3002
|
+
enumerable: true,
|
|
3003
|
+
get: function() {
|
|
3004
|
+
return bindsSubmitterIdentity;
|
|
3005
|
+
}
|
|
3006
|
+
});
|
|
2102
3007
|
Object.defineProperty(exports, "buildActionSeedPrompt", {
|
|
2103
3008
|
enumerable: true,
|
|
2104
3009
|
get: function() {
|
|
@@ -2195,18 +3100,42 @@ Object.defineProperty(exports, "maybeSpawnSuccessor", {
|
|
|
2195
3100
|
return maybeSpawnSuccessor;
|
|
2196
3101
|
}
|
|
2197
3102
|
});
|
|
3103
|
+
Object.defineProperty(exports, "parseAuthoredApp", {
|
|
3104
|
+
enumerable: true,
|
|
3105
|
+
get: function() {
|
|
3106
|
+
return parseAuthoredApp;
|
|
3107
|
+
}
|
|
3108
|
+
});
|
|
2198
3109
|
Object.defineProperty(exports, "parseCivil", {
|
|
2199
3110
|
enumerable: true,
|
|
2200
3111
|
get: function() {
|
|
2201
3112
|
return parseCivil;
|
|
2202
3113
|
}
|
|
2203
3114
|
});
|
|
3115
|
+
Object.defineProperty(exports, "projectApp", {
|
|
3116
|
+
enumerable: true,
|
|
3117
|
+
get: function() {
|
|
3118
|
+
return projectApp;
|
|
3119
|
+
}
|
|
3120
|
+
});
|
|
2204
3121
|
Object.defineProperty(exports, "promptPathsFor", {
|
|
2205
3122
|
enumerable: true,
|
|
2206
3123
|
get: function() {
|
|
2207
3124
|
return promptPathsFor;
|
|
2208
3125
|
}
|
|
2209
3126
|
});
|
|
3127
|
+
Object.defineProperty(exports, "publishApp", {
|
|
3128
|
+
enumerable: true,
|
|
3129
|
+
get: function() {
|
|
3130
|
+
return publishApp;
|
|
3131
|
+
}
|
|
3132
|
+
});
|
|
3133
|
+
Object.defineProperty(exports, "publishProblems", {
|
|
3134
|
+
enumerable: true,
|
|
3135
|
+
get: function() {
|
|
3136
|
+
return publishProblems;
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
2210
3139
|
Object.defineProperty(exports, "readCustomViewHtml", {
|
|
2211
3140
|
enumerable: true,
|
|
2212
3141
|
get: function() {
|
|
@@ -2274,4 +3203,4 @@ Object.defineProperty(exports, "validateRecordObject", {
|
|
|
2274
3203
|
}
|
|
2275
3204
|
});
|
|
2276
3205
|
|
|
2277
|
-
//# sourceMappingURL=server-
|
|
3206
|
+
//# sourceMappingURL=server-CWZyg8fn.cjs.map
|