@mulmoclaude/core 3.7.0 → 3.11.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/core/itemId.d.ts +28 -0
- package/dist/collection/firestore.cjs +9 -1
- package/dist/collection/firestore.cjs.map +1 -1
- package/dist/collection/firestore.js +10 -2
- package/dist/collection/firestore.js.map +1 -1
- package/dist/collection/index.cjs +55 -66
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -1
- package/dist/collection/index.js +3 -15
- package/dist/collection/index.js.map +1 -1
- 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/firestoreDocs.d.ts +18 -0
- package/dist/collection/server/host.d.ts +29 -0
- package/dist/collection/server/index.cjs +70 -62
- package/dist/collection/server/index.d.ts +2 -3
- package/dist/collection/server/index.js +4 -4
- package/dist/collection/server/io.d.ts +5 -3
- package/dist/collection/server/manageTool.d.ts +0 -4
- package/dist/collection/server/publishManifest.d.ts +1 -0
- package/dist/collection/server/publishProject.d.ts +171 -2
- package/dist/collection-watchers/index.cjs +82 -38
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +67 -23
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{store-_61sO8K8.js → discovery-B9zdNkgW.js} +2525 -2354
- package/dist/discovery-B9zdNkgW.js.map +1 -0
- package/dist/{store-5_P_NsGa.cjs → discovery-BsXiDZBR.cjs} +2545 -2362
- package/dist/discovery-BsXiDZBR.cjs.map +1 -0
- package/dist/feeds/index.cjs +4 -4
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +14 -14
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +4 -4
- package/dist/firestore/listen.d.ts +4 -0
- package/dist/google/index.cjs +16 -16
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.js +2 -2
- package/dist/{ingestTypes-CEi-Ot7F.js → ingestTypes-B312BwXg.js} +2 -2
- package/dist/{ingestTypes-CEi-Ot7F.js.map → ingestTypes-B312BwXg.js.map} +1 -1
- package/dist/{ingestTypes-iaiq33Ph.cjs → ingestTypes-B4XfrUoq.cjs} +3 -3
- package/dist/{ingestTypes-iaiq33Ph.cjs.map → ingestTypes-B4XfrUoq.cjs.map} +1 -1
- package/dist/{calendarGrid-CQ8MVSRb.js → itemId-DfTm08jm.js} +34 -2
- package/dist/itemId-DfTm08jm.js.map +1 -0
- package/dist/{calendarGrid-DGILaVxI.cjs → itemId-Dzrr3vs5.cjs} +45 -1
- package/dist/itemId-Dzrr3vs5.cjs.map +1 -0
- package/dist/listen-D5av7GzB.js +22 -0
- package/dist/listen-D5av7GzB.js.map +1 -0
- package/dist/listen-UZA68Upt.cjs +45 -0
- package/dist/listen-UZA68Upt.cjs.map +1 -0
- package/dist/{promptSafety-CpiME8pj.js → promptSafety-CSdyp7RF.js} +2 -2
- package/dist/{promptSafety-CpiME8pj.js.map → promptSafety-CSdyp7RF.js.map} +1 -1
- package/dist/{promptSafety-NNGViiCr.cjs → promptSafety-CWqYq-NS.cjs} +8 -8
- package/dist/{promptSafety-NNGViiCr.cjs.map → promptSafety-CWqYq-NS.cjs.map} +1 -1
- package/dist/remote-host/server/hostRunner.d.ts +2 -4
- package/dist/remote-host/server/index.cjs +4 -19
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +1 -16
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{server-CWZyg8fn.cjs → server-X_magqUa.cjs} +620 -734
- package/dist/server-X_magqUa.cjs.map +1 -0
- package/dist/{server-B48Jyxcj.js → server-zcM1ViCE.js} +418 -568
- package/dist/server-zcM1ViCE.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/calendarGrid-CQ8MVSRb.js.map +0 -1
- package/dist/calendarGrid-DGILaVxI.cjs.map +0 -1
- package/dist/collection/core/shortHexId.d.ts +0 -8
- package/dist/collection/server/publish.d.ts +0 -56
- package/dist/server-B48Jyxcj.js.map +0 -1
- package/dist/server-CWZyg8fn.cjs.map +0 -1
- package/dist/store-5_P_NsGa.cjs.map +0 -1
- package/dist/store-_61sO8K8.js.map +0 -1
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { a as isErrorWithCode, c as isStringArray, l as isUnknownArray, s as isRecord, t as errorMessage } from "./dist-D8zokgGo.js";
|
|
2
2
|
import { n as writeFileAtomic } from "./root-BMroU_mB.js";
|
|
3
3
|
import { n as toPosixRelPath } from "./relPath-DW8MC8VO.js";
|
|
4
|
-
import {
|
|
5
|
-
import { C as actionVisible, _ as uniqueRefTargets, b as projectBacklinkRow, c as selectDynamicRecord, f as itemIsDone, g as uniqueEmbedTargets, h as uniqueBacklinkSources, i as ownProp, n as deriveAll, o as firstDateField, s as resolveIcon, t as defangForPrompt, v as backlinkRows, x as rollupValue, y as coerceNumeric } from "./promptSafety-
|
|
6
|
-
import { B as SCHEMA_FILE$1, C as
|
|
4
|
+
import { B as fieldText, I as embedTargetId, L as isFieldDrivenEvery, N as COMPUTED_TYPES, V as fieldTextOrNull, d as parseIsoDate, f as parseIsoDateTime, x as isValidCollectionName, z as storageKindFor } from "./itemId-DfTm08jm.js";
|
|
5
|
+
import { C as actionVisible, _ as uniqueRefTargets, b as projectBacklinkRow, c as selectDynamicRecord, f as itemIsDone, g as uniqueEmbedTargets, h as uniqueBacklinkSources, i as ownProp, n as deriveAll, o as firstDateField, s as resolveIcon, t as defangForPrompt, v as backlinkRows, x as rollupValue, y as coerceNumeric } from "./promptSafety-CSdyp7RF.js";
|
|
6
|
+
import { B as SCHEMA_FILE$1, C as CollectionQueryZ, I as parseAppManifest, J as isBackendUnavailable, K as safeSlugName, O as isRegularFile, S as compileJsonlQuery, U as resolveDataDir, V as isContainedInRoot, W as resolveTemplatePath, X as archiveDir, b as queryCsv, c as resolveMutateSet, d as storeFor, f as checkpointSqliteDatabase, ht as stagingSkillDir, i as resolvePrimaryField, it as isPresetSlug$1, j as resolveCreateItemId, m as cacheDir, n as discoverCollections, nt as getWorkspaceRoot, ot as log, r as loadCollection, s as CollectionSchemaZ, u as readOnlyRefusal, y as normalizeCsvValue } from "./discovery-B9zdNkgW.js";
|
|
7
7
|
import { ingestStatePath } from "./feeds/paths.js";
|
|
8
8
|
import { mirrorSkillWrite } from "./skill-bridge/index.js";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import { execFile } from "node:child_process";
|
|
14
|
-
import { promisify } from "node:util";
|
|
15
13
|
//#region src/collection/server/publishManifest.ts
|
|
16
14
|
/** A collection id / app id, held to the one name rule (`SAFE_SLUG_PATTERN`)
|
|
17
15
|
* that `sharedCollectionKey` applies. Stated once so a path built later
|
|
@@ -124,6 +122,34 @@ var PublicZ = z.object({
|
|
|
124
122
|
read: z.array(NameZ).optional(),
|
|
125
123
|
submit: z.record(NameZ, SubmitZ).optional()
|
|
126
124
|
}).strict();
|
|
125
|
+
/** The URL name an app is handed out under: `https://<host>/{slug}`.
|
|
126
|
+
*
|
|
127
|
+
* A SEPARATE name from the `aid`, and that separation is the point (design
|
|
128
|
+
* D2b). `apps/{aid}` is a shelf every user of the deployment shares and the
|
|
129
|
+
* rules' `allow create` asks only that you name yourself owner — so a
|
|
130
|
+
* memorable aid is first-come-first-served, cannot be checked for
|
|
131
|
+
* availability, and frees up again when an app is deleted. The aid is
|
|
132
|
+
* therefore a UUID, and the thing people can fight over is moved to the name
|
|
133
|
+
* that costs nothing to change.
|
|
134
|
+
*
|
|
135
|
+
* Declared here rather than kept beside `app.json` because a RESERVATION has
|
|
136
|
+
* to travel with the repository: `appSlugs/{slug}` is unreadable until the app
|
|
137
|
+
* is published (`allow read: if resource.data.published == true`), so nothing
|
|
138
|
+
* can recover which slug an app holds by asking Firestore. A second file to
|
|
139
|
+
* keep in step with the declaration is the alternative, and it is the kind of
|
|
140
|
+
* pair that goes out of step silently.
|
|
141
|
+
*
|
|
142
|
+
* The shape is stricter than `NameZ` on purpose: it is BOTH a URL path
|
|
143
|
+
* segment people read aloud and a Firestore document id. Lowercase
|
|
144
|
+
* alphanumerics separated by single hyphens covers both without a case rule
|
|
145
|
+
* that would make two slugs collide in one place and not the other.
|
|
146
|
+
*
|
|
147
|
+
* Which slug an app ended up with is the HOST's business — a wanted slug can
|
|
148
|
+
* be taken, and the host writes the one it reserved back here. Nothing in this
|
|
149
|
+
* package reads the key; it is declared so that writing it back does not make
|
|
150
|
+
* the file unparseable. */
|
|
151
|
+
var SLUG_SHAPE = "must be lowercase letters, digits and single hyphens, and must not start or end with one (e.g. sakura-hair)";
|
|
152
|
+
var SlugZ = z.string().trim().max(64).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, SLUG_SHAPE).refine((slug) => !slug.includes("--"), SLUG_SHAPE);
|
|
127
153
|
/** The whole authored declaration.
|
|
128
154
|
*
|
|
129
155
|
* `owner` is accepted but is NOT the published value — publish stamps the
|
|
@@ -135,6 +161,8 @@ var PublicZ = z.object({
|
|
|
135
161
|
var AuthoredAppZ = z.object({
|
|
136
162
|
aid: NameZ,
|
|
137
163
|
name: z.string().trim().min(1).optional(),
|
|
164
|
+
/** The wanted (or reserved) URL name — see {@link SlugZ}. */
|
|
165
|
+
slug: SlugZ.optional(),
|
|
138
166
|
/** Per-worktree app id (design D6, implementation order 7). Accepted so a
|
|
139
167
|
* repository already carrying it parses; nothing reads it yet. */
|
|
140
168
|
aidEnv: z.string().trim().min(1).optional(),
|
|
@@ -263,6 +291,7 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
263
291
|
publishedAt: stamp.publishedAt,
|
|
264
292
|
publishedBy: stamp.email,
|
|
265
293
|
publishedCommit: stamp.commit,
|
|
294
|
+
publishedDirty: stamp.dirty === true ? true : void 0,
|
|
266
295
|
previousPublished: previousOf(existing)
|
|
267
296
|
});
|
|
268
297
|
const config = {
|
|
@@ -281,6 +310,102 @@ function projectApp(authored, schemas, stamp, existing) {
|
|
|
281
310
|
config
|
|
282
311
|
};
|
|
283
312
|
}
|
|
313
|
+
/** Keys on the app document that publish owns: what is PUBLIC right now, and
|
|
314
|
+
* the rule-facing configuration anonymous access is judged against. Deploy
|
|
315
|
+
* carries them through from the existing document and never authors them. */
|
|
316
|
+
var PUBLISH_OWNED_KEYS = [
|
|
317
|
+
"public",
|
|
318
|
+
"collections",
|
|
319
|
+
"participantRead",
|
|
320
|
+
"publishedAt",
|
|
321
|
+
"publishedBy",
|
|
322
|
+
"publishedCommit",
|
|
323
|
+
"publishedDirty",
|
|
324
|
+
"previousPublished"
|
|
325
|
+
];
|
|
326
|
+
var isPublishOwned = (key) => PUBLISH_OWNED_KEYS.includes(key);
|
|
327
|
+
function projectDeploy(authored, schemas, stamp, existing) {
|
|
328
|
+
const { app } = projectApp(authored, schemas, stamp, existing);
|
|
329
|
+
const deployed = Object.fromEntries(Object.entries(app).filter(([key]) => !isPublishOwned(key)));
|
|
330
|
+
for (const key of PUBLISH_OWNED_KEYS) {
|
|
331
|
+
const live = existing?.[key];
|
|
332
|
+
if (live !== void 0) deployed[key] = live;
|
|
333
|
+
}
|
|
334
|
+
deployed.deployedAt = stamp.publishedAt;
|
|
335
|
+
deployed.deployedBy = stamp.email;
|
|
336
|
+
if (stamp.commit !== void 0) deployed.deployedCommit = stamp.commit;
|
|
337
|
+
return {
|
|
338
|
+
app: deployed,
|
|
339
|
+
staging: schemas.map(({ cid, schema }) => ({
|
|
340
|
+
cid,
|
|
341
|
+
doc: stagedDoc(schema, stamp, authored, cid)
|
|
342
|
+
}))
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/** One staged schema document, carrying this cid's rule-facing configuration
|
|
346
|
+
* alongside the schema so publish can promote them together. */
|
|
347
|
+
function stagedDoc(schema, stamp, authored, cid) {
|
|
348
|
+
const doc = {
|
|
349
|
+
publishedSchema: schema,
|
|
350
|
+
deployedAt: stamp.publishedAt,
|
|
351
|
+
deployedBy: stamp.email
|
|
352
|
+
};
|
|
353
|
+
const config = authored.collections?.[cid];
|
|
354
|
+
if (config !== void 0) doc.config = config;
|
|
355
|
+
if (authored.participantRead?.includes(cid) === true) doc.participantRead = true;
|
|
356
|
+
if (stamp.commit !== void 0) doc.deployedCommit = stamp.commit;
|
|
357
|
+
return doc;
|
|
358
|
+
}
|
|
359
|
+
/** The rule-facing configuration to promote, read from the STAGED documents
|
|
360
|
+
* rather than from the manifest as it reads right now.
|
|
361
|
+
*
|
|
362
|
+
* Otherwise: deploy revision A, edit `app.json` to revision B, publish — and
|
|
363
|
+
* the promoted schema is A's while the authorization behaviour is B's, a
|
|
364
|
+
* combination nobody exercised through `/staging/{aid}`.
|
|
365
|
+
*
|
|
366
|
+
* (`public` is deliberately NOT part of this: it is not staged, because it is
|
|
367
|
+
* the decision being made AT publish rather than something under test.) */
|
|
368
|
+
function stagedRuleConfig(staged) {
|
|
369
|
+
const entries = [];
|
|
370
|
+
const participantRead = [];
|
|
371
|
+
for (const entry of staged) {
|
|
372
|
+
const { config } = entry.doc;
|
|
373
|
+
if (config !== void 0) entries.push([entry.cid, config]);
|
|
374
|
+
if (entry.doc.participantRead === true) participantRead.push(entry.cid);
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
collections: entries.length > 0 ? Object.fromEntries(entries) : void 0,
|
|
378
|
+
participantRead: participantRead.length > 0 ? participantRead : void 0
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function projectPublish(authored, staged, stamp, existing) {
|
|
382
|
+
const { app, config } = projectApp(authored, [], stamp, existing);
|
|
383
|
+
const staging = stagedRuleConfig(staged);
|
|
384
|
+
app.collections = staging.collections;
|
|
385
|
+
app.participantRead = staging.participantRead;
|
|
386
|
+
const published = Object.fromEntries(Object.entries(existing ?? app).filter(([key]) => !isPublishOwned(key)));
|
|
387
|
+
for (const key of PUBLISH_OWNED_KEYS) if (key !== "public" && app[key] !== void 0) published[key] = app[key];
|
|
388
|
+
const publicBlock = app.public;
|
|
389
|
+
return {
|
|
390
|
+
app: published,
|
|
391
|
+
config,
|
|
392
|
+
public: isRecord(publicBlock) ? publicBlock : void 0
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
/** Re-stamp a staged schema document as it is promoted to `collections/{cid}`.
|
|
396
|
+
*
|
|
397
|
+
* The stamp answers "which version is PUBLIC right now, and who made it so",
|
|
398
|
+
* so it is written by the operation that changes the answer — publish — not
|
|
399
|
+
* carried over from the deploy that staged it. */
|
|
400
|
+
function promoteSchema(staged, stamp) {
|
|
401
|
+
const doc = {
|
|
402
|
+
publishedSchema: staged.publishedSchema,
|
|
403
|
+
publishedAt: stamp.publishedAt,
|
|
404
|
+
publishedBy: stamp.email
|
|
405
|
+
};
|
|
406
|
+
if (stamp.commit !== void 0) doc.publishedCommit = stamp.commit;
|
|
407
|
+
return doc;
|
|
408
|
+
}
|
|
284
409
|
/** One published schema document. Written key by key rather than through
|
|
285
410
|
* `compact`, so the declared type is the type — an optional commit is the
|
|
286
411
|
* only variable part. */
|
|
@@ -296,8 +421,31 @@ function schemaDoc(schema, stamp) {
|
|
|
296
421
|
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
297
422
|
* collection path plus a document id, and the app document's id is the aid. */
|
|
298
423
|
var APPS_COLLECTION = "apps";
|
|
299
|
-
/** The collection (schema) documents' parent path
|
|
424
|
+
/** The collection (schema) documents' parent path — what the PUBLIC page
|
|
425
|
+
* reads, written only by publish (promotion). */
|
|
300
426
|
var appSchemasPath = (aid) => `apps/${aid}/collections`;
|
|
427
|
+
/** The URL-slug reservations — `appSlugs/{slug}` → `{ aid, published }`.
|
|
428
|
+
*
|
|
429
|
+
* A TOP-LEVEL collection, not a field on the app: the public page resolves a
|
|
430
|
+
* slug to an aid BEFORE it can read anything under `apps/{aid}`, and a slug
|
|
431
|
+
* has to be claimable atomically (create-if-absent) so two apps cannot hold
|
|
432
|
+
* the same URL.
|
|
433
|
+
*
|
|
434
|
+
* `published` is what makes the reservation invisible until publish. The slug
|
|
435
|
+
* is human-readable, so a readable reservation would let anyone guess the URL
|
|
436
|
+
* and get the aid — and the aid is the `/staging/{aid}` entrance. The rule is
|
|
437
|
+
* `allow read: if resource.data.published == true`, which needs no `get()` and
|
|
438
|
+
* so costs nothing against the rules' expression budget. */
|
|
439
|
+
var APP_SLUGS_COLLECTION = "appSlugs";
|
|
440
|
+
var appSlugDoc = (aid, published) => ({
|
|
441
|
+
aid,
|
|
442
|
+
published
|
|
443
|
+
});
|
|
444
|
+
/** The staged schema documents' parent path — what `/staging/{aid}` reads,
|
|
445
|
+
* written by deploy. A separate DOCUMENT rather than a field beside
|
|
446
|
+
* `publishedSchema`, because the rules cannot hide a field: anything inside a
|
|
447
|
+
* document the public page may read is public. */
|
|
448
|
+
var appStagingPath = (aid) => `apps/${aid}/staging`;
|
|
301
449
|
/** The public-config documents' parent path. */
|
|
302
450
|
var appConfigPath = (aid) => `apps/${aid}/config`;
|
|
303
451
|
//#endregion
|
|
@@ -523,530 +671,32 @@ function publishProblems(app, collections, publisherEmail) {
|
|
|
523
671
|
...primaryKeyProblems(app, collections)
|
|
524
672
|
];
|
|
525
673
|
}
|
|
526
|
-
/** A public submission must be
|
|
527
|
-
*
|
|
528
|
-
* The rules
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
/** The emptiness rule shared by `required` and the "only check present
|
|
553
|
-
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
554
|
-
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
555
|
-
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
556
|
-
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
557
|
-
function enforcedProblem(key, spec, value) {
|
|
558
|
-
const empty = isEmptyValue(value);
|
|
559
|
-
if (spec.required && empty) return `missing required field '${key}'`;
|
|
560
|
-
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
564
|
-
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
565
|
-
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
566
|
-
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
567
|
-
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
568
|
-
* which the day view can't place). `string`-backed types accept anything
|
|
569
|
-
* stringifiable; `ref` existence is out of scope. */
|
|
570
|
-
function strictTypeProblem(key, spec, value) {
|
|
571
|
-
switch (spec.type) {
|
|
572
|
-
case "number":
|
|
573
|
-
case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
574
|
-
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
575
|
-
case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
576
|
-
case "datetime": return parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
577
|
-
default: return null;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
581
|
-
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
582
|
-
* First row problem wins, prefixed with the row number so the fix is
|
|
583
|
-
* locatable. */
|
|
584
|
-
function strictTableProblem(key, spec, value) {
|
|
585
|
-
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
586
|
-
for (let index = 0; index < value.length; index++) {
|
|
587
|
-
const row = value[index];
|
|
588
|
-
if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
589
|
-
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
590
|
-
const subValue = row[subKey];
|
|
591
|
-
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
592
|
-
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
|
-
/** First problem for one field's stored value under `tier`, or null.
|
|
598
|
-
* Enforced checks always run (and their messages never vary by tier — the
|
|
599
|
-
* scan and the write gate must agree on them); strict adds the per-type
|
|
600
|
-
* layer on present values only. */
|
|
601
|
-
function recordFieldProblem(key, spec, value, tier) {
|
|
602
|
-
const enforced = enforcedProblem(key, spec, value);
|
|
603
|
-
if (enforced || tier === "enforced") return enforced;
|
|
604
|
-
if (isEmptyValue(value)) return null;
|
|
605
|
-
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
606
|
-
return strictTypeProblem(key, spec, value);
|
|
607
|
-
}
|
|
608
|
-
var compiled = /* @__PURE__ */ new WeakMap();
|
|
609
|
-
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
610
|
-
* Loose object: unknown keys are allowed and any declared key may be
|
|
611
|
-
* absent (records are user files, not parse-and-rewrite targets —
|
|
612
|
-
* callers validate, they never persist the parse output). The checks run
|
|
613
|
-
* as ONE object-level refine iterating fields in declaration order —
|
|
614
|
-
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
615
|
-
* must still reach the required check", and the single loop keeps the
|
|
616
|
-
* first reported issue identical to the historical first-problem-wins
|
|
617
|
-
* contract. */
|
|
618
|
-
function compileRecordZ(schema, tier) {
|
|
619
|
-
const cached = compiled.get(schema)?.[tier];
|
|
620
|
-
if (cached) return cached;
|
|
621
|
-
const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
|
|
622
|
-
const validator = z.looseObject({}).superRefine((record, ctx) => {
|
|
623
|
-
for (const [key, spec] of stored) {
|
|
624
|
-
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
625
|
-
if (problem) ctx.addIssue({
|
|
626
|
-
code: "custom",
|
|
627
|
-
message: problem,
|
|
628
|
-
path: [key]
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
});
|
|
632
|
-
const entry = compiled.get(schema) ?? {};
|
|
633
|
-
entry[tier] = validator;
|
|
634
|
-
compiled.set(schema, entry);
|
|
635
|
-
return validator;
|
|
636
|
-
}
|
|
637
|
-
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
638
|
-
* issue per record keeps the report short and the fix obvious (the
|
|
639
|
-
* historical contract of `validateRecordObject`). */
|
|
640
|
-
function firstRecordProblem(record, schema, tier) {
|
|
641
|
-
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
642
|
-
if (result.success) return null;
|
|
643
|
-
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
644
|
-
}
|
|
645
|
-
//#endregion
|
|
646
|
-
//#region src/collection/server/validate.ts
|
|
647
|
-
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
648
|
-
* because a caller that REPORTS a count has to know the count is a floor —
|
|
649
|
-
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
650
|
-
var MAX_RECORD_ISSUES = 25;
|
|
651
|
-
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
652
|
-
* at all.
|
|
653
|
-
*
|
|
654
|
-
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
655
|
-
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
656
|
-
* direction that matters: `publish` lets the user override invalid records,
|
|
657
|
-
* and overriding this one would mean publishing without ever having looked. */
|
|
658
|
-
var STORE_UNREADABLE = "(store)";
|
|
659
|
-
var MAX_ISSUES = 25;
|
|
660
|
-
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
661
|
-
* ones that won't load or violate the schema. An empty list means every
|
|
662
|
-
* record is fine. */
|
|
663
|
-
/** List entries under the data dir, guarding realpath containment (against a
|
|
664
|
-
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
665
|
-
* missing dir as empty while surfacing real I/O faults. */
|
|
666
|
-
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
667
|
-
if (!isContainedInRoot(dataDir, workspaceRoot)) {
|
|
668
|
-
log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
669
|
-
return [];
|
|
670
|
-
}
|
|
671
|
-
try {
|
|
672
|
-
return await readdir(dataDir);
|
|
673
|
-
} catch (err) {
|
|
674
|
-
if (isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
675
|
-
throw err;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
async function validateCollectionRecords(collection, opts = {}) {
|
|
679
|
-
if (collection.schema.dataSource !== void 0) return [];
|
|
680
|
-
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
681
|
-
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
682
|
-
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
683
|
-
const issues = [];
|
|
684
|
-
for (const name of entries.sort()) {
|
|
685
|
-
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
686
|
-
if (issues.length >= MAX_ISSUES) break;
|
|
687
|
-
const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
|
|
688
|
-
if (issue) issues.push(issue);
|
|
689
|
-
}
|
|
690
|
-
return issues;
|
|
691
|
-
}
|
|
692
|
-
/** Store-backed twin of the file scan: list every record through the
|
|
693
|
-
* collection's store and lint it with the same "strict" report-only tier.
|
|
694
|
-
* A row the store can't even parse is invisible here (the store skips
|
|
695
|
-
* it), so the read/parse classifications of the file scan don't apply —
|
|
696
|
-
* schema violations are what this catches. `file` carries the record id
|
|
697
|
-
* (there is no per-record filename). */
|
|
698
|
-
async function validateStoreRecords(collection, opts) {
|
|
699
|
-
let items;
|
|
700
|
-
try {
|
|
701
|
-
items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
702
|
-
} catch (err) {
|
|
703
|
-
return [{
|
|
704
|
-
file: STORE_UNREADABLE,
|
|
705
|
-
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
706
|
-
}];
|
|
707
|
-
}
|
|
708
|
-
const issues = [];
|
|
709
|
-
for (const item of items) {
|
|
710
|
-
if (issues.length >= MAX_ISSUES) break;
|
|
711
|
-
const itemId = fieldText(item[collection.schema.primaryKey]);
|
|
712
|
-
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
713
|
-
if (problem) issues.push({
|
|
714
|
-
file: itemId,
|
|
715
|
-
problem
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
return issues;
|
|
719
|
-
}
|
|
720
|
-
async function readRecordText(fullPath, name) {
|
|
721
|
-
try {
|
|
722
|
-
if (!(await lstat(fullPath)).isFile()) return {
|
|
723
|
-
file: name,
|
|
724
|
-
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
725
|
-
};
|
|
726
|
-
return { raw: await readFile(fullPath, "utf-8") };
|
|
727
|
-
} catch {
|
|
728
|
-
return {
|
|
729
|
-
file: name,
|
|
730
|
-
problem: "could not be read — skipped, won't appear"
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
735
|
-
* schema violation, or null when it's fine. */
|
|
736
|
-
async function inspectRecord(fullPath, name, schema) {
|
|
737
|
-
const read = await readRecordText(fullPath, name);
|
|
738
|
-
if ("problem" in read) return read;
|
|
739
|
-
let parsed;
|
|
740
|
-
try {
|
|
741
|
-
parsed = JSON.parse(read.raw);
|
|
742
|
-
} catch (err) {
|
|
743
|
-
return {
|
|
744
|
-
file: name,
|
|
745
|
-
problem: `invalid JSON (${err instanceof Error ? err.message : String(err)}) — SKIPPED, won't appear. Usual cause: an unescaped " inside a string value; use 「」/『』 or write \\" instead.`
|
|
746
|
-
};
|
|
747
|
-
}
|
|
748
|
-
if (!isRecord(parsed)) return {
|
|
749
|
-
file: name,
|
|
750
|
-
problem: "not a JSON object — skipped, won't appear"
|
|
751
|
-
};
|
|
752
|
-
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
753
|
-
return problem ? {
|
|
754
|
-
file: name,
|
|
755
|
-
problem
|
|
756
|
-
} : null;
|
|
757
|
-
}
|
|
758
|
-
/** What a non-string primary key actually is, for the error message. Names the
|
|
759
|
-
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
760
|
-
* nothing about what is wrong. */
|
|
761
|
-
function describeIdType(value) {
|
|
762
|
-
if (value === null) return "null";
|
|
763
|
-
if (value === void 0) return "missing";
|
|
764
|
-
if (Array.isArray(value)) return "an array";
|
|
765
|
-
return `a ${typeof value}`;
|
|
766
|
-
}
|
|
767
|
-
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
768
|
-
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
769
|
-
* tiers), or null when it's fine. One issue per record keeps the report
|
|
770
|
-
* short and the fix obvious. Pure + exported so write paths
|
|
771
|
-
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
772
|
-
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
773
|
-
* would be) stored under. The default `"enforced"` tier keeps every
|
|
774
|
-
* write gate on the historical three checks; only pass `"strict"` from
|
|
775
|
-
* report-only surfaces. */
|
|
776
|
-
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
777
|
-
const idValue = record[schema.primaryKey];
|
|
778
|
-
if (typeof idValue !== "string") return `'${schema.primaryKey}' must be a string, but is ${describeIdType(idValue)} — must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
779
|
-
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
780
|
-
return firstRecordProblem(record, schema, tier);
|
|
781
|
-
}
|
|
782
|
-
//#endregion
|
|
783
|
-
//#region src/collection/server/publish.ts
|
|
784
|
-
var execFileAsync = promisify(execFile);
|
|
785
|
-
/** How many broken records to name before summarising. A publish that would
|
|
786
|
-
* break a thousand rows is answered by the count and a sample; dumping all of
|
|
787
|
-
* them buries the number, which is the part the decision turns on. */
|
|
788
|
-
var MAX_LISTED_ISSUES = 10;
|
|
789
|
-
/** `git rev-parse HEAD` plus a dirty check, or nothing.
|
|
790
|
-
*
|
|
791
|
-
* A missing git, a repository with no commits and a non-repository are all
|
|
792
|
-
* the same answer here — no commit — because the stamp is attribution, not a
|
|
793
|
-
* requirement. What is NOT acceptable is a stamp that lies, which is why the
|
|
794
|
-
* dirty flag exists: publishing from a modified tree records a commit that
|
|
795
|
-
* does not describe what was published, and the flag is the only thing that
|
|
796
|
-
* would ever tell a reader so. */
|
|
797
|
-
async function gitStamp(root) {
|
|
798
|
-
try {
|
|
799
|
-
const { stdout } = await execFileAsync("git", [
|
|
800
|
-
"-C",
|
|
801
|
-
root,
|
|
802
|
-
"rev-parse",
|
|
803
|
-
"HEAD"
|
|
804
|
-
]);
|
|
805
|
-
const commit = stdout.trim();
|
|
806
|
-
const { stdout: status } = await execFileAsync("git", [
|
|
807
|
-
"-C",
|
|
808
|
-
root,
|
|
809
|
-
"status",
|
|
810
|
-
"--porcelain"
|
|
811
|
-
]);
|
|
812
|
-
return {
|
|
813
|
-
commit: commit.length > 0 ? commit : void 0,
|
|
814
|
-
dirty: status.trim().length > 0
|
|
815
|
-
};
|
|
816
|
-
} catch {
|
|
817
|
-
return {};
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
/** The shared collections of THIS REPOSITORY, by cid.
|
|
821
|
-
*
|
|
822
|
-
* `userSkillsDir: null` — not a test convenience, a boundary. Discovery
|
|
823
|
-
* resolves every schema it finds against the WORKSPACE root, user-scope
|
|
824
|
-
* included, so a globally installed skill under `~/.claude/skills` carrying
|
|
825
|
-
* `storage.type: "firestore"` picks up whichever repository's `aid` it
|
|
826
|
-
* happens to be discovered from. Left in, publish would write that schema
|
|
827
|
-
* into this app — and into every other app the same user publishes, since the
|
|
828
|
-
* skill is installed once per machine and the repositories are not.
|
|
829
|
-
*
|
|
830
|
-
* An app is a REPOSITORY (design D1): its collections are the ones committed
|
|
831
|
-
* beside its `app.json`, which is what makes a clone resolve the same
|
|
832
|
-
* collections and an invitation a matter of authorization rather than
|
|
833
|
-
* discovery. A schema that is not in the repository has no claim on a cid
|
|
834
|
-
* there. And because a view is HTML, publishing one is not a tidiness
|
|
835
|
-
* question: it is the machine's own skills reaching every member's browser.
|
|
836
|
-
*
|
|
837
|
-
* The consequence is deliberate: a cid named in `app.json` that exists only
|
|
838
|
-
* in user scope is now an unknown cid, and publish says so by name instead of
|
|
839
|
-
* quietly publishing a schema from outside the repository. */
|
|
840
|
-
async function sharedCollections(opts, root) {
|
|
841
|
-
return (await discoverCollections({
|
|
842
|
-
...opts,
|
|
843
|
-
workspaceRoot: root,
|
|
844
|
-
userSkillsDir: null
|
|
845
|
-
})).filter((collection) => collection.appId !== void 0);
|
|
846
|
-
}
|
|
847
|
-
/** Existing records that would not satisfy the schema about to be published.
|
|
848
|
-
*
|
|
849
|
-
* Read from FIRESTORE, not from disk: a shared collection's records live in
|
|
850
|
-
* the app, and the question this answers is "what does the live data look
|
|
851
|
-
* like under the new schema" — which is the migration question. Reported as
|
|
852
|
-
* a refusal the publisher can override, because a breaking change is
|
|
853
|
-
* sometimes exactly what is intended and the point is that it is a decision
|
|
854
|
-
* rather than a discovery. */
|
|
855
|
-
async function recordProblems(collections, opts) {
|
|
856
|
-
const lines = [];
|
|
857
|
-
const unreadable = [];
|
|
858
|
-
let records = 0;
|
|
859
|
-
let cappedAnywhere = false;
|
|
860
|
-
for (const collection of collections) {
|
|
861
|
-
const issues = await validateCollectionRecords(collection, opts);
|
|
862
|
-
if (issues.length === 0) continue;
|
|
863
|
-
const unread = issues.filter((issue) => issue.file === STORE_UNREADABLE);
|
|
864
|
-
if (unread.length > 0) {
|
|
865
|
-
unreadable.push(`${collection.slug}: ${unread.map((issue) => issue.problem).join("; ")}`);
|
|
866
|
-
continue;
|
|
867
|
-
}
|
|
868
|
-
records += issues.length;
|
|
869
|
-
const capped = issues.length >= 25;
|
|
870
|
-
cappedAnywhere = cappedAnywhere || capped;
|
|
871
|
-
const count = capped ? `at least ${issues.length}` : String(issues.length);
|
|
872
|
-
const plural = issues.length === 1 ? "" : "s";
|
|
873
|
-
const note = capped ? " (the scan stops there)" : "";
|
|
874
|
-
lines.push(`${collection.slug}: ${count} existing record${plural} would not satisfy the schema about to be published${note}`);
|
|
875
|
-
for (const issue of issues.slice(0, MAX_LISTED_ISSUES)) lines.push(` - ${issue.file}: ${issue.problem}`);
|
|
876
|
-
if (issues.length > MAX_LISTED_ISSUES) lines.push(` - … and ${issues.length - MAX_LISTED_ISSUES} more`);
|
|
877
|
-
}
|
|
878
|
-
return {
|
|
879
|
-
lines,
|
|
880
|
-
records,
|
|
881
|
-
capped: cappedAnywhere,
|
|
882
|
-
unreadable
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
function schemasOf(collections) {
|
|
886
|
-
return collections.map((collection) => ({
|
|
887
|
-
cid: collection.slug,
|
|
888
|
-
schema: collection.schema
|
|
889
|
-
})).sort((left, right) => left.cid < right.cid ? -1 : left.cid > right.cid ? 1 : 0);
|
|
890
|
-
}
|
|
891
|
-
/** Read and parse `<root>/app.json`'s full declaration. */
|
|
892
|
-
async function readAuthored(root) {
|
|
893
|
-
let raw;
|
|
894
|
-
try {
|
|
895
|
-
raw = await readFile(path.join(root, APP_MANIFEST_FILE), "utf-8");
|
|
896
|
-
} catch (err) {
|
|
897
|
-
return {
|
|
898
|
-
ok: false,
|
|
899
|
-
problems: [`cannot read ${path.join(root, APP_MANIFEST_FILE)}: ${String(err)}`]
|
|
900
|
-
};
|
|
901
|
-
}
|
|
902
|
-
return parseAuthoredApp(raw);
|
|
903
|
-
}
|
|
904
|
-
/** Everything wrong with the declaration itself, publisher included. */
|
|
905
|
-
function declarationProblems(app, collections, handle) {
|
|
906
|
-
const problems = publishProblems(app, collections.map((collection) => ({
|
|
907
|
-
cid: collection.slug,
|
|
908
|
-
primaryKey: collection.schema.primaryKey
|
|
909
|
-
})), handle.email);
|
|
910
|
-
if (app.owner !== void 0 && app.owner !== handle.uid) problems.push(`app.json declares owner "${app.owner}", which is not your uid (${handle.uid}). \`owner\` is stamped by publish and carried forward unchanged afterwards — remove it from app.json rather than maintaining it by hand.`);
|
|
911
|
-
return problems;
|
|
912
|
-
}
|
|
913
|
-
/** Put the three kinds of document, in the order the rules require, and turn a
|
|
914
|
-
* rejected write into the result type instead of letting it escape.
|
|
915
|
-
*
|
|
916
|
-
* Returns null when everything was written; a failure result otherwise.
|
|
917
|
-
*
|
|
918
|
-
* A raw rejection here would reach the agent as a tool crash rather than the
|
|
919
|
-
* actionable text this tool promises. But "actionable" is a strong claim for
|
|
920
|
-
* a half-finished publish, so the message ENUMERATES what landed rather than
|
|
921
|
-
* summarising it: the order is app → every schema → config, and a summary
|
|
922
|
-
* written for one failure point is wrong at the others. Saying "the roster
|
|
923
|
-
* and configuration are live" after a SCHEMA write failed names a config
|
|
924
|
-
* document this publish never wrote — which still holds whatever the last
|
|
925
|
-
* publish left, and is exactly the state the caller is trying to repair. */
|
|
926
|
-
async function writeDocuments(handle, aid, published) {
|
|
927
|
-
const steps = [
|
|
928
|
-
{
|
|
929
|
-
what: `the app document (apps/${aid})`,
|
|
930
|
-
run: () => handle.docs.set(APPS_COLLECTION, aid, published.app)
|
|
931
|
-
},
|
|
932
|
-
...published.schemas.map(({ cid, doc }) => ({
|
|
933
|
-
what: `the published schema for '${cid}'`,
|
|
934
|
-
run: () => handle.docs.set(appSchemasPath(aid), cid, doc)
|
|
935
|
-
})),
|
|
936
|
-
{
|
|
937
|
-
what: `the public config document (apps/${aid}/config/${PUBLIC_CONFIG_DOC})`,
|
|
938
|
-
run: () => handle.docs.set(appConfigPath(aid), PUBLIC_CONFIG_DOC, published.config)
|
|
939
|
-
}
|
|
940
|
-
];
|
|
941
|
-
const landed = [];
|
|
942
|
-
for (const [index, step] of steps.entries()) try {
|
|
943
|
-
await step.run();
|
|
944
|
-
landed.push(step.what);
|
|
945
|
-
} catch (err) {
|
|
946
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
947
|
-
return {
|
|
948
|
-
ok: false,
|
|
949
|
-
partial: index > 0,
|
|
950
|
-
problems: [`publish failed while writing ${step.what}: ${reason}`, ...partialState(landed, [step.what, ...steps.slice(index + 1).map((rest) => rest.what)])]
|
|
951
|
-
};
|
|
952
|
-
}
|
|
953
|
-
return null;
|
|
954
|
-
}
|
|
955
|
-
/** What is live and what is not, listed rather than summarised.
|
|
956
|
-
*
|
|
957
|
-
* Two facts, and both matter for the repair: a document this publish wrote is
|
|
958
|
-
* live NOW, and a document it did not write still holds what the LAST publish
|
|
959
|
-
* left — which is not the same as being absent, and not the same as matching
|
|
960
|
-
* the declaration that was just half-applied. */
|
|
961
|
-
function partialState(landed, notWritten) {
|
|
962
|
-
const repair = "Publishing again is the repair: the write is idempotent, and it re-does every step, including the ones that did land.";
|
|
963
|
-
if (landed.length === 0) return [`Nothing was written. ${repair}`];
|
|
964
|
-
return [
|
|
965
|
-
`Written by this publish, and live now: ${landed.join("; ")}.`,
|
|
966
|
-
`NOT written: ${notWritten.join("; ")} — ${notWritten.length === 1 ? "it still holds" : "they still hold"} whatever the previous publish left.`,
|
|
967
|
-
repair
|
|
968
|
-
];
|
|
969
|
-
}
|
|
970
|
-
/** Publish this repository's declaration to its app.
|
|
971
|
-
*
|
|
972
|
-
* Everything variable is a parameter or comes from the host binding, so the
|
|
973
|
-
* whole path is exercisable against an in-memory `FirestoreDocs` with no
|
|
974
|
-
* network and no API key — which is the only way the conversion table gets
|
|
975
|
-
* tested as a table. */
|
|
976
|
-
async function publishApp(opts = {}) {
|
|
977
|
-
const root = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
978
|
-
const handle = firestoreHandle();
|
|
979
|
-
if (!handle) return {
|
|
980
|
-
ok: false,
|
|
981
|
-
partial: false,
|
|
982
|
-
problems: ["publish needs a signed-in Firestore session: connect remote-host first. Publishing writes the app's roster and configuration as the app's owner, which is an authenticated write."]
|
|
983
|
-
};
|
|
984
|
-
const authored = await readAuthored(root);
|
|
985
|
-
if (!authored.ok) return {
|
|
986
|
-
...authored,
|
|
987
|
-
partial: false
|
|
988
|
-
};
|
|
989
|
-
const collections = await sharedCollections(opts, root);
|
|
990
|
-
const problems = declarationProblems(authored.app, collections, handle);
|
|
991
|
-
if (problems.length > 0) return {
|
|
992
|
-
ok: false,
|
|
993
|
-
partial: false,
|
|
994
|
-
problems
|
|
995
|
-
};
|
|
996
|
-
const issues = await recordProblems(collections, {
|
|
997
|
-
...opts,
|
|
998
|
-
workspaceRoot: root
|
|
999
|
-
});
|
|
1000
|
-
if (issues.unreadable.length > 0) return {
|
|
1001
|
-
ok: false,
|
|
1002
|
-
partial: false,
|
|
1003
|
-
problems: [...issues.unreadable, "publish stopped: the live records could not be read, so nothing checked whether the schemas about to be published still fit them. This is not something `confirm` overrides — confirming means accepting a known breakage, and here there is no reading at all. Fix the access (or the connection) and publish again."]
|
|
1004
|
-
};
|
|
1005
|
-
if (issues.records > 0 && opts.confirm !== true) return {
|
|
1006
|
-
ok: false,
|
|
1007
|
-
partial: false,
|
|
1008
|
-
problems: [...issues.lines, "publish stopped: these records are live and members are reading them. Migrate them first, or re-run with confirm to publish the schema anyway and repair the records afterwards."]
|
|
1009
|
-
};
|
|
1010
|
-
return writePublished(authored.app, collections, handle, opts, root, issues);
|
|
1011
|
-
}
|
|
1012
|
-
/** The write half: stamp, project, and put the documents in the order the
|
|
1013
|
-
* rules require. Split from the gate above so neither half hides the other —
|
|
1014
|
-
* everything up to here can refuse, and nothing from here on does. */
|
|
1015
|
-
async function writePublished(authored, collections, handle, opts, root, issues) {
|
|
1016
|
-
const { aid } = authored;
|
|
1017
|
-
let existing;
|
|
1018
|
-
try {
|
|
1019
|
-
existing = await handle.docs.get(APPS_COLLECTION, aid);
|
|
1020
|
-
} catch (err) {
|
|
1021
|
-
return {
|
|
1022
|
-
ok: false,
|
|
1023
|
-
partial: false,
|
|
1024
|
-
problems: [`publish failed while reading the current app document (apps/${aid}): ${err instanceof Error ? err.message : String(err)}`, "Nothing was written. Publishing again is safe — this read only decides whether the app is created or updated."]
|
|
1025
|
-
};
|
|
1026
|
-
}
|
|
1027
|
-
const stampSource = await (opts.resolveCommit ?? gitStamp)(root);
|
|
1028
|
-
const stamp = {
|
|
1029
|
-
uid: handle.uid,
|
|
1030
|
-
email: handle.email,
|
|
1031
|
-
publishedAt: (opts.now ?? Date.now)(),
|
|
1032
|
-
commit: stampSource.commit
|
|
1033
|
-
};
|
|
1034
|
-
const existingApp = isRecord(existing) ? existing : null;
|
|
1035
|
-
const published = projectApp(authored, schemasOf(collections), stamp, existingApp);
|
|
1036
|
-
if (stampSource.dirty === true) published.app.publishedDirty = true;
|
|
1037
|
-
const written = await writeDocuments(handle, aid, published);
|
|
1038
|
-
if (written !== null) return written;
|
|
1039
|
-
return {
|
|
1040
|
-
ok: true,
|
|
1041
|
-
aid,
|
|
1042
|
-
cids: published.schemas.map((entry) => entry.cid),
|
|
1043
|
-
created: existingApp === null,
|
|
1044
|
-
commit: stamp.commit,
|
|
1045
|
-
dirty: stampSource.dirty === true,
|
|
1046
|
-
recordIssues: issues.records,
|
|
1047
|
-
recordIssuesCapped: issues.capped,
|
|
1048
|
-
published
|
|
1049
|
-
};
|
|
674
|
+
/** A public submission must NOT be allowed to name its own primary key.
|
|
675
|
+
*
|
|
676
|
+
* The rules constrain the DOCUMENT ID (`idFrom`) and cannot constrain the
|
|
677
|
+
* value of a field — nothing compares `request.resource.data[primaryKey]`
|
|
678
|
+
* with the path being written. So a submit path that accepts the primary key
|
|
679
|
+
* as a `createField` lets a submitter write at their one permitted document
|
|
680
|
+
* id while CLAIMING another record's identity, or a duplicate.
|
|
681
|
+
*
|
|
682
|
+
* It is refused rather than tolerated because there is nothing for the field
|
|
683
|
+
* to do: `firestoreStore` takes a shared record's identity from the document
|
|
684
|
+
* id and overwrites the field on read, so a submitted value is either equal
|
|
685
|
+
* to the id (noise) or a lie (silently discarded). Publishing a form field
|
|
686
|
+
* whose value is thrown away is worse than not having it — the author will
|
|
687
|
+
* believe submitters choose their ids.
|
|
688
|
+
*
|
|
689
|
+
* This is the second answer to the same question. The first was the reverse —
|
|
690
|
+
* REQUIRE the key, because a record without one was rejected by every reader
|
|
691
|
+
* — and it was right about the symptom and wrong about the cure: the identity
|
|
692
|
+
* belongs to the id the rules can pin, not to a field they cannot. */
|
|
693
|
+
function primaryKeyProblems(app, collections) {
|
|
694
|
+
const primaryKeyOf = new Map(collections.map((collection) => [collection.cid, collection.primaryKey]));
|
|
695
|
+
return Object.entries(app.public?.submit ?? {}).flatMap(([cid, submit]) => {
|
|
696
|
+
const primaryKey = primaryKeyOf.get(cid);
|
|
697
|
+
if (primaryKey === void 0 || !submit.createFields.includes(primaryKey)) return [];
|
|
698
|
+
return [`public.submit.${cid}.createFields must NOT include "${primaryKey}", the schema's primaryKey: the rules can pin the document id but not the value of a field, so a submitter could write at their own id while claiming another record's. A shared record's identity is its document id — the store fills the field from it, and a submitted value is either the same thing or a lie that is thrown away.`];
|
|
699
|
+
});
|
|
1050
700
|
}
|
|
1051
701
|
//#endregion
|
|
1052
702
|
//#region src/collection/server/skillAssets.ts
|
|
@@ -1412,6 +1062,238 @@ async function runCollectionQuery(collection, query, opts = {}) {
|
|
|
1412
1062
|
return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
|
|
1413
1063
|
}
|
|
1414
1064
|
//#endregion
|
|
1065
|
+
//#region src/collection/core/recordZ.ts
|
|
1066
|
+
/** The emptiness rule shared by `required` and the "only check present
|
|
1067
|
+
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
1068
|
+
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
1069
|
+
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
1070
|
+
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
1071
|
+
function enforcedProblem(key, spec, value) {
|
|
1072
|
+
const empty = isEmptyValue(value);
|
|
1073
|
+
if (spec.required && empty) return `missing required field '${key}'`;
|
|
1074
|
+
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
1075
|
+
return null;
|
|
1076
|
+
}
|
|
1077
|
+
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
1078
|
+
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
1079
|
+
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
1080
|
+
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
1081
|
+
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
1082
|
+
* which the day view can't place). `string`-backed types accept anything
|
|
1083
|
+
* stringifiable; `ref` existence is out of scope. */
|
|
1084
|
+
function strictTypeProblem(key, spec, value) {
|
|
1085
|
+
switch (spec.type) {
|
|
1086
|
+
case "number":
|
|
1087
|
+
case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
1088
|
+
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
1089
|
+
case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
1090
|
+
case "datetime": return parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
1091
|
+
default: return null;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
/** Strict check for a PRESENT `table` value: an array of row objects, each
|
|
1095
|
+
* row conforming to the sub-schema (required / enum / typed sub-values).
|
|
1096
|
+
* First row problem wins, prefixed with the row number so the fix is
|
|
1097
|
+
* locatable. */
|
|
1098
|
+
function strictTableProblem(key, spec, value) {
|
|
1099
|
+
if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
|
|
1100
|
+
for (let index = 0; index < value.length; index++) {
|
|
1101
|
+
const row = value[index];
|
|
1102
|
+
if (!isRecord(row)) return `'${key}' row ${index + 1} is not an object`;
|
|
1103
|
+
for (const [subKey, subSpec] of Object.entries(spec.of)) {
|
|
1104
|
+
const subValue = row[subKey];
|
|
1105
|
+
const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
|
|
1106
|
+
if (problem) return `'${key}' row ${index + 1}: ${problem}`;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
/** First problem for one field's stored value under `tier`, or null.
|
|
1112
|
+
* Enforced checks always run (and their messages never vary by tier — the
|
|
1113
|
+
* scan and the write gate must agree on them); strict adds the per-type
|
|
1114
|
+
* layer on present values only. */
|
|
1115
|
+
function recordFieldProblem(key, spec, value, tier) {
|
|
1116
|
+
const enforced = enforcedProblem(key, spec, value);
|
|
1117
|
+
if (enforced || tier === "enforced") return enforced;
|
|
1118
|
+
if (isEmptyValue(value)) return null;
|
|
1119
|
+
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
1120
|
+
return strictTypeProblem(key, spec, value);
|
|
1121
|
+
}
|
|
1122
|
+
var compiled = /* @__PURE__ */ new WeakMap();
|
|
1123
|
+
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
1124
|
+
* Loose object: unknown keys are allowed and any declared key may be
|
|
1125
|
+
* absent (records are user files, not parse-and-rewrite targets —
|
|
1126
|
+
* callers validate, they never persist the parse output). The checks run
|
|
1127
|
+
* as ONE object-level refine iterating fields in declaration order —
|
|
1128
|
+
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
1129
|
+
* must still reach the required check", and the single loop keeps the
|
|
1130
|
+
* first reported issue identical to the historical first-problem-wins
|
|
1131
|
+
* contract. */
|
|
1132
|
+
function compileRecordZ(schema, tier) {
|
|
1133
|
+
const cached = compiled.get(schema)?.[tier];
|
|
1134
|
+
if (cached) return cached;
|
|
1135
|
+
const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
|
|
1136
|
+
const validator = z.looseObject({}).superRefine((record, ctx) => {
|
|
1137
|
+
for (const [key, spec] of stored) {
|
|
1138
|
+
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
1139
|
+
if (problem) ctx.addIssue({
|
|
1140
|
+
code: "custom",
|
|
1141
|
+
message: problem,
|
|
1142
|
+
path: [key]
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
const entry = compiled.get(schema) ?? {};
|
|
1147
|
+
entry[tier] = validator;
|
|
1148
|
+
compiled.set(schema, entry);
|
|
1149
|
+
return validator;
|
|
1150
|
+
}
|
|
1151
|
+
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
1152
|
+
* issue per record keeps the report short and the fix obvious (the
|
|
1153
|
+
* historical contract of `validateRecordObject`). */
|
|
1154
|
+
function firstRecordProblem(record, schema, tier) {
|
|
1155
|
+
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
1156
|
+
if (result.success) return null;
|
|
1157
|
+
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
1158
|
+
}
|
|
1159
|
+
//#endregion
|
|
1160
|
+
//#region src/collection/server/validate.ts
|
|
1161
|
+
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
1162
|
+
* because a caller that REPORTS a count has to know the count is a floor —
|
|
1163
|
+
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
1164
|
+
var MAX_RECORD_ISSUES = 25;
|
|
1165
|
+
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
1166
|
+
* at all.
|
|
1167
|
+
*
|
|
1168
|
+
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
1169
|
+
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
1170
|
+
* direction that matters: `publish` lets the user override invalid records,
|
|
1171
|
+
* and overriding this one would mean publishing without ever having looked. */
|
|
1172
|
+
var STORE_UNREADABLE = "(store)";
|
|
1173
|
+
var MAX_ISSUES = 25;
|
|
1174
|
+
/** Read every `<id>.json` under the collection's dataDir and report the
|
|
1175
|
+
* ones that won't load or violate the schema. An empty list means every
|
|
1176
|
+
* record is fine. */
|
|
1177
|
+
/** List entries under the data dir, guarding realpath containment (against a
|
|
1178
|
+
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
1179
|
+
* missing dir as empty while surfacing real I/O faults. */
|
|
1180
|
+
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
1181
|
+
if (!isContainedInRoot(dataDir, workspaceRoot)) {
|
|
1182
|
+
log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
1183
|
+
return [];
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
return await readdir(dataDir);
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
1189
|
+
throw err;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function validateCollectionRecords(collection, opts = {}) {
|
|
1193
|
+
if (collection.schema.dataSource !== void 0) return [];
|
|
1194
|
+
if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
|
|
1195
|
+
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
1196
|
+
const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
|
|
1197
|
+
const issues = [];
|
|
1198
|
+
for (const name of entries.sort()) {
|
|
1199
|
+
if (!name.endsWith(".json") || name.startsWith(".")) continue;
|
|
1200
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
1201
|
+
const issue = await inspectRecord(path.join(collection.dataDir, name), name, collection.schema);
|
|
1202
|
+
if (issue) issues.push(issue);
|
|
1203
|
+
}
|
|
1204
|
+
return issues;
|
|
1205
|
+
}
|
|
1206
|
+
/** Store-backed twin of the file scan: list every record through the
|
|
1207
|
+
* collection's store and lint it with the same "strict" report-only tier.
|
|
1208
|
+
* A row the store can't even parse is invisible here (the store skips
|
|
1209
|
+
* it), so the read/parse classifications of the file scan don't apply —
|
|
1210
|
+
* schema violations are what this catches. `file` carries the record id
|
|
1211
|
+
* (there is no per-record filename). */
|
|
1212
|
+
async function validateStoreRecords(collection, opts) {
|
|
1213
|
+
let items;
|
|
1214
|
+
try {
|
|
1215
|
+
items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
return [{
|
|
1218
|
+
file: STORE_UNREADABLE,
|
|
1219
|
+
problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
|
|
1220
|
+
}];
|
|
1221
|
+
}
|
|
1222
|
+
const issues = [];
|
|
1223
|
+
for (const item of items) {
|
|
1224
|
+
if (issues.length >= MAX_ISSUES) break;
|
|
1225
|
+
const itemId = fieldText(item[collection.schema.primaryKey]);
|
|
1226
|
+
const problem = validateRecordObject(item, itemId, collection.schema, "strict");
|
|
1227
|
+
if (problem) issues.push({
|
|
1228
|
+
file: itemId,
|
|
1229
|
+
problem
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
return issues;
|
|
1233
|
+
}
|
|
1234
|
+
async function readRecordText(fullPath, name) {
|
|
1235
|
+
try {
|
|
1236
|
+
if (!(await lstat(fullPath)).isFile()) return {
|
|
1237
|
+
file: name,
|
|
1238
|
+
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
1239
|
+
};
|
|
1240
|
+
return { raw: await readFile(fullPath, "utf-8") };
|
|
1241
|
+
} catch {
|
|
1242
|
+
return {
|
|
1243
|
+
file: name,
|
|
1244
|
+
problem: "could not be read — skipped, won't appear"
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
/** Classify a single record file: unreadable / unparseable / non-object /
|
|
1249
|
+
* schema violation, or null when it's fine. */
|
|
1250
|
+
async function inspectRecord(fullPath, name, schema) {
|
|
1251
|
+
const read = await readRecordText(fullPath, name);
|
|
1252
|
+
if ("problem" in read) return read;
|
|
1253
|
+
let parsed;
|
|
1254
|
+
try {
|
|
1255
|
+
parsed = JSON.parse(read.raw);
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
return {
|
|
1258
|
+
file: name,
|
|
1259
|
+
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.`
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
if (!isRecord(parsed)) return {
|
|
1263
|
+
file: name,
|
|
1264
|
+
problem: "not a JSON object — skipped, won't appear"
|
|
1265
|
+
};
|
|
1266
|
+
const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
|
|
1267
|
+
return problem ? {
|
|
1268
|
+
file: name,
|
|
1269
|
+
problem
|
|
1270
|
+
} : null;
|
|
1271
|
+
}
|
|
1272
|
+
/** What a non-string primary key actually is, for the error message. Names the
|
|
1273
|
+
* shape rather than stringifying the value — "[object Object]" tells the reader
|
|
1274
|
+
* nothing about what is wrong. */
|
|
1275
|
+
function describeIdType(value) {
|
|
1276
|
+
if (value === null) return "null";
|
|
1277
|
+
if (value === void 0) return "missing";
|
|
1278
|
+
if (Array.isArray(value)) return "an array";
|
|
1279
|
+
return `a ${typeof value}`;
|
|
1280
|
+
}
|
|
1281
|
+
/** First schema problem on an in-memory record (primaryKey↔id mismatch,
|
|
1282
|
+
* then the compiled per-field checks — see `../core/recordZ` for the two
|
|
1283
|
+
* tiers), or null when it's fine. One issue per record keeps the report
|
|
1284
|
+
* short and the fix obvious. Pure + exported so write paths
|
|
1285
|
+
* (manageCollection putItems) can gate on the SAME enforced rules the
|
|
1286
|
+
* post-hoc file scan reports — `itemId` is the id the record is (or
|
|
1287
|
+
* would be) stored under. The default `"enforced"` tier keeps every
|
|
1288
|
+
* write gate on the historical three checks; only pass `"strict"` from
|
|
1289
|
+
* report-only surfaces. */
|
|
1290
|
+
function validateRecordObject(record, itemId, schema, tier = "enforced") {
|
|
1291
|
+
const idValue = record[schema.primaryKey];
|
|
1292
|
+
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`;
|
|
1293
|
+
if (idValue !== itemId) return `'${schema.primaryKey}' is '${idValue}' but must equal the filename ('${itemId}'), or the record can't be opened`;
|
|
1294
|
+
return firstRecordProblem(record, schema, tier);
|
|
1295
|
+
}
|
|
1296
|
+
//#endregion
|
|
1415
1297
|
//#region src/collection/server/mutate.ts
|
|
1416
1298
|
/** First problem with the submitted params, or null. Every declared param
|
|
1417
1299
|
* is checked by the shared record-field validator; keys the action never
|
|
@@ -2651,32 +2533,6 @@ async function handleGetOntology(deps) {
|
|
|
2651
2533
|
collections
|
|
2652
2534
|
});
|
|
2653
2535
|
}
|
|
2654
|
-
/** Publish this repository's `app.json` + shared schemas to its Firestore app.
|
|
2655
|
-
*
|
|
2656
|
-
* Named as a whole-app action because it IS one: publish takes the repository
|
|
2657
|
-
* as its unit (one roster, one public config, every shared collection), so it
|
|
2658
|
-
* carries no `slug` and refusing one is not a limitation to work around.
|
|
2659
|
-
*
|
|
2660
|
-
* The reply is prose rather than a status code on purpose. This is the one
|
|
2661
|
-
* operation in the collection surface that changes what every member sees the
|
|
2662
|
-
* moment it lands, and the two things the caller has to relay — what will
|
|
2663
|
-
* break, and that `confirm` is how it proceeds anyway — are sentences, not
|
|
2664
|
-
* fields. */
|
|
2665
|
-
async function handlePublishApp(deps, confirm) {
|
|
2666
|
-
const result = await publishApp({
|
|
2667
|
-
...deps,
|
|
2668
|
-
confirm
|
|
2669
|
-
});
|
|
2670
|
-
if (!result.ok) {
|
|
2671
|
-
const bullets = result.problems.map((problem) => `- ${problem}`).join("\n");
|
|
2672
|
-
return `${result.partial ? "publish FAILED PART-WAY — some documents are already live:" : "publish refused — nothing was written:"}\n${bullets}`;
|
|
2673
|
-
}
|
|
2674
|
-
const dirtyNote = result.dirty ? " (WORKING TREE DIRTY — the commit does not describe what was published)" : "";
|
|
2675
|
-
const stamp = result.commit ? `commit ${result.commit.slice(0, 12)}${dirtyNote}` : "no commit (not a git repository, or no HEAD)";
|
|
2676
|
-
const brokenCount = result.recordIssuesCapped ? `at least ${result.recordIssues}` : `${result.recordIssues}`;
|
|
2677
|
-
const forced = result.recordIssues > 0 ? ` Published over ${brokenCount} record(s) that do not satisfy the new schema — repair them now; members are reading them.` : "";
|
|
2678
|
-
return `${result.created ? "Created" : "Updated"} app '${result.aid}' and published ${result.cids.length} collection(s): ${result.cids.join(", ")}. Signed ${stamp}. The previous app document is kept in \`previousPublished\` for rollback.${forced}`;
|
|
2679
|
-
}
|
|
2680
2536
|
/** Return the collection-authoring reference (`collection-skills.md`),
|
|
2681
2537
|
* rendered by `renderSchemaDocs` — the full doc overflows the agent's
|
|
2682
2538
|
* per-result limit, so the default reply is the core guide + a table of
|
|
@@ -2785,7 +2641,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
2785
2641
|
written: true
|
|
2786
2642
|
});
|
|
2787
2643
|
}
|
|
2788
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference — the default reply is the core authoring guide plus a table of contents; fetch advanced sections (actions, bells, calendar/kanban views, dataSource, storage) by passing their heading as `topic` rather than dumping `topic: \"all\"`. Then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done.
|
|
2644
|
+
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.";
|
|
2789
2645
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
2790
2646
|
async function dispatchGetItems(collection, args, deps) {
|
|
2791
2647
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -2830,19 +2686,18 @@ async function dispatchManageCollection(deps, args) {
|
|
|
2830
2686
|
const action = typeof args.action === "string" ? args.action : "";
|
|
2831
2687
|
if (action === "schemaDocs") return handleSchemaDocs(deps, typeof args.topic === "string" ? args.topic : void 0);
|
|
2832
2688
|
if (action === "getOntology") return handleGetOntology(deps);
|
|
2833
|
-
if (action === "publishApp") return handlePublishApp(deps, args.confirm === true);
|
|
2834
2689
|
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
2835
2690
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
2836
2691
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
2837
2692
|
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
2838
|
-
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\",
|
|
2693
|
+
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
2839
2694
|
const collection = await loadCollection(slug, deps);
|
|
2840
2695
|
if (!collection) return unknownCollection(slug);
|
|
2841
2696
|
return dispatchRecordAction(action, collection, args, deps);
|
|
2842
2697
|
}
|
|
2843
2698
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
2844
2699
|
name: "manageCollection",
|
|
2845
|
-
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference — the core guide plus a table of contents by default; pass `topic` for a specific section. getSchema/putSchema read and validate-then-write the collection's schema.json.
|
|
2700
|
+
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.",
|
|
2846
2701
|
inputSchema: {
|
|
2847
2702
|
type: "object",
|
|
2848
2703
|
properties: {
|
|
@@ -2856,8 +2711,7 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2856
2711
|
"getOntology",
|
|
2857
2712
|
"schemaDocs",
|
|
2858
2713
|
"getSchema",
|
|
2859
|
-
"putSchema"
|
|
2860
|
-
"publishApp"
|
|
2714
|
+
"putSchema"
|
|
2861
2715
|
],
|
|
2862
2716
|
description: "What to do."
|
|
2863
2717
|
},
|
|
@@ -2893,10 +2747,6 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
2893
2747
|
type: "object",
|
|
2894
2748
|
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."
|
|
2895
2749
|
},
|
|
2896
|
-
confirm: {
|
|
2897
|
-
type: "boolean",
|
|
2898
|
-
description: "publishApp: publish even though existing records fail the schemas being published. Omit it first — the refusal lists what would break, and that list is the thing to show the user before asking."
|
|
2899
|
-
},
|
|
2900
2750
|
topic: {
|
|
2901
2751
|
type: "string",
|
|
2902
2752
|
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)."
|
|
@@ -2918,6 +2768,6 @@ function makeManageCollectionTool(deps = {}) {
|
|
|
2918
2768
|
};
|
|
2919
2769
|
}
|
|
2920
2770
|
//#endregion
|
|
2921
|
-
export {
|
|
2771
|
+
export { parseAuthoredApp as $, runQueryOverRows as A, APP_SLUGS_COLLECTION as B, STORE_UNREADABLE as C, recordFieldProblem as D, compileRecordZ as E, readCustomViewI18n as F, appStagingPath as G, appConfigPath as H, readSkillTemplate as I, projectPublish as J, projectApp as K, bindsSubmitterIdentity as L, buildCollectionActionSeedPrompt as M, promptPathsFor as N, runCollectionQuery as O, readCustomViewHtml as P, AuthoredAppZ as Q, publishProblems as R, MAX_RECORD_ISSUES as S, validateRecordObject as T, appSchemasPath as U, PUBLIC_CONFIG_DOC as V, appSlugDoc as W, stagedRuleConfig as X, promoteSchema as Y, APP_ROLES as Z, computeCollectionIcon as _, deleteCollection as a, applyMutateAction as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, buildActionSeedPrompt as j, enrichItems as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, projectDeploy as q, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, buildWorkspaceOntology as v, validateCollectionRecords as w, firstMutateParamProblem as x, schemaRelations as y, APPS_COLLECTION as z };
|
|
2922
2772
|
|
|
2923
|
-
//# sourceMappingURL=server-
|
|
2773
|
+
//# sourceMappingURL=server-zcM1ViCE.js.map
|