@davidorex/pi-context 0.26.0 → 0.28.1
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/CHANGELOG.md +71 -15
- package/README.md +71 -18
- package/dist/block-api.d.ts +165 -18
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +800 -57
- package/dist/block-api.js.map +1 -1
- package/dist/citation-rot-scanner.d.ts +80 -0
- package/dist/citation-rot-scanner.d.ts.map +1 -0
- package/dist/citation-rot-scanner.js +421 -0
- package/dist/citation-rot-scanner.js.map +1 -0
- package/dist/content-hash.d.ts +25 -0
- package/dist/content-hash.d.ts.map +1 -0
- package/dist/content-hash.js +62 -0
- package/dist/content-hash.js.map +1 -0
- package/dist/context-dir.d.ts +126 -1
- package/dist/context-dir.d.ts.map +1 -1
- package/dist/context-dir.js +208 -4
- package/dist/context-dir.js.map +1 -1
- package/dist/context-registry.d.ts +122 -0
- package/dist/context-registry.d.ts.map +1 -0
- package/dist/context-registry.js +189 -0
- package/dist/context-registry.js.map +1 -0
- package/dist/context-sdk.d.ts +179 -12
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +536 -101
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +222 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +244 -56
- package/dist/context.js.map +1 -1
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.d.ts.map +1 -0
- package/dist/git-env.js +29 -0
- package/dist/git-env.js.map +1 -0
- package/dist/index.d.ts +124 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +440 -1125
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +5 -3
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +121 -0
- package/dist/migration-registry-loader.d.ts.map +1 -0
- package/dist/migration-registry-loader.js +309 -0
- package/dist/migration-registry-loader.js.map +1 -0
- package/dist/migrations-store.d.ts +141 -0
- package/dist/migrations-store.d.ts.map +1 -0
- package/dist/migrations-store.js +206 -0
- package/dist/migrations-store.js.map +1 -0
- package/dist/object-store.d.ts +26 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +104 -0
- package/dist/object-store.js.map +1 -0
- package/dist/ops-registry.d.ts +44 -0
- package/dist/ops-registry.d.ts.map +1 -0
- package/dist/ops-registry.js +1223 -0
- package/dist/ops-registry.js.map +1 -0
- package/dist/orientation.d.ts +16 -0
- package/dist/orientation.d.ts.map +1 -0
- package/dist/orientation.js +49 -0
- package/dist/orientation.js.map +1 -0
- package/dist/promote-item.d.ts +28 -0
- package/dist/promote-item.d.ts.map +1 -0
- package/dist/promote-item.js +257 -0
- package/dist/promote-item.js.map +1 -0
- package/dist/read-element.d.ts +129 -0
- package/dist/read-element.d.ts.map +1 -0
- package/dist/read-element.js +296 -0
- package/dist/read-element.js.map +1 -0
- package/dist/rename-canonical-id.d.ts.map +1 -1
- package/dist/rename-canonical-id.js +22 -13
- package/dist/rename-canonical-id.js.map +1 -1
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +23 -13
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/schema-validator.d.ts +1 -0
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +7 -3
- package/dist/schema-validator.js.map +1 -1
- package/dist/schema-write.d.ts +78 -4
- package/dist/schema-write.d.ts.map +1 -1
- package/dist/schema-write.js +297 -34
- package/dist/schema-write.js.map +1 -1
- package/dist/status-vocab.d.ts +1 -0
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +4 -0
- package/dist/status-vocab.js.map +1 -1
- package/dist/truncate.d.ts +34 -0
- package/dist/truncate.d.ts.map +1 -0
- package/dist/truncate.js +85 -0
- package/dist/truncate.js.map +1 -0
- package/dist/write-schema-migration-tool.d.ts +46 -0
- package/dist/write-schema-migration-tool.d.ts.map +1 -0
- package/dist/write-schema-migration-tool.js +115 -0
- package/dist/write-schema-migration-tool.js.map +1 -0
- package/package.json +35 -2
- package/samples/blocks/work-orders.json +3 -0
- package/samples/conception.json +22 -3
- package/samples/migrations.json +133 -0
- package/samples/schemas/context-contracts.schema.json +19 -4
- package/samples/schemas/conventions.schema.json +68 -12
- package/samples/schemas/decisions.schema.json +125 -28
- package/samples/schemas/features.schema.json +140 -26
- package/samples/schemas/framework-gaps.schema.json +171 -26
- package/samples/schemas/issues.schema.json +86 -13
- package/samples/schemas/layer-plans.schema.json +195 -35
- package/samples/schemas/phase.schema.json +18 -3
- package/samples/schemas/rationale.schema.json +37 -7
- package/samples/schemas/requirements.schema.json +77 -10
- package/samples/schemas/research.schema.json +169 -36
- package/samples/schemas/spec-reviews.schema.json +88 -18
- package/samples/schemas/story.schema.json +18 -3
- package/samples/schemas/tasks.schema.json +66 -10
- package/samples/schemas/verification.schema.json +70 -12
- package/samples/schemas/work-orders.schema.json +195 -0
- package/schemas/bootstrap.schema.json +18 -6
- package/schemas/config.schema.json +58 -26
- package/schemas/context-registry.schema.json +46 -0
- package/schemas/migrations.schema.json +123 -0
- package/schemas/relations.schema.json +41 -10
- package/skill-narrative.md +42 -21
- package/skills/pi-context/SKILL.md +159 -65
- package/skills/pi-context/references/bundled-resources.md +7 -2
package/dist/block-api.js
CHANGED
|
@@ -16,12 +16,16 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Future extraction seam for pi-project extension.
|
|
18
18
|
*/
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
19
20
|
import fs from "node:fs";
|
|
20
21
|
import path from "node:path";
|
|
21
22
|
import _lockfile from "proper-lockfile";
|
|
22
|
-
import {
|
|
23
|
+
import { canonicalJson, computeContentHash, sha256Hex } from "./content-hash.js";
|
|
24
|
+
import { assertSubstrateName, resolveContextDir, schemaPathForDir, substrateIdForDir, tryResolveContextDir, } from "./context-dir.js";
|
|
23
25
|
import { stampItem } from "./dispatch-context.js";
|
|
24
|
-
import {
|
|
26
|
+
import { getProjectMigrationRegistryForDir } from "./migration-registry-loader.js";
|
|
27
|
+
import { hasObject, putObject } from "./object-store.js";
|
|
28
|
+
import { validateBlockWithMigrationForDir, validateFromFile } from "./schema-validator.js";
|
|
25
29
|
// Node16 module resolution + CJS interop: default import may be wrapped
|
|
26
30
|
const lockfile = _lockfile.default ?? _lockfile;
|
|
27
31
|
/**
|
|
@@ -41,12 +45,12 @@ function withBlockLock(filePath, fn) {
|
|
|
41
45
|
lockfile.unlockSync(filePath);
|
|
42
46
|
}
|
|
43
47
|
}
|
|
44
|
-
function
|
|
48
|
+
function blockFilePathForDir(substrateDir, blockName) {
|
|
45
49
|
assertSubstrateName(blockName);
|
|
46
|
-
return path.join(
|
|
50
|
+
return path.join(substrateDir, `${blockName}.json`);
|
|
47
51
|
}
|
|
48
|
-
function
|
|
49
|
-
return
|
|
52
|
+
function blockSchemaPathForDir(substrateDir, blockName) {
|
|
53
|
+
return schemaPathForDir(substrateDir, blockName);
|
|
50
54
|
}
|
|
51
55
|
// ── Schema introspection cache (DispatchContext support, FGAP-004) ───────────
|
|
52
56
|
/**
|
|
@@ -57,6 +61,54 @@ function blockSchemaPath(cwd, blockName) {
|
|
|
57
61
|
* validation on injected fields.
|
|
58
62
|
*/
|
|
59
63
|
const AUTHOR_FIELDS = ["created_by", "created_at", "modified_by", "modified_at"];
|
|
64
|
+
/**
|
|
65
|
+
* The MANDATORY content/metadata floor (content-addressed substrate identity,
|
|
66
|
+
* Cycle 3 / carried item 1; v3 spec §A2). The identity/addressing fields
|
|
67
|
+
* `id`, `oid`, `content_hash`, `content_parent` are ALWAYS metadata —
|
|
68
|
+
* EXCLUDED from the content hash — and a schema's `x-identity.metadata_fields`
|
|
69
|
+
* override can NEVER pull them into the content. This is the floor that makes
|
|
70
|
+
* the content hash a faithful identity for content rather than for addressing:
|
|
71
|
+
* a refname rename (`id`), a freshly-minted `oid`, or a recomputed
|
|
72
|
+
* `content_hash` / advanced `content_parent` must never move the hash, or the
|
|
73
|
+
* object store would fork on pure addressing churn. `metadataFieldsForSchema`
|
|
74
|
+
* unions this floor into whatever partition an override declares so the floor
|
|
75
|
+
* is non-overridable by construction.
|
|
76
|
+
*/
|
|
77
|
+
export const MANDATORY_METADATA_FIELDS = new Set([
|
|
78
|
+
"id",
|
|
79
|
+
"oid",
|
|
80
|
+
"content_hash",
|
|
81
|
+
"content_parent",
|
|
82
|
+
]);
|
|
83
|
+
/**
|
|
84
|
+
* The DISCRETIONARY metadata fields (v3 spec §A2): the four author/attestation
|
|
85
|
+
* fields (`AUTHOR_FIELDS`) plus the lifecycle-closure fields (`closed_by`,
|
|
86
|
+
* `closed_at`). These are the fields a schema MAY redefine via an
|
|
87
|
+
* `x-identity.metadata_fields` override — when an override is declared it
|
|
88
|
+
* REPLACES this discretionary set (the floor is still unioned back in); when
|
|
89
|
+
* absent these are the discretionary defaults. Built from `AUTHOR_FIELDS` so
|
|
90
|
+
* the four author strings keep a single source of truth and cannot drift from
|
|
91
|
+
* the stamping path.
|
|
92
|
+
*/
|
|
93
|
+
export const DISCRETIONARY_METADATA_FIELDS = new Set([
|
|
94
|
+
...AUTHOR_FIELDS,
|
|
95
|
+
"closed_by",
|
|
96
|
+
"closed_at",
|
|
97
|
+
]);
|
|
98
|
+
/**
|
|
99
|
+
* The default content/metadata partition when no `x-identity.metadata_fields`
|
|
100
|
+
* override is declared: `MANDATORY ∪ DISCRETIONARY` — the same 10 fields the
|
|
101
|
+
* Cycle-2 surface enumerated (`id`, `oid`, `content_hash`, `content_parent`,
|
|
102
|
+
* the four author fields, `closed_by`, `closed_at`). Retained as the
|
|
103
|
+
* no-override default so the membership is identical to the pre-Cycle-3 set;
|
|
104
|
+
* `metadataFieldsForSchema` now composes it from the two named subsets rather
|
|
105
|
+
* than a flat literal so the mandatory floor is provably a subset of every
|
|
106
|
+
* resolved partition (see that function).
|
|
107
|
+
*/
|
|
108
|
+
export const DEFAULT_METADATA_FIELDS = new Set([
|
|
109
|
+
...MANDATORY_METADATA_FIELDS,
|
|
110
|
+
...DISCRETIONARY_METADATA_FIELDS,
|
|
111
|
+
]);
|
|
60
112
|
const schemaCache = new Map();
|
|
61
113
|
function safeStat(p) {
|
|
62
114
|
try {
|
|
@@ -150,6 +202,222 @@ function collectArrayItemAuthorDecisions(schema, into) {
|
|
|
150
202
|
}
|
|
151
203
|
}
|
|
152
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Read the `x-identity.metadata_fields` override from an item subschema, if
|
|
207
|
+
* present. `x-identity` follows the established `x-prompt-budget` /
|
|
208
|
+
* `x-lifecycle` extension-keyword convention; `metadata_fields` is an array of
|
|
209
|
+
* field names to treat as metadata (excluded from the content hash) for items
|
|
210
|
+
* of this kind. Returns the array as a `Set` when validly declared, else
|
|
211
|
+
* `null` (caller falls back to `DEFAULT_METADATA_FIELDS`). Non-string entries
|
|
212
|
+
* are ignored defensively; a non-array `metadata_fields` yields `null`.
|
|
213
|
+
*/
|
|
214
|
+
function readItemMetadataFieldsOverride(itemSchema) {
|
|
215
|
+
if (!itemSchema || typeof itemSchema !== "object")
|
|
216
|
+
return null;
|
|
217
|
+
const xIdentity = itemSchema["x-identity"];
|
|
218
|
+
if (!xIdentity || typeof xIdentity !== "object")
|
|
219
|
+
return null;
|
|
220
|
+
const fields = xIdentity.metadata_fields;
|
|
221
|
+
if (!Array.isArray(fields))
|
|
222
|
+
return null;
|
|
223
|
+
const out = new Set();
|
|
224
|
+
for (const f of fields) {
|
|
225
|
+
if (typeof f === "string")
|
|
226
|
+
out.add(f);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Walk every `items` subschema reachable from a schema (same traversal shape
|
|
232
|
+
* as `collectArrayItemAuthorDecisions`) and record, per array property name,
|
|
233
|
+
* the RAW `x-identity.metadata_fields` override for that array's items — ONLY
|
|
234
|
+
* when one is declared. A key with no declared override is left ABSENT from
|
|
235
|
+
* `into` (no default is written) so `metadataFieldsForSchema` can distinguish
|
|
236
|
+
* "override declared" from "no override" and apply the mandatory-floor union
|
|
237
|
+
* over the discretionary default at the read site. Mutates `into` in place.
|
|
238
|
+
* Nested arrays appear under their own key. When the same key appears at
|
|
239
|
+
* multiple depths, the first explicitly-declared override encountered is
|
|
240
|
+
* retained (a later declaration does not clobber it).
|
|
241
|
+
*/
|
|
242
|
+
function collectArrayItemMetadataOverrides(schema, into) {
|
|
243
|
+
if (!schema || typeof schema !== "object")
|
|
244
|
+
return;
|
|
245
|
+
const s = schema;
|
|
246
|
+
const props = s.properties;
|
|
247
|
+
if (!props)
|
|
248
|
+
return;
|
|
249
|
+
for (const [propKey, propSpecRaw] of Object.entries(props)) {
|
|
250
|
+
if (!propSpecRaw || typeof propSpecRaw !== "object")
|
|
251
|
+
continue;
|
|
252
|
+
const spec = propSpecRaw;
|
|
253
|
+
if (spec.type === "array") {
|
|
254
|
+
const items = spec.items;
|
|
255
|
+
if (items && typeof items === "object") {
|
|
256
|
+
const itemProps = items.properties;
|
|
257
|
+
if (itemProps) {
|
|
258
|
+
const override = readItemMetadataFieldsOverride(items);
|
|
259
|
+
// Record only an explicit override; absence means "no
|
|
260
|
+
// override" so the read site applies the discretionary
|
|
261
|
+
// default. First explicit declaration wins.
|
|
262
|
+
if (override && !into.has(propKey)) {
|
|
263
|
+
into.set(propKey, override);
|
|
264
|
+
}
|
|
265
|
+
collectArrayItemMetadataOverrides(items, into);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
collectArrayItemMetadataOverrides(spec, into);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The three item fields whose presence (ALL THREE) in an array item's
|
|
276
|
+
* `properties` arms the Cycle-3 identity-stamping path for that array (locked
|
|
277
|
+
* decision 1). Distinct from `MANDATORY_METADATA_FIELDS` (which additionally
|
|
278
|
+
* carries `id`, present on every block item regardless of identity): these are
|
|
279
|
+
* the net-new fields a Cycle-3 schema edit adds, so their joint presence is the
|
|
280
|
+
* signal that a schema opted into identity stamping.
|
|
281
|
+
*/
|
|
282
|
+
const IDENTITY_DECLARATION_FIELDS = ["oid", "content_hash", "content_parent"];
|
|
283
|
+
/**
|
|
284
|
+
* Walk every `items` subschema reachable from a schema (same traversal shape as
|
|
285
|
+
* `collectArrayItemMetadataOverrides`) and record, per array property name,
|
|
286
|
+
* whether the item subschema's `properties` declares ALL THREE
|
|
287
|
+
* `IDENTITY_DECLARATION_FIELDS`. This is the schema-gate cataloguer for
|
|
288
|
+
* `prepareItemIdentityForWrite` (locked decision 1) — mirrors
|
|
289
|
+
* `collectArrayItemAuthorDecisions` for the author-stamp gate. A key absent
|
|
290
|
+
* from `into` (or recorded `false`) means "this array does not stamp identity".
|
|
291
|
+
* `true` is recorded once and never downgraded (first positive declaration
|
|
292
|
+
* across reachable shapes wins, paralleling the author cataloguer's union).
|
|
293
|
+
*/
|
|
294
|
+
function collectArrayItemIdentityDecisions(schema, into) {
|
|
295
|
+
if (!schema || typeof schema !== "object")
|
|
296
|
+
return;
|
|
297
|
+
const s = schema;
|
|
298
|
+
const props = s.properties;
|
|
299
|
+
if (!props)
|
|
300
|
+
return;
|
|
301
|
+
for (const [propKey, propSpecRaw] of Object.entries(props)) {
|
|
302
|
+
if (!propSpecRaw || typeof propSpecRaw !== "object")
|
|
303
|
+
continue;
|
|
304
|
+
const spec = propSpecRaw;
|
|
305
|
+
if (spec.type === "array") {
|
|
306
|
+
const items = spec.items;
|
|
307
|
+
if (items && typeof items === "object") {
|
|
308
|
+
const itemProps = items.properties;
|
|
309
|
+
if (itemProps) {
|
|
310
|
+
const declaresAll = IDENTITY_DECLARATION_FIELDS.every((f) => Object.hasOwn(itemProps, f));
|
|
311
|
+
if (declaresAll) {
|
|
312
|
+
into.set(propKey, true);
|
|
313
|
+
}
|
|
314
|
+
else if (!into.has(propKey)) {
|
|
315
|
+
into.set(propKey, false);
|
|
316
|
+
}
|
|
317
|
+
collectArrayItemIdentityDecisions(items, into);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
collectArrayItemIdentityDecisions(spec, into);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The content/metadata partition for items of array `arrayKey` under `schema`:
|
|
328
|
+
* `MANDATORY_METADATA_FIELDS ∪ (override ?? DISCRETIONARY_METADATA_FIELDS)`
|
|
329
|
+
* (carried item 1 / v3 spec §A2). The item subschema's
|
|
330
|
+
* `x-identity.metadata_fields` override, when declared, REPLACES the
|
|
331
|
+
* discretionary set; the mandatory floor (`id`/`oid`/`content_hash`/
|
|
332
|
+
* `content_parent`) is then unioned back in so an override can never pull a
|
|
333
|
+
* floor field into the content — even an override that omits `id` still
|
|
334
|
+
* excludes the floor, so two items differing only in `id` hash equal under
|
|
335
|
+
* that override. When no override is declared the result equals
|
|
336
|
+
* `DEFAULT_METADATA_FIELDS` (floor ∪ discretionary) byte-for-byte.
|
|
337
|
+
*
|
|
338
|
+
* Single reader — both the cache populate site (`getSchemaCacheEntry` via
|
|
339
|
+
* `collectArrayItemMetadataOverrides`) and `contentProjection` route through
|
|
340
|
+
* this so there is exactly one resolution path and no parallel default.
|
|
341
|
+
* Resolves the item subschema by the same array-key traversal used for
|
|
342
|
+
* author-field decisions, reading the RAW override (not the already-defaulted
|
|
343
|
+
* collected value) so the union is over the override itself.
|
|
344
|
+
*/
|
|
345
|
+
export function metadataFieldsForSchema(schema, arrayKey) {
|
|
346
|
+
const overrides = new Map();
|
|
347
|
+
collectArrayItemMetadataOverrides(schema, overrides);
|
|
348
|
+
const override = overrides.get(arrayKey);
|
|
349
|
+
const discretionary = override ?? DISCRETIONARY_METADATA_FIELDS;
|
|
350
|
+
return new Set([...MANDATORY_METADATA_FIELDS, ...discretionary]);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Human-readable description of how a schema's `x-identity.metadata_fields`
|
|
354
|
+
* override changes the discretionary metadata partition relative to the
|
|
355
|
+
* default — or `null` when NO array item subschema declares an override
|
|
356
|
+
* (carried item 2 / informed-authorization confirm). Pure function: no
|
|
357
|
+
* filesystem, no cache; takes a parsed schema object and inspects every
|
|
358
|
+
* reachable array item subschema for an `x-identity.metadata_fields`
|
|
359
|
+
* declaration.
|
|
360
|
+
*
|
|
361
|
+
* When at least one override is present, returns a one-line-per-array summary
|
|
362
|
+
* naming, per array key, which DISCRETIONARY fields the override DROPS
|
|
363
|
+
* (present in `DISCRETIONARY_METADATA_FIELDS` but absent from the override)
|
|
364
|
+
* and which non-floor fields it ADDS (present in the override, not a floor
|
|
365
|
+
* field, not a default discretionary field). The mandatory floor
|
|
366
|
+
* (`id`/`oid`/`content_hash`/`content_parent`) is never reported as
|
|
367
|
+
* add/drop — it is structurally unaffected by an override — so the caller
|
|
368
|
+
* (auth-gate) can append a standing "floor remains excluded" affirmation.
|
|
369
|
+
*
|
|
370
|
+
* Used by `authGateHandler` to enrich the `write-schema` confirm message; the
|
|
371
|
+
* exact wording is the affordance, not a parse contract.
|
|
372
|
+
*/
|
|
373
|
+
export function describeIdentityOverride(schema) {
|
|
374
|
+
const overrides = new Map();
|
|
375
|
+
collectArrayItemMetadataOverrides(schema, overrides);
|
|
376
|
+
if (overrides.size === 0)
|
|
377
|
+
return null;
|
|
378
|
+
const lines = [];
|
|
379
|
+
for (const [arrayKey, override] of overrides) {
|
|
380
|
+
const dropped = [];
|
|
381
|
+
for (const f of DISCRETIONARY_METADATA_FIELDS) {
|
|
382
|
+
if (!override.has(f))
|
|
383
|
+
dropped.push(f);
|
|
384
|
+
}
|
|
385
|
+
const added = [];
|
|
386
|
+
for (const f of override) {
|
|
387
|
+
if (!MANDATORY_METADATA_FIELDS.has(f) && !DISCRETIONARY_METADATA_FIELDS.has(f))
|
|
388
|
+
added.push(f);
|
|
389
|
+
}
|
|
390
|
+
const parts = [];
|
|
391
|
+
if (added.length > 0)
|
|
392
|
+
parts.push(`treats as metadata (excluded from hash): ${added.join(", ")}`);
|
|
393
|
+
if (dropped.length > 0)
|
|
394
|
+
parts.push(`now hashed (no longer metadata): ${dropped.join(", ")}`);
|
|
395
|
+
if (parts.length === 0)
|
|
396
|
+
parts.push("redefines the discretionary metadata set with no net field-level change");
|
|
397
|
+
lines.push(`array '${arrayKey}': ${parts.join("; ")}`);
|
|
398
|
+
}
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Project an item to its hashable content: a SHALLOW COPY of `item` with the
|
|
403
|
+
* metadata keys (`metadataFieldsForSchema(schema, arrayKey)`) deleted. Does
|
|
404
|
+
* NOT mutate `item`. The result is what Cycle 3 will feed to
|
|
405
|
+
* `computeContentHash`, so a metadata-only mutation (refreshed author stamp,
|
|
406
|
+
* freshly-assigned `oid`, etc.) leaves the projection — and therefore the
|
|
407
|
+
* content hash — unchanged.
|
|
408
|
+
*
|
|
409
|
+
* `schema` is the parsed schema object (not a path), so this is usable both
|
|
410
|
+
* from the cache populate path and from ad-hoc callers / tests holding an
|
|
411
|
+
* inline schema.
|
|
412
|
+
*/
|
|
413
|
+
export function contentProjection(schema, arrayKey, item) {
|
|
414
|
+
const metadataFields = metadataFieldsForSchema(schema, arrayKey);
|
|
415
|
+
const projection = { ...item };
|
|
416
|
+
for (const f of metadataFields) {
|
|
417
|
+
delete projection[f];
|
|
418
|
+
}
|
|
419
|
+
return projection;
|
|
420
|
+
}
|
|
153
421
|
/**
|
|
154
422
|
* Load the cached schema-introspection answer for a schema file, refreshing
|
|
155
423
|
* the cache when the file's mtime changes (or it appears / disappears).
|
|
@@ -188,12 +456,26 @@ function getSchemaCacheEntry(schemaPath) {
|
|
|
188
456
|
hasAuthorFields: false,
|
|
189
457
|
envelopeDeclares: new Set(),
|
|
190
458
|
perArrayKey: new Map(),
|
|
459
|
+
metadataFieldsByArrayKey: new Map(),
|
|
460
|
+
identityFieldsByArrayKey: new Map(),
|
|
191
461
|
};
|
|
192
462
|
schemaCache.set(key, entry);
|
|
193
463
|
return entry;
|
|
194
464
|
}
|
|
195
465
|
const perArrayKey = new Map();
|
|
196
466
|
collectArrayItemAuthorDecisions(schema, perArrayKey);
|
|
467
|
+
// Resolved content/metadata partition per array key (carried item 1).
|
|
468
|
+
// Collect the declared override keys, then resolve each through
|
|
469
|
+
// `metadataFieldsForSchema` so the cached value already carries the
|
|
470
|
+
// mandatory-floor union; identical resolution path as the read site.
|
|
471
|
+
const overrides = new Map();
|
|
472
|
+
collectArrayItemMetadataOverrides(schema, overrides);
|
|
473
|
+
const metadataFieldsByArrayKey = new Map();
|
|
474
|
+
for (const arrayKey of overrides.keys()) {
|
|
475
|
+
metadataFieldsByArrayKey.set(arrayKey, metadataFieldsForSchema(schema, arrayKey));
|
|
476
|
+
}
|
|
477
|
+
const identityFieldsByArrayKey = new Map();
|
|
478
|
+
collectArrayItemIdentityDecisions(schema, identityFieldsByArrayKey);
|
|
197
479
|
const envelopeDeclares = schemaTopLevelDeclaredAuthorFields(schema);
|
|
198
480
|
let anyArrayItemDeclares = false;
|
|
199
481
|
for (const v of perArrayKey.values()) {
|
|
@@ -207,10 +489,26 @@ function getSchemaCacheEntry(schemaPath) {
|
|
|
207
489
|
hasAuthorFields: envelopeDeclares.size > 0 || anyArrayItemDeclares,
|
|
208
490
|
envelopeDeclares,
|
|
209
491
|
perArrayKey,
|
|
492
|
+
metadataFieldsByArrayKey,
|
|
493
|
+
identityFieldsByArrayKey,
|
|
210
494
|
};
|
|
211
495
|
schemaCache.set(key, entry);
|
|
212
496
|
return entry;
|
|
213
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Does the item subschema for `arrayKey` under the schema at `schemaPath`
|
|
500
|
+
* declare all three identity fields (`oid`/`content_hash`/`content_parent`)?
|
|
501
|
+
* The Cycle-3 stamping gate (locked decision 1) — `false` (incl. for a missing
|
|
502
|
+
* schema or an uncatalogued key) means `prepareItemIdentityForWrite` is a
|
|
503
|
+
* no-op for this array. Mirrors `declaredAuthorFieldsForArray`'s
|
|
504
|
+
* cache-backed lookup shape.
|
|
505
|
+
*/
|
|
506
|
+
function arrayDeclaresIdentityFields(schemaPath, arrayKey) {
|
|
507
|
+
const entry = getSchemaCacheEntry(schemaPath);
|
|
508
|
+
if (!entry)
|
|
509
|
+
return false;
|
|
510
|
+
return entry.identityFieldsByArrayKey.get(arrayKey) ?? false;
|
|
511
|
+
}
|
|
214
512
|
/**
|
|
215
513
|
* Subset of `AUTHOR_FIELDS` declared at the schema's top-level envelope.
|
|
216
514
|
* Used by `writeTypedFile` to decide which envelope fields to stamp on a
|
|
@@ -252,6 +550,134 @@ function maybeStampItem(schemaPath, arrayKey, item, ctx, mode) {
|
|
|
252
550
|
return item;
|
|
253
551
|
return stampItem(item, ctx, mode, declared);
|
|
254
552
|
}
|
|
553
|
+
// ── Content-addressed identity stamping (Cycle 3 / Phase C) ──────────────────
|
|
554
|
+
/**
|
|
555
|
+
* Mint a fresh OID for an item being born in `substrateId`. An OID is the
|
|
556
|
+
* substrate-stable, content-INDEPENDENT identity of an item across all its
|
|
557
|
+
* content versions (vs. `content_hash`, which moves with content; vs. `id`,
|
|
558
|
+
* the human refname). Derived as the first 32 hex chars of
|
|
559
|
+
* `sha256Hex(canonicalJson([substrateId, nonce ?? randomUUID()]))`:
|
|
560
|
+
* - salting with `substrateId` makes two substrates that mint with the same
|
|
561
|
+
* nonce produce distinct OIDs (cross-substrate uniqueness — the reason
|
|
562
|
+
* Cycle 3 needs the substrate_id core);
|
|
563
|
+
* - `nonce` is optional and exists for deterministic tests; production calls
|
|
564
|
+
* pass none and get a fresh `randomUUID()` so each birth is unique even
|
|
565
|
+
* within one substrate.
|
|
566
|
+
* 128 bits (32 hex) of digest is collision-free in the item-count regime.
|
|
567
|
+
*
|
|
568
|
+
* RETURN SHAPE: a bare 32-character lowercase-hex digest — no substrate prefix,
|
|
569
|
+
* no colon separator. `substrateId` salts the hash and does NOT appear in the
|
|
570
|
+
* returned value. (The `<substrate_id>:<oid>` colon-form seen elsewhere is the
|
|
571
|
+
* edge dedup key in `endpointIdentity`, not the oid itself.)
|
|
572
|
+
*/
|
|
573
|
+
export function mintOid(substrateId, nonce) {
|
|
574
|
+
const seed = nonce ?? randomUUID();
|
|
575
|
+
return sha256Hex(canonicalJson([substrateId, seed])).slice(0, 32);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Compute the content/identity fields for an item about to be written, per the
|
|
579
|
+
* content-addressed substrate identity model (Cycle 3 / Phase C). Returns the
|
|
580
|
+
* item with `oid` / `content_hash` / `content_parent` set; the input is never
|
|
581
|
+
* mutated (a shallow copy is returned). Also persists the content projection to
|
|
582
|
+
* the object store as a side effect on every stamping write.
|
|
583
|
+
*
|
|
584
|
+
* NO-OP GATE (locked decision 1): when the item's array subschema does not
|
|
585
|
+
* declare all three identity fields, the original `item` is returned unchanged
|
|
586
|
+
* (no oid mint, no hash, no object write, no substrate_id read). This scopes
|
|
587
|
+
* the behavior change to exactly the schemas Cycle 3 edits — bespoke test
|
|
588
|
+
* schemas without the fields are untouched — mirroring `maybeStampItem`'s
|
|
589
|
+
* author-field gate. The gate is NOT ctx-gated: content hash / oid are
|
|
590
|
+
* integrity, not attestation.
|
|
591
|
+
*
|
|
592
|
+
* Ordering vs author-stamp: call AFTER `maybeStampItem`/`maybeStampTypedItem`.
|
|
593
|
+
* Author fields are in the metadata partition (excluded from the projection),
|
|
594
|
+
* so the content hash is invariant to whether author-stamping ran first — but
|
|
595
|
+
* `content_hash` itself is a metadata field, so it must be assigned AFTER the
|
|
596
|
+
* projection is computed from the (already author-stamped) item.
|
|
597
|
+
*
|
|
598
|
+
* `mode`:
|
|
599
|
+
* - `"create"`: mint a fresh `oid` (via `substrateIdForDir(substrateDir)`),
|
|
600
|
+
* compute `content_hash`, set NO `content_parent` (a v1 item has no prior).
|
|
601
|
+
* - `"update"`: preserve `prior.oid` — and THROW if the incoming item carries
|
|
602
|
+
* a different `oid` (locked decision 3: oid is immutable). Recompute
|
|
603
|
+
* `content_hash`. When the content changed (new hash !== prior.content_hash)
|
|
604
|
+
* set `content_parent = prior.content_hash`; when content is unchanged
|
|
605
|
+
* (no-op write) leave `content_parent` at the prior value (not advanced).
|
|
606
|
+
*
|
|
607
|
+
* `prior` is the on-disk item being replaced (required for `"update"`); for
|
|
608
|
+
* `"create"` it is ignored.
|
|
609
|
+
*/
|
|
610
|
+
export function prepareItemIdentityForWrite(substrateDir, blockName, item, schemaPath, arrayKey, mode, prior) {
|
|
611
|
+
// Schema-gate: no-op unless the array's items declare all three identity
|
|
612
|
+
// fields. Cheap cache-backed check; runs before any substrate_id read or
|
|
613
|
+
// hashing so non-identity schemas pay nothing.
|
|
614
|
+
if (!arrayDeclaresIdentityFields(schemaPath, arrayKey)) {
|
|
615
|
+
return item;
|
|
616
|
+
}
|
|
617
|
+
const out = { ...item };
|
|
618
|
+
// `content_hash` is itself a metadata field; project (which drops it +
|
|
619
|
+
// the rest of the floor + discretionary metadata) BEFORE assigning the
|
|
620
|
+
// fresh hash. The schema must be parseable here — the gate passed, so the
|
|
621
|
+
// file exists and is valid JSON (getSchemaCacheEntry already parsed it).
|
|
622
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
|
623
|
+
if (mode === "create") {
|
|
624
|
+
const substrateId = substrateIdForDir(substrateDir);
|
|
625
|
+
out.oid = mintOid(substrateId);
|
|
626
|
+
// content_parent intentionally absent on a v1 item.
|
|
627
|
+
const projection = contentProjection(schema, arrayKey, out);
|
|
628
|
+
const hash = computeContentHash(projection);
|
|
629
|
+
out.content_hash = hash;
|
|
630
|
+
// Object persistence is deferred to writeTypedFile's post-validation walk
|
|
631
|
+
// (Cycle 9.1 P6): stamping must not write to objects/ before the whole
|
|
632
|
+
// block clears AJV, else an AJV-fail leaves an orphan content object.
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
635
|
+
// mode === "update"
|
|
636
|
+
const priorOid = prior && typeof prior.oid === "string" ? prior.oid : undefined;
|
|
637
|
+
const incomingOid = typeof out.oid === "string" ? out.oid : undefined;
|
|
638
|
+
if (priorOid !== undefined && incomingOid !== undefined && incomingOid !== priorOid) {
|
|
639
|
+
throw new Error(`prepareItemIdentityForWrite: oid is immutable — update to block '${blockName}' (array '${arrayKey}') would change oid '${priorOid}' to '${incomingOid}'`);
|
|
640
|
+
}
|
|
641
|
+
// Preserve the prior oid (the canonical, content-independent identity).
|
|
642
|
+
// When there is no prior oid (e.g. an update against an item that predates
|
|
643
|
+
// identity stamping / was never stamped) the path ALWAYS mints — a
|
|
644
|
+
// caller-supplied oid is never honored on first stamp, upholding the
|
|
645
|
+
// "OIDs are system-minted, never caller-asserted" invariant identically to
|
|
646
|
+
// create-mode. (`incomingOid` is still consulted by the immutability throw
|
|
647
|
+
// guard above when a prior oid exists.)
|
|
648
|
+
if (priorOid !== undefined) {
|
|
649
|
+
out.oid = priorOid;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
out.oid = mintOid(substrateIdForDir(substrateDir));
|
|
653
|
+
}
|
|
654
|
+
const priorHash = prior && typeof prior.content_hash === "string" ? prior.content_hash : undefined;
|
|
655
|
+
const priorParent = prior && typeof prior.content_parent === "string" ? prior.content_parent : undefined;
|
|
656
|
+
const projection = contentProjection(schema, arrayKey, out);
|
|
657
|
+
const hash = computeContentHash(projection);
|
|
658
|
+
out.content_hash = hash;
|
|
659
|
+
// Object persistence deferred to writeTypedFile's post-validation walk
|
|
660
|
+
// (Cycle 9.1 P6); see create-mode note above.
|
|
661
|
+
// content_parent advances to the prior content_hash ONLY when content
|
|
662
|
+
// actually changed. On an unchanged-content write (no-op / author-only
|
|
663
|
+
// re-stamp) it is NOT advanced — instead the prior version's own
|
|
664
|
+
// content_parent is PRESERVED (carried forward) so the version chain is not
|
|
665
|
+
// truncated by a metadata-only write. When there is neither a prior hash
|
|
666
|
+
// (first stamping write on a pre-existing item) nor a prior parent, it is
|
|
667
|
+
// left absent.
|
|
668
|
+
if (priorHash !== undefined && priorHash !== hash) {
|
|
669
|
+
out.content_parent = priorHash;
|
|
670
|
+
}
|
|
671
|
+
else if (priorParent !== undefined) {
|
|
672
|
+
out.content_parent = priorParent;
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
// No-op write whose prior had no parent (e.g. re-stamp of a v1 item):
|
|
676
|
+
// ensure a stale content_parent does not linger on the carried item.
|
|
677
|
+
delete out.content_parent;
|
|
678
|
+
}
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
255
681
|
/**
|
|
256
682
|
* Read and parse a .project/{blockName}.json file.
|
|
257
683
|
* Throws if the file does not exist or contains invalid JSON.
|
|
@@ -260,14 +686,14 @@ function maybeStampItem(schemaPath, arrayKey, item, ctx, mode) {
|
|
|
260
686
|
* matching items in the specified array key. Non-array or missing keys return the
|
|
261
687
|
* block unchanged. The filter is applied after parsing, before returning.
|
|
262
688
|
*/
|
|
263
|
-
export function
|
|
264
|
-
const filePath =
|
|
689
|
+
export function readBlockForDir(substrateDir, blockName, filter) {
|
|
690
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
265
691
|
let content;
|
|
266
692
|
try {
|
|
267
693
|
content = fs.readFileSync(filePath, "utf-8");
|
|
268
694
|
}
|
|
269
695
|
catch {
|
|
270
|
-
throw new Error(`Block file not found:
|
|
696
|
+
throw new Error(`Block file not found: ${filePath}`);
|
|
271
697
|
}
|
|
272
698
|
let data;
|
|
273
699
|
try {
|
|
@@ -275,7 +701,24 @@ export function readBlock(cwd, blockName, filter) {
|
|
|
275
701
|
}
|
|
276
702
|
catch (err) {
|
|
277
703
|
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
-
throw new Error(`Invalid JSON in block file:
|
|
704
|
+
throw new Error(`Invalid JSON in block file: ${filePath}: ${msg}`);
|
|
705
|
+
}
|
|
706
|
+
// Version-aware validation hook (FGAP-136 plan step 4). When the block has
|
|
707
|
+
// an existing schema AND the parsed data carries a schema_version field,
|
|
708
|
+
// route through validateBlockWithMigration with the substrate-loaded
|
|
709
|
+
// project MigrationRegistry. The hook is conditional on schema_version
|
|
710
|
+
// presence so pre-versioned blocks (no schema_version field on items)
|
|
711
|
+
// pass through unchanged — the migration boundary only fires when there
|
|
712
|
+
// IS a version assertion to compare against. Whole-block validation
|
|
713
|
+
// uses the parsed `data` as-is; per-array-item migration is out of
|
|
714
|
+
// scope today (the block envelope is what carries schema_version, not
|
|
715
|
+
// each item).
|
|
716
|
+
if (existingBlockSchemaPathForDir(substrateDir, blockName) !== null) {
|
|
717
|
+
const envelope = data;
|
|
718
|
+
if (envelope && typeof envelope === "object" && typeof envelope.schema_version === "string") {
|
|
719
|
+
const registry = getProjectMigrationRegistryForDir(substrateDir);
|
|
720
|
+
data = validateBlockWithMigrationForDir(substrateDir, blockName, envelope, registry);
|
|
721
|
+
}
|
|
279
722
|
}
|
|
280
723
|
if (filter) {
|
|
281
724
|
const record = data;
|
|
@@ -286,13 +729,20 @@ export function readBlock(cwd, blockName, filter) {
|
|
|
286
729
|
}
|
|
287
730
|
return data;
|
|
288
731
|
}
|
|
732
|
+
export function readBlock(cwd, blockName, filter) {
|
|
733
|
+
// Assert the name BEFORE resolving the substrate dir so the FGAP-079
|
|
734
|
+
// path-traversal guard fires ahead of BootstrapNotFoundError (preserves the
|
|
735
|
+
// pre-Phase-0 ordering: name-guard precedes pointer resolution).
|
|
736
|
+
assertSubstrateName(blockName);
|
|
737
|
+
return readBlockForDir(resolveContextDir(cwd), blockName, filter);
|
|
738
|
+
}
|
|
289
739
|
/**
|
|
290
740
|
* Resolve the existing schema path for a block (or null when no schema
|
|
291
741
|
* file is present at the conventional location). Used by every wrapper
|
|
292
742
|
* that delegates to the typed-file primitives.
|
|
293
743
|
*/
|
|
294
|
-
function
|
|
295
|
-
const schemaFile =
|
|
744
|
+
function existingBlockSchemaPathForDir(substrateDir, blockName) {
|
|
745
|
+
const schemaFile = blockSchemaPathForDir(substrateDir, blockName);
|
|
296
746
|
return fs.existsSync(schemaFile) ? schemaFile : null;
|
|
297
747
|
}
|
|
298
748
|
/**
|
|
@@ -358,6 +808,28 @@ export function writeTypedFile(filePath, schemaPath, data, ctx, errorLabel) {
|
|
|
358
808
|
if (schemaPath) {
|
|
359
809
|
validateFromFile(schemaPath, toWrite, label);
|
|
360
810
|
}
|
|
811
|
+
// Post-validation object persistence (Cycle 9.1 P6). Content objects are
|
|
812
|
+
// written to objects/ ONLY after the whole block clears AJV and BEFORE the
|
|
813
|
+
// tmp+rename — so an AJV-fail leaves no orphan object, and a committed block
|
|
814
|
+
// never references a missing object. Stamping (prepareItemIdentityForWrite)
|
|
815
|
+
// computed each item's content_hash but no longer persists; we persist here.
|
|
816
|
+
// Gated on schemaPath: a schema-less write carries no identity items, and
|
|
817
|
+
// the per-item content_hash check protects the non-stamping config/registry/
|
|
818
|
+
// relations/migrations writers (their items carry no content_hash).
|
|
819
|
+
if (schemaPath) {
|
|
820
|
+
const substrateDir = path.dirname(filePath);
|
|
821
|
+
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
|
|
822
|
+
forEachBlockArray(toWrite, (arrayKey, arr) => {
|
|
823
|
+
for (const item of arr) {
|
|
824
|
+
if (item && typeof item === "object" && !Array.isArray(item)) {
|
|
825
|
+
const rec = item;
|
|
826
|
+
if (typeof rec.content_hash === "string" && !hasObject(substrateDir, rec.content_hash)) {
|
|
827
|
+
putObject(substrateDir, rec.content_hash, contentProjection(schema, arrayKey, rec));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
361
833
|
// Ensure directory exists
|
|
362
834
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
363
835
|
// Atomic write: tmp + rename. Callers needing read-modify-write atomicity
|
|
@@ -396,6 +868,77 @@ export function writeTypedFile(filePath, schemaPath, data, ctx, errorLabel) {
|
|
|
396
868
|
* intentionally a no-op until a schema actually declares author fields
|
|
397
869
|
* on a top-level array shape (no current consumer does so).
|
|
398
870
|
*/
|
|
871
|
+
/**
|
|
872
|
+
* Atomic id-uniqueness guard for the append path. Reads the `id` off the
|
|
873
|
+
* incoming item (when it carries one) and rejects the append if any element
|
|
874
|
+
* already present in `arr` shares that id. Items genuinely lacking an `id`
|
|
875
|
+
* property are skipped (NOT fabricated) — mirrors the `"id" in item` gate the
|
|
876
|
+
* tool layer used. The check runs inside the caller's `withBlockLock` critical
|
|
877
|
+
* section against the array already in hand, so it is atomic (unlike a racy
|
|
878
|
+
* `readBlock`-then-`append` at the tool layer). `labelForArray` is the
|
|
879
|
+
* `<label>.<arrayKey>` context the message names.
|
|
880
|
+
*/
|
|
881
|
+
function assertAppendIdUnique(arr, item, labelForArray) {
|
|
882
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
883
|
+
return;
|
|
884
|
+
const rec = item;
|
|
885
|
+
if (!("id" in rec))
|
|
886
|
+
return;
|
|
887
|
+
const id = rec.id;
|
|
888
|
+
for (const existing of arr) {
|
|
889
|
+
if (existing && typeof existing === "object" && existing.id === id) {
|
|
890
|
+
throw new Error(`Item '${String(id)}' already exists in ${labelForArray}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Atomic whole-file id-uniqueness guard: rejects a whole-array write that
|
|
896
|
+
* itself carries two elements sharing an `id`. Only elements that HAVE an `id`
|
|
897
|
+
* property participate (mirrors `assertAppendIdUnique`'s gate); id-less
|
|
898
|
+
* elements are skipped. `labelForArray` is the `<label>.<arrayKey>` context the
|
|
899
|
+
* message names.
|
|
900
|
+
*/
|
|
901
|
+
function assertNoDuplicateIdsInArray(arr, labelForArray) {
|
|
902
|
+
const seen = new Set();
|
|
903
|
+
for (const el of arr) {
|
|
904
|
+
if (!el || typeof el !== "object" || Array.isArray(el))
|
|
905
|
+
continue;
|
|
906
|
+
const rec = el;
|
|
907
|
+
if (!("id" in rec))
|
|
908
|
+
continue;
|
|
909
|
+
const id = rec.id;
|
|
910
|
+
if (seen.has(id)) {
|
|
911
|
+
throw new Error(`Item '${String(id)}' already exists in ${labelForArray}`);
|
|
912
|
+
}
|
|
913
|
+
seen.add(id);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Pure-data recursive walk over every array-valued property at any depth.
|
|
918
|
+
* For a plain (non-array) object `data`, each `[key, value]` pair whose `value`
|
|
919
|
+
* is an array is reported via `visit(key, value)`, then each element of that
|
|
920
|
+
* array that is itself a plain (non-array) object is recursed into (so an
|
|
921
|
+
* item's own nested id-bearing arrays — e.g. `layer-plans` items'
|
|
922
|
+
* `layers` / `migration_phases` — are visited under their item-local key).
|
|
923
|
+
* No schema is consulted; this matches the block shapes the schemas use
|
|
924
|
+
* (top-level arrays of items, items carrying nested arrays of items). Non-array
|
|
925
|
+
* object-valued properties that are NOT array elements are not descended into —
|
|
926
|
+
* block items live in arrays, so only array elements are recursion frontiers.
|
|
927
|
+
*/
|
|
928
|
+
function forEachBlockArray(data, visit) {
|
|
929
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
930
|
+
return;
|
|
931
|
+
for (const [key, value] of Object.entries(data)) {
|
|
932
|
+
if (!Array.isArray(value))
|
|
933
|
+
continue;
|
|
934
|
+
visit(key, value);
|
|
935
|
+
for (const el of value) {
|
|
936
|
+
if (el && typeof el === "object" && !Array.isArray(el)) {
|
|
937
|
+
forEachBlockArray(el, visit);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
399
942
|
export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, errorLabel) {
|
|
400
943
|
const label = errorLabel ?? filePath;
|
|
401
944
|
withBlockLock(filePath, () => {
|
|
@@ -405,9 +948,14 @@ export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, er
|
|
|
405
948
|
if (!Array.isArray(data)) {
|
|
406
949
|
throw new Error(`${label}: expected top-level array, got ${typeof data}`);
|
|
407
950
|
}
|
|
408
|
-
|
|
951
|
+
// Atomic id-uniqueness guard (reads the in-hand array under the lock).
|
|
952
|
+
assertAppendIdUnique(data, item, `${label}.__top__`);
|
|
953
|
+
const authored = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
409
954
|
? maybeStampItem(schemaPath, "__top__", item, ctx, "create")
|
|
410
955
|
: item;
|
|
956
|
+
// Identity-stamp AFTER author-stamp (ordering is hash-neutral; see
|
|
957
|
+
// prepareItemIdentityForWrite). Flat-array shape never identity-stamps.
|
|
958
|
+
const itemToAppend = maybeIdentityStampTypedItem(filePath, schemaPath, null, authored, "create");
|
|
411
959
|
const next = [...data, itemToAppend];
|
|
412
960
|
// Validate the WHOLE array against schemaPath, then write.
|
|
413
961
|
writeTypedFile(filePath, schemaPath, next, undefined, label);
|
|
@@ -424,9 +972,12 @@ export function appendToTypedFile(filePath, schemaPath, arrayPath, item, ctx, er
|
|
|
424
972
|
if (!Array.isArray(record[arrayPath])) {
|
|
425
973
|
throw new Error(`${label} key '${arrayPath}' is not an array`);
|
|
426
974
|
}
|
|
427
|
-
|
|
975
|
+
// Atomic id-uniqueness guard (reads the in-hand array under the lock).
|
|
976
|
+
assertAppendIdUnique(record[arrayPath], item, `${label}.${arrayPath}`);
|
|
977
|
+
const authored = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
428
978
|
? maybeStampItem(schemaPath, arrayPath, item, ctx, "create")
|
|
429
979
|
: item;
|
|
980
|
+
const itemToAppend = maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, authored, "create");
|
|
430
981
|
record[arrayPath] = [...record[arrayPath], itemToAppend];
|
|
431
982
|
writeTypedFile(filePath, schemaPath, record, undefined, label);
|
|
432
983
|
});
|
|
@@ -511,7 +1062,10 @@ export function appendManyToTypedFileIfAbsent(filePath, schemaPath, arrayPath, i
|
|
|
511
1062
|
continue;
|
|
512
1063
|
}
|
|
513
1064
|
seen.add(key);
|
|
514
|
-
|
|
1065
|
+
// Identity-stamp each newly-appended candidate (create mode; an
|
|
1066
|
+
// append-if-absent candidate is always a fresh item). No-op unless
|
|
1067
|
+
// the schema declares identity fields.
|
|
1068
|
+
arr.push(maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, candidate, "create"));
|
|
515
1069
|
appended++;
|
|
516
1070
|
}
|
|
517
1071
|
if (appended > 0) {
|
|
@@ -581,6 +1135,27 @@ function maybeStampTypedItem(schemaPath, arrayPath, item, ctx, mode) {
|
|
|
581
1135
|
const key = arrayPath ?? "__top__";
|
|
582
1136
|
return maybeStampItem(schemaPath, key, item, ctx, mode);
|
|
583
1137
|
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Per-item identity stamping (Cycle 3) for the typed-file mutation primitives.
|
|
1140
|
+
* Thin wrapper over `prepareItemIdentityForWrite` that derives
|
|
1141
|
+
* `substrateDir` / `blockName` from `filePath` (block files live at
|
|
1142
|
+
* `<substrateDir>/<block>.json`) and skips non-object items (a scalar / array
|
|
1143
|
+
* item cannot carry identity fields). The flat-array key (`arrayPath === null`)
|
|
1144
|
+
* maps to the `__top__` sentinel which `collectArrayItemIdentityDecisions`
|
|
1145
|
+
* never populates, so flat-array shapes never identity-stamp — same
|
|
1146
|
+
* intentional no-op as author stamping. NOT ctx-gated: identity is integrity,
|
|
1147
|
+
* not attestation, so this runs whether or not a DispatchContext was supplied;
|
|
1148
|
+
* the schema-gate inside `prepareItemIdentityForWrite` is the only fire/no-op
|
|
1149
|
+
* decision.
|
|
1150
|
+
*/
|
|
1151
|
+
function maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, item, mode, prior) {
|
|
1152
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
1153
|
+
return item;
|
|
1154
|
+
const substrateDir = path.dirname(filePath);
|
|
1155
|
+
const blockName = path.basename(filePath, ".json");
|
|
1156
|
+
const key = arrayPath ?? "__top__";
|
|
1157
|
+
return prepareItemIdentityForWrite(substrateDir, blockName, item, schemaPath, key, mode, prior);
|
|
1158
|
+
}
|
|
584
1159
|
/**
|
|
585
1160
|
* Validated atomic find-by-predicate update of one item inside an array
|
|
586
1161
|
* reachable from `(filePath, schemaPath, arrayPath)`. Generalises
|
|
@@ -615,8 +1190,12 @@ export function updateItemInTypedFile(filePath, schemaPath, arrayPath, predicate
|
|
|
615
1190
|
if (matchCount > 1) {
|
|
616
1191
|
console.error(`[block-api] updateItemInBlock: ${matchCount} items matched predicate, only first updated`);
|
|
617
1192
|
}
|
|
618
|
-
const
|
|
619
|
-
const
|
|
1193
|
+
const prior = arr[idx];
|
|
1194
|
+
const merged = { ...prior, ...updates };
|
|
1195
|
+
const authored = ctx ? maybeStampTypedItem(schemaPath, arrayPath, merged, ctx, "update") : merged;
|
|
1196
|
+
// Identity-stamp in update mode: preserve prior oid (throw on change),
|
|
1197
|
+
// recompute hash, advance content_parent on content change.
|
|
1198
|
+
const updated = maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, authored, "update", prior);
|
|
620
1199
|
const patched = [...arr];
|
|
621
1200
|
patched[idx] = updated;
|
|
622
1201
|
writeTypedFile(filePath, schemaPath, rewriteParent(patched), undefined, label);
|
|
@@ -677,7 +1256,14 @@ export function upsertItemInTypedFile(filePath, schemaPath, arrayPath, item, idF
|
|
|
677
1256
|
itemForStamp = { ...carriedFields, ...item };
|
|
678
1257
|
}
|
|
679
1258
|
}
|
|
680
|
-
const
|
|
1259
|
+
const authored = ctx ? maybeStampTypedItem(schemaPath, arrayPath, itemForStamp, ctx, stampMode) : itemForStamp;
|
|
1260
|
+
// Identity-stamp: append branch is create (no prior); replace branch is
|
|
1261
|
+
// update with the on-disk item as prior, so prepareItemIdentityForWrite
|
|
1262
|
+
// preserves the prior oid even though upsert REPLACES (the supplied item
|
|
1263
|
+
// carries no oid) and advances content_parent on content change. This is
|
|
1264
|
+
// the identity analogue of the FGAP-018 created_* carry-forward above.
|
|
1265
|
+
const priorForIdentity = idx === -1 ? undefined : arr[idx];
|
|
1266
|
+
const stamped = maybeIdentityStampTypedItem(filePath, schemaPath, arrayPath, authored, stampMode, priorForIdentity);
|
|
681
1267
|
const patched = [...arr];
|
|
682
1268
|
if (idx === -1) {
|
|
683
1269
|
patched.push(stamped);
|
|
@@ -753,9 +1339,15 @@ export function appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, pr
|
|
|
753
1339
|
if (!Array.isArray(parent[nestedArrayKey])) {
|
|
754
1340
|
throw new Error(`Matched item in ${label} key '${parentArrayKey}' nested key '${nestedArrayKey}' is not an array`);
|
|
755
1341
|
}
|
|
756
|
-
|
|
1342
|
+
// Atomic id-uniqueness guard on the nested array (Cycle 9.1 P4): reads the
|
|
1343
|
+
// in-hand nested array under the lock. Label names the full parent.nested path.
|
|
1344
|
+
assertAppendIdUnique(parent[nestedArrayKey], item, `${label}.${parentArrayKey}.${nestedArrayKey}`);
|
|
1345
|
+
const authored = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
757
1346
|
? maybeStampItem(schemaPath, nestedArrayKey, item, ctx, "create")
|
|
758
1347
|
: item;
|
|
1348
|
+
// Identity-stamp the appended nested item (create mode), keyed on the
|
|
1349
|
+
// nested array key so the gate consults the nested item subschema.
|
|
1350
|
+
const itemToAppend = maybeIdentityStampTypedItem(filePath, schemaPath, nestedArrayKey, authored, "create");
|
|
759
1351
|
const updatedParent = {
|
|
760
1352
|
...parent,
|
|
761
1353
|
[nestedArrayKey]: [...parent[nestedArrayKey], itemToAppend],
|
|
@@ -813,8 +1405,12 @@ export function updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey
|
|
|
813
1405
|
if (nestedMatchCount > 1) {
|
|
814
1406
|
console.error(`[block-api] updateNestedArrayItem: ${nestedMatchCount} nested items matched predicate, only first updated`);
|
|
815
1407
|
}
|
|
816
|
-
const
|
|
817
|
-
const
|
|
1408
|
+
const priorNested = nestedArr[nestedIdx];
|
|
1409
|
+
const mergedNested = { ...priorNested, ...updates };
|
|
1410
|
+
const authoredNested = ctx ? maybeStampItem(schemaPath, nestedArrayKey, mergedNested, ctx, "update") : mergedNested;
|
|
1411
|
+
// Identity-stamp the updated nested item (update mode; prior = on-disk
|
|
1412
|
+
// nested item) so oid is preserved/immutable and content_parent advances.
|
|
1413
|
+
const updatedNested = maybeIdentityStampTypedItem(filePath, schemaPath, nestedArrayKey, authoredNested, "update", priorNested);
|
|
818
1414
|
const patchedNested = [...nestedArr];
|
|
819
1415
|
patchedNested[nestedIdx] = updatedNested;
|
|
820
1416
|
const updatedParent = { ...parent, [nestedArrayKey]: patchedNested };
|
|
@@ -883,10 +1479,110 @@ export function removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey,
|
|
|
883
1479
|
* stamping; callers wanting per-item attribution should prefer the
|
|
884
1480
|
* array-grained writers.
|
|
885
1481
|
*/
|
|
1482
|
+
export function writeBlockForDir(substrateDir, blockName, data, ctx) {
|
|
1483
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1484
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
1485
|
+
// Whole-block identity stamping (Cycle 3): for every top-level array whose
|
|
1486
|
+
// item subschema declares the identity fields, stamp each item — mint-or-
|
|
1487
|
+
// preserve oid, recompute content_hash, advance content_parent on change.
|
|
1488
|
+
// Prior item state is read from the on-disk block and matched by oid (then
|
|
1489
|
+
// id) so a re-written item preserves its oid and only advances its parent
|
|
1490
|
+
// when its content actually changed. A no-op for non-identity schemas /
|
|
1491
|
+
// non-object data (the gate inside prepareItemIdentityForWrite short-
|
|
1492
|
+
// circuits). Author-only re-stamping is hash-neutral, so a whole-block
|
|
1493
|
+
// re-write that merely refreshes attestation leaves content hashes stable.
|
|
1494
|
+
// Whole-file id-uniqueness guard: a whole-block write carrying two items
|
|
1495
|
+
// sharing an `id` within one array is rejected before stamping/validation.
|
|
1496
|
+
// Recurses through nested id-bearing arrays (Cycle 9.1 P4) so a duplicate id
|
|
1497
|
+
// inside e.g. a `layer-plans` item's `layers` is rejected too.
|
|
1498
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
1499
|
+
forEachBlockArray(data, (arrayKey, arr) => assertNoDuplicateIdsInArray(arr, `${blockName}.${arrayKey}`));
|
|
1500
|
+
}
|
|
1501
|
+
let identityStamped = data;
|
|
1502
|
+
if (schemaPath !== null && data && typeof data === "object" && !Array.isArray(data)) {
|
|
1503
|
+
identityStamped = stampWholeBlockIdentity(substrateDir, blockName, filePath, schemaPath, data);
|
|
1504
|
+
}
|
|
1505
|
+
// Version-aware pre-write validation (FGAP-136 plan step 4). When a
|
|
1506
|
+
// schema is present AND the data envelope carries a schema_version,
|
|
1507
|
+
// run validateBlockWithMigration with the substrate-loaded
|
|
1508
|
+
// MigrationRegistry. A version-mismatch with no declared migration
|
|
1509
|
+
// throws upstream of writeTypedFile so the file never lands; a
|
|
1510
|
+
// declared migration walks the data forward before AJV runs. The
|
|
1511
|
+
// migrated form is what writeTypedFile receives. The ForDir registry +
|
|
1512
|
+
// validation read the TARGET substrate's schema + migrations.json, never
|
|
1513
|
+
// the active dir's.
|
|
1514
|
+
let toWrite = identityStamped;
|
|
1515
|
+
if (schemaPath !== null &&
|
|
1516
|
+
identityStamped &&
|
|
1517
|
+
typeof identityStamped === "object" &&
|
|
1518
|
+
typeof identityStamped.schema_version === "string") {
|
|
1519
|
+
const registry = getProjectMigrationRegistryForDir(substrateDir);
|
|
1520
|
+
toWrite = validateBlockWithMigrationForDir(substrateDir, blockName, identityStamped, registry);
|
|
1521
|
+
}
|
|
1522
|
+
writeTypedFile(filePath, schemaPath, toWrite, ctx, `block file '${blockName}.json'`);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Identity-stamp every item of every top-level array in a whole-block write
|
|
1526
|
+
* (Cycle 3 / locked decision: writeBlockForDir stamps each array item). Reads
|
|
1527
|
+
* the on-disk block (if present) to build a prior-item index keyed by `oid`
|
|
1528
|
+
* (primary) then `id` (fallback), so each re-written item:
|
|
1529
|
+
* - reuses its prior `oid` (immutable; `prepareItemIdentityForWrite` throws
|
|
1530
|
+
* if the incoming item carries a DIFFERENT non-empty oid),
|
|
1531
|
+
* - recomputes its `content_hash`,
|
|
1532
|
+
* - advances `content_parent` to the prior hash only when content changed.
|
|
1533
|
+
* An item with no on-disk match is treated as a fresh create (mints an oid).
|
|
1534
|
+
* Returns a shallow-cloned block; the input is not mutated. Arrays whose item
|
|
1535
|
+
* subschema does not declare the identity fields pass through untouched (the
|
|
1536
|
+
* gate inside `prepareItemIdentityForWrite` short-circuits per item).
|
|
1537
|
+
*/
|
|
1538
|
+
function stampWholeBlockIdentity(substrateDir, blockName, filePath, schemaPath, data) {
|
|
1539
|
+
// Read prior on-disk block directly (no migration hook, no lock — caller
|
|
1540
|
+
// holds the lock when one is needed; a fresh read here just supplies the
|
|
1541
|
+
// prior-item index). Absent file → no priors → every item is a create.
|
|
1542
|
+
let priorData = null;
|
|
1543
|
+
if (fs.existsSync(filePath)) {
|
|
1544
|
+
try {
|
|
1545
|
+
priorData = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
1546
|
+
}
|
|
1547
|
+
catch {
|
|
1548
|
+
priorData = null;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const out = { ...data };
|
|
1552
|
+
for (const [arrayKey, value] of Object.entries(data)) {
|
|
1553
|
+
if (!Array.isArray(value))
|
|
1554
|
+
continue;
|
|
1555
|
+
if (!arrayDeclaresIdentityFields(schemaPath, arrayKey))
|
|
1556
|
+
continue;
|
|
1557
|
+
// Build prior index for this array, keyed by oid then id.
|
|
1558
|
+
const priorByOid = new Map();
|
|
1559
|
+
const priorById = new Map();
|
|
1560
|
+
const priorArr = priorData && Array.isArray(priorData[arrayKey]) ? priorData[arrayKey] : [];
|
|
1561
|
+
for (const p of priorArr) {
|
|
1562
|
+
if (!p || typeof p !== "object")
|
|
1563
|
+
continue;
|
|
1564
|
+
const rec = p;
|
|
1565
|
+
if (typeof rec.oid === "string")
|
|
1566
|
+
priorByOid.set(rec.oid, rec);
|
|
1567
|
+
if (typeof rec.id === "string")
|
|
1568
|
+
priorById.set(rec.id, rec);
|
|
1569
|
+
}
|
|
1570
|
+
out[arrayKey] = value.map((item) => {
|
|
1571
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
1572
|
+
return item;
|
|
1573
|
+
const rec = item;
|
|
1574
|
+
const prior = (typeof rec.oid === "string" ? priorByOid.get(rec.oid) : undefined) ??
|
|
1575
|
+
(typeof rec.id === "string" ? priorById.get(rec.id) : undefined);
|
|
1576
|
+
const mode = prior ? "update" : "create";
|
|
1577
|
+
return prepareItemIdentityForWrite(substrateDir, blockName, rec, schemaPath, arrayKey, mode, prior);
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
return out;
|
|
1581
|
+
}
|
|
886
1582
|
export function writeBlock(cwd, blockName, data, ctx) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
1583
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1584
|
+
assertSubstrateName(blockName);
|
|
1585
|
+
writeBlockForDir(resolveContextDir(cwd), blockName, data, ctx);
|
|
890
1586
|
}
|
|
891
1587
|
/**
|
|
892
1588
|
* Read current file, push item onto data[arrayKey], validate whole file
|
|
@@ -900,9 +1596,9 @@ export function writeBlock(cwd, blockName, data, ctx) {
|
|
|
900
1596
|
* `additionalProperties: false` AJV failures on blocks whose item shape
|
|
901
1597
|
* doesn't carry author markers yet.
|
|
902
1598
|
*/
|
|
903
|
-
export function
|
|
904
|
-
withBlockLock(
|
|
905
|
-
const data =
|
|
1599
|
+
export function appendToBlockForDir(substrateDir, blockName, arrayKey, item, ctx) {
|
|
1600
|
+
withBlockLock(blockFilePathForDir(substrateDir, blockName), () => {
|
|
1601
|
+
const data = readBlockForDir(substrateDir, blockName);
|
|
906
1602
|
if (!data || typeof data !== "object") {
|
|
907
1603
|
throw new Error(`Block '${blockName}' is not an object`);
|
|
908
1604
|
}
|
|
@@ -913,17 +1609,26 @@ export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
|
|
|
913
1609
|
if (!Array.isArray(record[arrayKey])) {
|
|
914
1610
|
throw new Error(`Block '${blockName}' key '${arrayKey}' is not an array`);
|
|
915
1611
|
}
|
|
1612
|
+
// Atomic id-uniqueness guard (reads the in-hand array under the lock).
|
|
1613
|
+
// This primitive writes inline (does NOT route through appendToTypedFile),
|
|
1614
|
+
// so the guard is applied here separately.
|
|
1615
|
+
assertAppendIdUnique(record[arrayKey], item, `${blockName}.${arrayKey}`);
|
|
916
1616
|
// Optional ctx-stamping for object-shaped items (skipped silently for
|
|
917
1617
|
// scalar items even when the schema technically permits author fields —
|
|
918
1618
|
// stamping a string / number is meaningless).
|
|
919
|
-
const schemaPath =
|
|
1619
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
920
1620
|
const itemToAppend = ctx && item && typeof item === "object" && !Array.isArray(item)
|
|
921
1621
|
? maybeStampItem(schemaPath, arrayKey, item, ctx, "create")
|
|
922
1622
|
: item;
|
|
923
1623
|
record[arrayKey] = [...record[arrayKey], itemToAppend];
|
|
924
|
-
|
|
1624
|
+
writeBlockForDir(substrateDir, blockName, record);
|
|
925
1625
|
});
|
|
926
1626
|
}
|
|
1627
|
+
export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
|
|
1628
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1629
|
+
assertSubstrateName(blockName);
|
|
1630
|
+
appendToBlockForDir(resolveContextDir(cwd), blockName, arrayKey, item, ctx);
|
|
1631
|
+
}
|
|
927
1632
|
/**
|
|
928
1633
|
* Find an item in data[arrayKey] by predicate, shallow-merge updates onto it,
|
|
929
1634
|
* validate whole file against schema, write atomically. Throws if no item
|
|
@@ -934,11 +1639,16 @@ export function appendToBlock(cwd, blockName, arrayKey, item, ctx) {
|
|
|
934
1639
|
* update-mode after the shallow merge — `created_by` / `created_at` are
|
|
935
1640
|
* preserved, `modified_by` / `modified_at` refresh.
|
|
936
1641
|
*/
|
|
937
|
-
export function
|
|
938
|
-
const filePath =
|
|
939
|
-
const schemaPath =
|
|
1642
|
+
export function updateItemInBlockForDir(substrateDir, blockName, arrayKey, predicate, updates, ctx) {
|
|
1643
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1644
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
940
1645
|
updateItemInTypedFile(filePath, schemaPath, arrayKey, predicate, updates, ctx, `block file '${blockName}.json'`);
|
|
941
1646
|
}
|
|
1647
|
+
export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates, ctx) {
|
|
1648
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1649
|
+
assertSubstrateName(blockName);
|
|
1650
|
+
updateItemInBlockForDir(resolveContextDir(cwd), blockName, arrayKey, predicate, updates, ctx);
|
|
1651
|
+
}
|
|
942
1652
|
/**
|
|
943
1653
|
* Atomically find-or-append a single item in `data[arrayKey]` keyed by
|
|
944
1654
|
* `idField`. Acquires the block lock; reads the array; locates the first
|
|
@@ -965,11 +1675,16 @@ export function updateItemInBlock(cwd, blockName, arrayKey, predicate, updates,
|
|
|
965
1675
|
* to merge against. Callers that need merge-on-update should continue to
|
|
966
1676
|
* use `updateItemInBlock`.
|
|
967
1677
|
*/
|
|
968
|
-
export function
|
|
969
|
-
const filePath =
|
|
970
|
-
const schemaPath =
|
|
1678
|
+
export function upsertItemInBlockForDir(substrateDir, blockName, arrayKey, item, idField, ctx) {
|
|
1679
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1680
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
971
1681
|
return upsertItemInTypedFile(filePath, schemaPath, arrayKey, item, idField, ctx, `block file '${blockName}.json'`);
|
|
972
1682
|
}
|
|
1683
|
+
export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx) {
|
|
1684
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1685
|
+
assertSubstrateName(blockName);
|
|
1686
|
+
return upsertItemInBlockForDir(resolveContextDir(cwd), blockName, arrayKey, item, idField, ctx);
|
|
1687
|
+
}
|
|
973
1688
|
/**
|
|
974
1689
|
* Atomically append an item to a nested array inside a parent-array item.
|
|
975
1690
|
*
|
|
@@ -982,11 +1697,16 @@ export function upsertItemInBlock(cwd, blockName, arrayKey, item, idField, ctx)
|
|
|
982
1697
|
* file lock, predicate findIndex, multi-match warning, clone-before-write so
|
|
983
1698
|
* the original array remains unmodified if writeBlock throws.
|
|
984
1699
|
*/
|
|
985
|
-
export function
|
|
986
|
-
const filePath =
|
|
987
|
-
const schemaPath =
|
|
1700
|
+
export function appendToNestedArrayForDir(substrateDir, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
|
|
1701
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1702
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
988
1703
|
appendToNestedTypedFile(filePath, schemaPath, parentArrayKey, predicate, nestedArrayKey, item, ctx, `block file '${blockName}.json'`);
|
|
989
1704
|
}
|
|
1705
|
+
export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx) {
|
|
1706
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1707
|
+
assertSubstrateName(blockName);
|
|
1708
|
+
appendToNestedArrayForDir(resolveContextDir(cwd), blockName, parentArrayKey, predicate, nestedArrayKey, item, ctx);
|
|
1709
|
+
}
|
|
990
1710
|
/**
|
|
991
1711
|
* Atomically update a single item inside a nested array on a parent-array
|
|
992
1712
|
* item: locate parent by parentPredicate, locate nested by nestedPredicate,
|
|
@@ -999,11 +1719,16 @@ export function appendToNestedArray(cwd, blockName, parentArrayKey, predicate, n
|
|
|
999
1719
|
* updateItemInBlock convention). Clone-then-write keeps the original arrays
|
|
1000
1720
|
* unmodified if writeBlock throws.
|
|
1001
1721
|
*/
|
|
1002
|
-
export function
|
|
1003
|
-
const filePath =
|
|
1004
|
-
const schemaPath =
|
|
1722
|
+
export function updateNestedArrayItemForDir(substrateDir, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
|
|
1723
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1724
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
1005
1725
|
updateNestedItemInTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx, `block file '${blockName}.json'`);
|
|
1006
1726
|
}
|
|
1727
|
+
export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx) {
|
|
1728
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1729
|
+
assertSubstrateName(blockName);
|
|
1730
|
+
updateNestedArrayItemForDir(resolveContextDir(cwd), blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, updates, ctx);
|
|
1731
|
+
}
|
|
1007
1732
|
/**
|
|
1008
1733
|
* Atomically remove all items matching `predicate` from a top-level array
|
|
1009
1734
|
* inside `data[arrayKey]`, validate the whole file against schema, write
|
|
@@ -1013,11 +1738,16 @@ export function updateNestedArrayItem(cwd, blockName, parentArrayKey, parentPred
|
|
|
1013
1738
|
* without throwing — removal of a non-existent item is treated as an
|
|
1014
1739
|
* idempotent successful no-op, distinct from update which throws on miss.
|
|
1015
1740
|
*/
|
|
1016
|
-
export function
|
|
1017
|
-
const filePath =
|
|
1018
|
-
const schemaPath =
|
|
1741
|
+
export function removeFromBlockForDir(substrateDir, blockName, arrayKey, predicate, ctx) {
|
|
1742
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1743
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
1019
1744
|
return removeFromTypedFile(filePath, schemaPath, arrayKey, predicate, ctx, `block file '${blockName}.json'`);
|
|
1020
1745
|
}
|
|
1746
|
+
export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
|
|
1747
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1748
|
+
assertSubstrateName(blockName);
|
|
1749
|
+
return removeFromBlockForDir(resolveContextDir(cwd), blockName, arrayKey, predicate, ctx);
|
|
1750
|
+
}
|
|
1021
1751
|
/**
|
|
1022
1752
|
* Atomically remove all items matching `nestedPredicate` from a nested array
|
|
1023
1753
|
* inside the parent-array item matched by `parentPredicate`. Validates and
|
|
@@ -1028,11 +1758,16 @@ export function removeFromBlock(cwd, blockName, arrayKey, predicate, ctx) {
|
|
|
1028
1758
|
* mirrors removeFromBlock semantics). Multi-match warning at parent level
|
|
1029
1759
|
* via console.error.
|
|
1030
1760
|
*/
|
|
1031
|
-
export function
|
|
1032
|
-
const filePath =
|
|
1033
|
-
const schemaPath =
|
|
1761
|
+
export function removeFromNestedArrayForDir(substrateDir, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
|
|
1762
|
+
const filePath = blockFilePathForDir(substrateDir, blockName);
|
|
1763
|
+
const schemaPath = existingBlockSchemaPathForDir(substrateDir, blockName);
|
|
1034
1764
|
return removeFromNestedTypedFile(filePath, schemaPath, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx, `block file '${blockName}.json'`);
|
|
1035
1765
|
}
|
|
1766
|
+
export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx) {
|
|
1767
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1768
|
+
assertSubstrateName(blockName);
|
|
1769
|
+
return removeFromNestedArrayForDir(resolveContextDir(cwd), blockName, parentArrayKey, parentPredicate, nestedArrayKey, nestedPredicate, ctx);
|
|
1770
|
+
}
|
|
1036
1771
|
/**
|
|
1037
1772
|
* Read all `.json` files in a `.project/<subdir>/` directory and return the
|
|
1038
1773
|
* parsed contents as a sorted array. Sort order is filesystem-name ascending
|
|
@@ -1044,11 +1779,8 @@ export function removeFromNestedArray(cwd, blockName, parentArrayKey, parentPred
|
|
|
1044
1779
|
* byte-identically; both pi-workflows and the `read-block-dir` registered
|
|
1045
1780
|
* tool consume this single export.
|
|
1046
1781
|
*/
|
|
1047
|
-
export function
|
|
1048
|
-
const
|
|
1049
|
-
if (root === null)
|
|
1050
|
-
return []; // no .pi-context.json pointer → no items (consistent with missing-dir → [])
|
|
1051
|
-
const dirPath = path.join(root, subdir);
|
|
1782
|
+
export function readBlockDirForDir(substrateDir, subdir) {
|
|
1783
|
+
const dirPath = path.join(substrateDir, subdir);
|
|
1052
1784
|
let entries;
|
|
1053
1785
|
try {
|
|
1054
1786
|
entries = fs
|
|
@@ -1068,17 +1800,23 @@ export function readBlockDir(cwd, subdir) {
|
|
|
1068
1800
|
content = fs.readFileSync(filePath, "utf-8");
|
|
1069
1801
|
}
|
|
1070
1802
|
catch {
|
|
1071
|
-
throw new Error(`Cannot read file:
|
|
1803
|
+
throw new Error(`Cannot read file: ${filePath}`);
|
|
1072
1804
|
}
|
|
1073
1805
|
try {
|
|
1074
1806
|
results.push(JSON.parse(content));
|
|
1075
1807
|
}
|
|
1076
1808
|
catch {
|
|
1077
|
-
throw new Error(`Invalid JSON in:
|
|
1809
|
+
throw new Error(`Invalid JSON in: ${filePath}`);
|
|
1078
1810
|
}
|
|
1079
1811
|
}
|
|
1080
1812
|
return results;
|
|
1081
1813
|
}
|
|
1814
|
+
export function readBlockDir(cwd, subdir) {
|
|
1815
|
+
const root = tryResolveContextDir(cwd);
|
|
1816
|
+
if (root === null)
|
|
1817
|
+
return []; // no .pi-context.json pointer → no items (consistent with missing-dir → [])
|
|
1818
|
+
return readBlockDirForDir(root, subdir);
|
|
1819
|
+
}
|
|
1082
1820
|
// ── Item-schema resolution + id allocation (FGAP-083 / FGAP-084) ──────────────
|
|
1083
1821
|
/**
|
|
1084
1822
|
* Resolve the item subschema for a block schema: find the first array property
|
|
@@ -1131,8 +1869,8 @@ export function resolveBlockItemSchema(schema) {
|
|
|
1131
1869
|
* prefix+width parseable. Orchestrator CLIs + the in-pi append tool route
|
|
1132
1870
|
* through this instead of re-implementing allocation.
|
|
1133
1871
|
*/
|
|
1134
|
-
export function
|
|
1135
|
-
const schemaFile =
|
|
1872
|
+
export function nextIdForDir(substrateDir, blockName) {
|
|
1873
|
+
const schemaFile = blockSchemaPathForDir(substrateDir, blockName);
|
|
1136
1874
|
if (!fs.existsSync(schemaFile)) {
|
|
1137
1875
|
throw new Error(`nextId: schema not found for block '${blockName}' at ${schemaFile}`);
|
|
1138
1876
|
}
|
|
@@ -1149,7 +1887,7 @@ export function nextId(cwd, blockName) {
|
|
|
1149
1887
|
}
|
|
1150
1888
|
const prefix = m[1];
|
|
1151
1889
|
const width = Number.parseInt(m[2], 10);
|
|
1152
|
-
const data =
|
|
1890
|
+
const data = readBlockForDir(substrateDir, blockName);
|
|
1153
1891
|
const items = (data[arrayKey] ?? []);
|
|
1154
1892
|
const re = new RegExp(`^${prefix}(\\d+)$`);
|
|
1155
1893
|
let maxN = 0;
|
|
@@ -1164,4 +1902,9 @@ export function nextId(cwd, blockName) {
|
|
|
1164
1902
|
}
|
|
1165
1903
|
return `${prefix}${String(maxN + 1).padStart(width, "0")}`;
|
|
1166
1904
|
}
|
|
1905
|
+
export function nextId(cwd, blockName) {
|
|
1906
|
+
// Name-guard before pointer resolution (FGAP-079 ordering; see readBlock).
|
|
1907
|
+
assertSubstrateName(blockName);
|
|
1908
|
+
return nextIdForDir(resolveContextDir(cwd), blockName);
|
|
1909
|
+
}
|
|
1167
1910
|
//# sourceMappingURL=block-api.js.map
|