gitsheets 1.0.5 → 1.2.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/sheet.js CHANGED
@@ -1,13 +1,18 @@
1
1
  // Sheet — typed handle to one declared sheet in a Repository.
2
2
  // See specs/api/sheet.md and specs/concepts.md.
3
+ import { execFile, spawn } from 'node:child_process';
3
4
  import { runInNewContext } from 'node:vm';
4
- import { ConfigError, IndexError, NotFoundError, PathTemplateError, TransactionError, } from './errors.js';
5
+ import { promisify } from 'node:util';
6
+ import { createPatch as rfc6902CreatePatch } from 'rfc6902';
7
+ import { ConfigError, IndexError, NotFoundError, PathTemplateError, RefError, TransactionError, } from './errors.js';
5
8
  import { mergePatch } from './patch.js';
6
9
  import { Template } from './path-template/index.js';
7
- import { stringifyRecord, parseToml, parseConfigToml } from './toml.js';
10
+ import { parseConfigToml } from './toml.js';
8
11
  import sortKeys from 'sort-keys';
9
12
  import { transactionContext } from './transaction.js';
10
13
  import { validateRecord, } from './validation.js';
14
+ import { getFormat, resolveFormatConfig } from './format/index.js';
15
+ const exec = promisify(execFile);
11
16
  export const RECORD_SHEET_KEY = Symbol.for('gitsheets-sheet');
12
17
  export const RECORD_PATH_KEY = Symbol.for('gitsheets-path');
13
18
  // --- Helpers ---
@@ -131,7 +136,31 @@ async function loadConfig(workspace, configPath) {
131
136
  }
132
137
  schema = schemaRaw;
133
138
  }
134
- const config = { root, path, fields: fieldsClean, schema };
139
+ let format;
140
+ try {
141
+ format = resolveFormatConfig(gitsheet['format']);
142
+ }
143
+ catch (err) {
144
+ throw new ConfigError('config_invalid', `${configPath}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
145
+ }
146
+ // Body-field collision: a sheet whose path template references the body
147
+ // field would render the same key for a record regardless of body content.
148
+ if (format.body !== undefined) {
149
+ if (format.type !== 'markdown' && format.type !== 'mdx') {
150
+ throw new ConfigError('config_invalid', `${configPath}: [gitsheet.format].body only applies to markdown/mdx formats`);
151
+ }
152
+ // Static check: the path template's getFieldNames must not include the
153
+ // body field. Tested against the registered field name only — expressions
154
+ // referencing body are caught lazily during render.
155
+ const fieldNames = Template.fromString(path).getFieldNames();
156
+ if (fieldNames.includes(format.body)) {
157
+ throw new ConfigError('config_invalid', `${configPath}: [gitsheet.format].body = ${JSON.stringify(format.body)} collides with the path template — the body field cannot also identify the record`);
158
+ }
159
+ }
160
+ else if (format.type === 'markdown' || format.type === 'mdx') {
161
+ throw new ConfigError('config_invalid', `${configPath}: [gitsheet.format].body is required when type is "markdown" or "mdx"`);
162
+ }
163
+ const config = { root, path, fields: fieldsClean, schema, format };
135
164
  CONFIG_CACHE.set(node.hash, config);
136
165
  return config;
137
166
  }
@@ -163,15 +192,178 @@ function validateSortRule(sort, configPath, field) {
163
192
  // cache the TOML *text* (not the parsed object) so each reader gets a fresh
164
193
  // parsed copy — avoiding the original cache's v8.serialize/Date-subclass
165
194
  // issue without leaking mutable shared state.
166
- const TOML_TEXT_CACHE = new Map();
167
- async function readBlobTomlCached(blob) {
168
- const cached = TOML_TEXT_CACHE.get(blob.hash);
195
+ const RECORD_TEXT_CACHE = new Map();
196
+ async function readBlobTextCached(blob) {
197
+ const cached = RECORD_TEXT_CACHE.get(blob.hash);
169
198
  if (cached !== undefined)
170
199
  return cached;
171
200
  const text = await blob.read();
172
- TOML_TEXT_CACHE.set(blob.hash, text);
201
+ RECORD_TEXT_CACHE.set(blob.hash, text);
173
202
  return text;
174
203
  }
204
+ // Minimum-viable MIME map — covers the bulk of typical attachment uses
205
+ // (images, audio, video, docs). Unknown extensions get application/octet-stream.
206
+ const MIME_BY_EXT = {
207
+ // Text
208
+ txt: 'text/plain',
209
+ md: 'text/markdown',
210
+ csv: 'text/csv',
211
+ tsv: 'text/tab-separated-values',
212
+ toml: 'application/toml',
213
+ json: 'application/json',
214
+ yaml: 'application/yaml',
215
+ yml: 'application/yaml',
216
+ xml: 'application/xml',
217
+ html: 'text/html',
218
+ htm: 'text/html',
219
+ css: 'text/css',
220
+ js: 'application/javascript',
221
+ ts: 'application/typescript',
222
+ svg: 'image/svg+xml',
223
+ // Images
224
+ jpg: 'image/jpeg',
225
+ jpeg: 'image/jpeg',
226
+ png: 'image/png',
227
+ gif: 'image/gif',
228
+ webp: 'image/webp',
229
+ avif: 'image/avif',
230
+ bmp: 'image/bmp',
231
+ ico: 'image/vnd.microsoft.icon',
232
+ tiff: 'image/tiff',
233
+ // Audio / video
234
+ mp3: 'audio/mpeg',
235
+ wav: 'audio/wav',
236
+ ogg: 'audio/ogg',
237
+ m4a: 'audio/mp4',
238
+ flac: 'audio/flac',
239
+ mp4: 'video/mp4',
240
+ webm: 'video/webm',
241
+ mov: 'video/quicktime',
242
+ // Documents / archives
243
+ pdf: 'application/pdf',
244
+ zip: 'application/zip',
245
+ gz: 'application/gzip',
246
+ tar: 'application/x-tar',
247
+ '7z': 'application/x-7z-compressed',
248
+ };
249
+ function inferMimeType(filename) {
250
+ const dot = filename.lastIndexOf('.');
251
+ if (dot < 0 || dot === filename.length - 1)
252
+ return 'application/octet-stream';
253
+ const ext = filename.slice(dot + 1).toLowerCase();
254
+ return MIME_BY_EXT[ext] ?? 'application/octet-stream';
255
+ }
256
+ function makeAttachmentBlobHandle(gitDir, hash) {
257
+ return {
258
+ hash,
259
+ async read() {
260
+ return new Promise((resolve, reject) => {
261
+ const child = execFile('git', ['cat-file', 'blob', hash], { cwd: gitDir, encoding: 'buffer', maxBuffer: 1024 * 1024 * 1024 }, (err, stdout) => {
262
+ if (err)
263
+ return reject(err);
264
+ resolve(stdout);
265
+ });
266
+ child.stdin?.end();
267
+ });
268
+ },
269
+ stream() {
270
+ const child = spawn('git', ['cat-file', 'blob', hash], { cwd: gitDir });
271
+ child.stdin.end();
272
+ return child.stdout;
273
+ },
274
+ };
275
+ }
276
+ function parseDiffTreeZ(output) {
277
+ // `git diff-tree -z -r --no-commit-id` formats each entry as:
278
+ // :<srcMode> <dstMode> <srcHash> <dstHash> <statusToken>\0<srcPath>\0[<dstPath>\0]
279
+ // The `-z` flag NUL-separates the metadata line from each path, so any
280
+ // path with special characters in it is preserved verbatim. Status R/C
281
+ // emits two paths; everything else emits one.
282
+ const parts = output.split('\0');
283
+ const out = [];
284
+ let i = 0;
285
+ while (i < parts.length) {
286
+ const meta = parts[i];
287
+ if (!meta || !meta.startsWith(':')) {
288
+ i++;
289
+ continue;
290
+ }
291
+ const tokens = meta.slice(1).split(' ');
292
+ if (tokens.length < 5) {
293
+ i++;
294
+ continue;
295
+ }
296
+ const srcMode = tokens[0];
297
+ const dstMode = tokens[1];
298
+ const srcHash = tokens[2];
299
+ const dstHash = tokens[3];
300
+ const statusToken = tokens[4];
301
+ const statusChar = statusToken[0] ?? '';
302
+ const status = mapDiffStatus(statusChar);
303
+ if (!status) {
304
+ i++;
305
+ continue;
306
+ }
307
+ i++;
308
+ const srcPath = parts[i++] ?? '';
309
+ let canonicalPath = srcPath;
310
+ if (statusChar === 'R' || statusChar === 'C') {
311
+ const dstPath = parts[i++] ?? srcPath;
312
+ canonicalPath = dstPath;
313
+ }
314
+ out.push({
315
+ status,
316
+ statusChar,
317
+ srcMode: srcMode === '000000' ? null : srcMode,
318
+ dstMode: dstMode === '000000' ? null : dstMode,
319
+ srcHash: /^0+$/.test(srcHash) ? null : srcHash,
320
+ dstHash: /^0+$/.test(dstHash) ? null : dstHash,
321
+ canonicalPath,
322
+ });
323
+ }
324
+ return out;
325
+ }
326
+ function mapDiffStatus(ch) {
327
+ switch (ch) {
328
+ case 'A':
329
+ return 'added';
330
+ case 'M':
331
+ case 'T':
332
+ return 'modified';
333
+ case 'D':
334
+ return 'deleted';
335
+ case 'R':
336
+ case 'C':
337
+ return 'renamed';
338
+ default:
339
+ return null;
340
+ }
341
+ }
342
+ async function readBlobAsRecord(gitDir, hash, format, formatConfig) {
343
+ try {
344
+ const { stdout } = await exec('git', ['cat-file', 'blob', hash], {
345
+ cwd: gitDir,
346
+ maxBuffer: 64 * 1024 * 1024,
347
+ });
348
+ return format.parse(stdout, formatConfig);
349
+ }
350
+ catch {
351
+ // Blob unreadable or unparsable — caller treats this as "no record".
352
+ return null;
353
+ }
354
+ }
355
+ function stripRecordPath(rawPath, root, extension) {
356
+ let p = rawPath;
357
+ const cleanRoot = root.replace(/^\/+|\/+$/g, '');
358
+ if (cleanRoot && cleanRoot !== '.' && cleanRoot !== '') {
359
+ const prefix = `${cleanRoot}/`;
360
+ if (p.startsWith(prefix))
361
+ p = p.slice(prefix.length);
362
+ }
363
+ if (p.endsWith(extension))
364
+ p = p.slice(0, -extension.length);
365
+ return p;
366
+ }
175
367
  export class Sheet {
176
368
  #repo;
177
369
  #workspace;
@@ -180,6 +372,7 @@ export class Sheet {
180
372
  #configPath;
181
373
  #transaction;
182
374
  #validator;
375
+ #prefix;
183
376
  #indexes = new Map();
184
377
  constructor(opts) {
185
378
  this.#repo = opts.repo;
@@ -189,6 +382,26 @@ export class Sheet {
189
382
  this.#configPath = opts.configPath;
190
383
  this.#transaction = opts.transaction;
191
384
  this.#validator = opts.validator;
385
+ this.#prefix = (opts.prefix ?? '').replace(/^\/+|\/+$/g, '');
386
+ }
387
+ /**
388
+ * The effective tree path where this sheet's records live, formed by
389
+ * joining `config.root` and the optional `prefix`. Normalized; never has
390
+ * leading/trailing slashes.
391
+ */
392
+ #effectiveRoot(config) {
393
+ const root = config.root.replace(/^\/+|\/+$/g, '');
394
+ if (!this.#prefix)
395
+ return root;
396
+ return joinTreePath(root, this.#prefix);
397
+ }
398
+ /** Options to forward when delegating into a `tx.sheet(name, opts)` call. */
399
+ #txDelegateOpts() {
400
+ return this.#prefix ? { prefix: this.#prefix } : {};
401
+ }
402
+ /** Resolve the active `Format` for this sheet's storage type. */
403
+ #getFormat(config) {
404
+ return getFormat(config.format.type);
192
405
  }
193
406
  get name() {
194
407
  return this.#name;
@@ -218,15 +431,24 @@ export class Sheet {
218
431
  throwAborted(signal);
219
432
  const config = await this.readConfig();
220
433
  const template = Template.fromString(config.path);
221
- const sheetRoot = await this.#getSheetRoot(config.root);
434
+ const sheetRoot = await this.#getSheetRoot(this.#effectiveRoot(config));
222
435
  if (!sheetRoot)
223
436
  return;
437
+ const format = this.#getFormat(config);
438
+ const withBody = opts.withBody ?? true;
439
+ const bodyField = config.format.body;
440
+ // Guard: if the consumer asked for body-less reads but their filter
441
+ // references the body field, we'd silently match zero records. Fail
442
+ // loudly at query start instead.
443
+ if (!withBody && bodyField !== undefined && bodyField in filter) {
444
+ throw new TypeError(`Sheet.query: filter references body field ${JSON.stringify(bodyField)} while withBody: false — bodies aren't loaded so the filter would match nothing`);
445
+ }
224
446
  // hologit's TreeObject/BlobObject are structurally compatible with the
225
447
  // path-template tree interface; the casts bridge the type lattices.
226
- for await (const { blob, path: blobPath } of template.queryTree(sheetRoot, filter)) {
448
+ for await (const { blob, path: blobPath } of template.queryTree(sheetRoot, filter, { extension: format.extension })) {
227
449
  if (signal?.aborted)
228
450
  throwAborted(signal);
229
- const record = (await this.#readRecordFromBlob(blob, blobPath));
451
+ const record = (await this.#readRecordFromBlob(blob, blobPath, config, { headerOnly: !withBody }));
230
452
  if (!queryMatches(filter, record))
231
453
  continue;
232
454
  yield record;
@@ -245,6 +467,121 @@ export class Sheet {
245
467
  }
246
468
  return results;
247
469
  }
470
+ /**
471
+ * Hydrate a body-less record returned by `query`/`findByIndex` with its
472
+ * full body. Re-reads the record blob at the path annotation symbol and
473
+ * returns a fresh record with the body field populated.
474
+ *
475
+ * For TOML sheets (no body concept) this returns the input record
476
+ * unchanged after a fresh parse. For markdown/mdx sheets the body field
477
+ * is populated from the on-disk blob.
478
+ *
479
+ * @see specs/behaviors/content-types.md#lazy-body-loading
480
+ */
481
+ async loadBody(record) {
482
+ const recordPath = record[RECORD_PATH_KEY];
483
+ if (typeof recordPath !== 'string') {
484
+ throw new TypeError(`Sheet.loadBody: record is missing the path annotation (RECORD_PATH_KEY) — did it come from query()?`);
485
+ }
486
+ const config = await this.readConfig();
487
+ const format = this.#getFormat(config);
488
+ const fullPath = joinTreePath(this.#effectiveRoot(config), `${recordPath}${format.extension}`);
489
+ const node = await this.#dataTree.getChild(fullPath);
490
+ if (!node || !isBlob(node)) {
491
+ throw new NotFoundError('record_not_found', `Sheet.loadBody: no record blob at ${fullPath}`);
492
+ }
493
+ return (await this.#readRecordFromBlob(node, recordPath, config));
494
+ }
495
+ /**
496
+ * Async iterator of changes between `srcCommitHash` and the current tree,
497
+ * scoped to this sheet's root. `srcCommitHash` accepts a commit hash, a
498
+ * tree hash, or a ref name; if omitted it defaults to the empty tree
499
+ * (every current record yields `status: 'added'`).
500
+ *
501
+ * Currently scoped to `*.toml` entries — attachment-blob diffs are
502
+ * out-of-scope for v1.1.
503
+ *
504
+ * See specs/api/sheet.md#diffFrom and specs/behaviors/attachments.md.
505
+ */
506
+ async *diffFrom(srcCommitHash, opts = {}) {
507
+ const config = await this.readConfig();
508
+ const gitDir = this.#repo.gitDir;
509
+ const { TreeObject } = await import('hologit');
510
+ const srcRef = srcCommitHash ?? TreeObject.getEmptyTreeHash();
511
+ const dstTreeHash = await this.#dataTree.getHash();
512
+ const args = ['diff-tree', '-z', '-r', '-M', '--no-commit-id', srcRef, dstTreeHash];
513
+ const effectiveRoot = this.#effectiveRoot(config);
514
+ if (effectiveRoot && effectiveRoot !== '.') {
515
+ args.push('--', effectiveRoot);
516
+ }
517
+ let stdout;
518
+ try {
519
+ const result = await exec('git', args, {
520
+ cwd: gitDir,
521
+ maxBuffer: 64 * 1024 * 1024,
522
+ });
523
+ stdout = result.stdout;
524
+ }
525
+ catch (err) {
526
+ throw new RefError('ref_not_found', `Sheet.diffFrom: git diff-tree failed for src=${srcRef} dst=${dstTreeHash}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
527
+ }
528
+ const format = this.#getFormat(config);
529
+ const entries = parseDiffTreeZ(stdout);
530
+ for (const entry of entries) {
531
+ // Scope to record files for this sheet's storage format. Attachments
532
+ // (any extension), hidden files, and `.gitsheets/` config blobs are
533
+ // filtered out — they're not records.
534
+ if (!entry.canonicalPath.endsWith(format.extension))
535
+ continue;
536
+ if (entry.canonicalPath.startsWith('.gitsheets/'))
537
+ continue;
538
+ const recordPath = stripRecordPath(entry.canonicalPath, this.#effectiveRoot(config), format.extension);
539
+ const change = {
540
+ path: recordPath,
541
+ status: entry.status,
542
+ srcMode: entry.srcMode,
543
+ dstMode: entry.dstMode,
544
+ srcHash: entry.srcHash,
545
+ dstHash: entry.dstHash,
546
+ };
547
+ if (opts.blobs) {
548
+ if (entry.srcHash) {
549
+ change['srcBlob'] = this.#repo.hologitRepo.createBlob({
550
+ hash: entry.srcHash,
551
+ mode: entry.srcMode ?? '100644',
552
+ });
553
+ }
554
+ if (entry.dstHash) {
555
+ change['dstBlob'] = this.#repo.hologitRepo.createBlob({
556
+ hash: entry.dstHash,
557
+ mode: entry.dstMode ?? '100644',
558
+ });
559
+ }
560
+ }
561
+ if (opts.records || opts.patches) {
562
+ const src = entry.srcHash
563
+ ? await readBlobAsRecord(gitDir, entry.srcHash, format, config.format)
564
+ : null;
565
+ const dst = entry.dstHash
566
+ ? await readBlobAsRecord(gitDir, entry.dstHash, format, config.format)
567
+ : null;
568
+ if (opts.records) {
569
+ if (src !== null)
570
+ change['src'] = src;
571
+ if (dst !== null)
572
+ change['dst'] = dst;
573
+ }
574
+ if (opts.patches) {
575
+ // rfc6902 treats undefined/null specially. For added: src is null,
576
+ // dst is the object → patch is a single `add` op. For deleted: src
577
+ // is the object, dst is null → patch is a single `remove` op. For
578
+ // modified: a sequence of ops describing the diff.
579
+ change['patch'] = rfc6902CreatePatch(src, dst);
580
+ }
581
+ }
582
+ yield change;
583
+ }
584
+ }
248
585
  async pathForRecord(record) {
249
586
  const config = await this.readConfig();
250
587
  return Template.fromString(config.path).render(record);
@@ -270,12 +607,12 @@ export class Sheet {
270
607
  async clear() {
271
608
  if (this.#transaction === undefined) {
272
609
  await this.#repo.transact({ message: `${this.#name} clear` }, async (tx) => {
273
- await tx.sheet(this.#name).clear();
610
+ await tx.sheet(this.#name, this.#txDelegateOpts()).clear();
274
611
  });
275
612
  return;
276
613
  }
277
614
  const config = await this.readConfig();
278
- const sheetTree = await this.#dataTree.getSubtree(config.root, true);
615
+ const sheetTree = await this.#dataTree.getSubtree(this.#effectiveRoot(config), true);
279
616
  if (sheetTree) {
280
617
  const children = await sheetTree.getChildren();
281
618
  const names = [];
@@ -300,9 +637,9 @@ export class Sheet {
300
637
  }
301
638
  return new Sheet(opts);
302
639
  }
303
- async upsert(record) {
640
+ async upsert(record, opts = {}) {
304
641
  if (this.#transaction !== undefined) {
305
- return this.#upsertInTx(this.#transaction, record);
642
+ return this.#upsertInTx(this.#transaction, record, opts);
306
643
  }
307
644
  this.#checkStrictMode();
308
645
  // The tx-bound Sheet returned by tx.sheet(name) doesn't carry this
@@ -326,9 +663,9 @@ export class Sheet {
326
663
  }
327
664
  const tx = transactionContext.getStore();
328
665
  if (tx !== undefined) {
329
- return tx.sheet(this.#name).upsert(validated);
666
+ return tx.sheet(this.#name, this.#txDelegateOpts()).upsert(validated, opts);
330
667
  }
331
- return this.#autoTransact(async (innerTx) => innerTx.sheet(this.#name).upsert(validated), (r) => `${this.#name} upsert ${r.path}`);
668
+ return this.#autoTransact(async (innerTx) => innerTx.sheet(this.#name, this.#txDelegateOpts()).upsert(validated, opts), (r) => `${this.#name} upsert ${r.path}`);
332
669
  }
333
670
  /**
334
671
  * RFC 7396 JSON Merge Patch. Reads the matching record, merges `partial`,
@@ -347,7 +684,10 @@ export class Sheet {
347
684
  if (typeof existingPath === 'string') {
348
685
  merged[RECORD_PATH_KEY] = existingPath;
349
686
  }
350
- return this.upsert(merged);
687
+ // patch may produce a record missing the body field (e.g., `{body: null}`
688
+ // deletes per RFC 7396). The consumer's intent is explicit at the patch
689
+ // call site, so we don't trip the upsert allowMissingBody guard here.
690
+ return this.upsert(merged, { allowMissingBody: true });
351
691
  }
352
692
  async delete(target) {
353
693
  if (this.#transaction !== undefined) {
@@ -357,12 +697,12 @@ export class Sheet {
357
697
  this.#checkStrictMode();
358
698
  const tx = transactionContext.getStore();
359
699
  if (tx !== undefined) {
360
- await tx.sheet(this.#name).delete(target);
700
+ await tx.sheet(this.#name, this.#txDelegateOpts()).delete(target);
361
701
  return;
362
702
  }
363
703
  const path = typeof target === 'string' ? target : await this.pathForRecord(target);
364
704
  await this.#repo.transact({ message: `${this.#name} delete ${path}` }, async (innerTx) => {
365
- await innerTx.sheet(this.#name).delete(target);
705
+ await innerTx.sheet(this.#name, this.#txDelegateOpts()).delete(target);
366
706
  });
367
707
  }
368
708
  defineIndex(name, optsOrFn, maybeFn) {
@@ -414,17 +754,43 @@ export class Sheet {
414
754
  async getAttachment(record, name) {
415
755
  const config = await this.readConfig();
416
756
  const recordPath = typeof record === 'string' ? record : await this.pathForRecord(record);
417
- const node = await this.#dataTree.getChild(joinTreePath(config.root, recordPath, name));
757
+ const node = await this.#dataTree.getChild(joinTreePath(this.#effectiveRoot(config), recordPath, name));
418
758
  return node && isBlob(node) ? node : null;
419
759
  }
420
760
  async getAttachments(record) {
421
761
  const config = await this.readConfig();
422
762
  const recordPath = typeof record === 'string' ? record : await this.pathForRecord(record);
423
- const dir = await this.#dataTree.getChild(joinTreePath(config.root, recordPath));
763
+ const dir = await this.#dataTree.getChild(joinTreePath(this.#effectiveRoot(config), recordPath));
424
764
  if (!dir || !isTree(dir))
425
765
  return null;
426
766
  return dir.getBlobMap();
427
767
  }
768
+ /**
769
+ * Async iterator over a record's attachments. Each yielded item carries
770
+ * `name`, an extension-inferred `mimeType`, and a `blob` handle with
771
+ * `.read()` (returns `Buffer`) and `.stream()` (returns a Readable).
772
+ *
773
+ * The iterator is the friendlier consumer surface for browsing attachments.
774
+ * For programmatic blob-hash access, `getAttachments` remains.
775
+ *
776
+ * See specs/behaviors/attachments.md.
777
+ */
778
+ async *attachments(record) {
779
+ const blobMap = await this.getAttachments(record);
780
+ if (!blobMap)
781
+ return;
782
+ const gitDir = this.#repo.gitDir;
783
+ for (const name in blobMap) {
784
+ const blob = blobMap[name];
785
+ if (!blob)
786
+ continue;
787
+ yield {
788
+ name,
789
+ mimeType: inferMimeType(name),
790
+ blob: makeAttachmentBlobHandle(gitDir, blob.hash),
791
+ };
792
+ }
793
+ }
428
794
  async setAttachment(record, name, blob) {
429
795
  await this.setAttachments(record, { [name]: blob });
430
796
  }
@@ -433,21 +799,78 @@ export class Sheet {
433
799
  this.#checkStrictMode();
434
800
  const tx = transactionContext.getStore();
435
801
  if (tx !== undefined) {
436
- await tx.sheet(this.#name).setAttachments(record, attachments);
802
+ await tx.sheet(this.#name, this.#txDelegateOpts()).setAttachments(record, attachments);
437
803
  return;
438
804
  }
439
805
  await this.#repo.transact({ message: `${this.#name} attachments` }, async (innerTx) => {
440
- await innerTx.sheet(this.#name).setAttachments(record, attachments);
806
+ await innerTx.sheet(this.#name, this.#txDelegateOpts()).setAttachments(record, attachments);
441
807
  });
442
808
  return;
443
809
  }
444
810
  const config = await this.readConfig();
445
811
  const recordPath = typeof record === 'string' ? record : await this.pathForRecord(record);
446
812
  for (const [aName, content] of Object.entries(attachments)) {
447
- await this.#dataTree.writeChild(joinTreePath(config.root, recordPath, aName), content);
813
+ await this.#dataTree.writeChild(joinTreePath(this.#effectiveRoot(config), recordPath, aName), content);
448
814
  }
449
815
  this.#transaction.markMutated();
450
816
  }
817
+ /**
818
+ * Remove a single attachment. Throws `NotFoundError` if the named attachment
819
+ * doesn't exist (so callers can't silently miss bugs). Sibling attachments
820
+ * are left intact. See specs/behaviors/attachments.md.
821
+ */
822
+ async deleteAttachment(record, name) {
823
+ if (this.#transaction === undefined) {
824
+ this.#checkStrictMode();
825
+ const tx = transactionContext.getStore();
826
+ if (tx !== undefined) {
827
+ await tx.sheet(this.#name, this.#txDelegateOpts()).deleteAttachment(record, name);
828
+ return;
829
+ }
830
+ await this.#repo.transact({ message: `${this.#name} deleteAttachment ${name}` }, async (innerTx) => {
831
+ await innerTx.sheet(this.#name, this.#txDelegateOpts()).deleteAttachment(record, name);
832
+ });
833
+ return;
834
+ }
835
+ const config = await this.readConfig();
836
+ const recordPath = typeof record === 'string' ? record : await this.pathForRecord(record);
837
+ const fullPath = joinTreePath(this.#effectiveRoot(config), recordPath, name);
838
+ const existing = await this.#dataTree.getChild(fullPath);
839
+ if (!existing || !isBlob(existing)) {
840
+ throw new NotFoundError('record_not_found', `${this.#name}: no attachment at ${joinTreePath(recordPath, name)}`);
841
+ }
842
+ await this.#dataTree.deleteChild(fullPath);
843
+ this.#transaction.markMutated();
844
+ }
845
+ /**
846
+ * Remove all attachments for a record. No-op if the record has no
847
+ * attachment directory (same idempotent shape as the cascade behavior in
848
+ * `Sheet.delete`). See specs/behaviors/attachments.md.
849
+ */
850
+ async deleteAttachments(record) {
851
+ if (this.#transaction === undefined) {
852
+ this.#checkStrictMode();
853
+ const tx = transactionContext.getStore();
854
+ if (tx !== undefined) {
855
+ await tx.sheet(this.#name, this.#txDelegateOpts()).deleteAttachments(record);
856
+ return;
857
+ }
858
+ await this.#repo.transact({ message: `${this.#name} deleteAttachments` }, async (innerTx) => {
859
+ await innerTx.sheet(this.#name, this.#txDelegateOpts()).deleteAttachments(record);
860
+ });
861
+ return;
862
+ }
863
+ const config = await this.readConfig();
864
+ const recordPath = typeof record === 'string' ? record : await this.pathForRecord(record);
865
+ const fullPath = joinTreePath(this.#effectiveRoot(config), recordPath);
866
+ const existing = await this.#dataTree.getChild(fullPath);
867
+ if (!existing || !isTree(existing)) {
868
+ // No attachment dir — true no-op, don't even mark the tx mutated.
869
+ return;
870
+ }
871
+ await this.#dataTree.deleteChild(fullPath);
872
+ this.#transaction.markMutated();
873
+ }
451
874
  // --- Private helpers ---
452
875
  #checkStrictMode() {
453
876
  if (this.#repo.isStrictMode()) {
@@ -471,9 +894,18 @@ export class Sheet {
471
894
  void message;
472
895
  return staged;
473
896
  }
474
- async #upsertInTx(tx, record) {
897
+ async #upsertInTx(tx, record, opts = {}) {
475
898
  const config = await this.readConfig();
476
899
  const template = Template.fromString(config.path);
900
+ // Body presence guard: upsert is a full-record replace. A markdown sheet
901
+ // whose record omits the body field would silently erase the on-disk
902
+ // body. Require explicit opt-in to make the trade visible.
903
+ const bodyField = config.format.body;
904
+ if (bodyField !== undefined &&
905
+ !opts.allowMissingBody &&
906
+ record[bodyField] === undefined) {
907
+ throw new TypeError(`Sheet.upsert: record is missing the body field ${JSON.stringify(bodyField)}. Pass { allowMissingBody: true } to opt in, or use Sheet.patch for body-preserving frontmatter updates.`);
908
+ }
477
909
  // Validate before normalizing — per specs/behaviors/validation.md order:
478
910
  // JSON Schema → Standard Schema (may transform) → normalize → render → write.
479
911
  let validated = (await validateRecord({
@@ -511,25 +943,27 @@ export class Sheet {
511
943
  throw new IndexError('index_unique_conflict', `unique index "${state.name}" on sheet "${this.#name}": key ${JSON.stringify(key)} is already used by ${ownerPath}`, { conflictingPaths: [ownerPath, recordPath] });
512
944
  }
513
945
  }
946
+ const format = this.#getFormat(config);
514
947
  // Rename: if the source record was loaded from a different path, delete the old one.
515
948
  if (typeof existing === 'string' && existing !== recordPath) {
516
949
  try {
517
- await this.#dataTree.deleteChild(joinTreePath(config.root, `${existing}.toml`));
950
+ await this.#dataTree.deleteChild(joinTreePath(this.#effectiveRoot(config), `${existing}${format.extension}`));
518
951
  }
519
952
  catch {
520
953
  // Old path may not exist — ignore.
521
954
  }
522
955
  }
523
- const toml = stringifyRecord(stripSymbols(normalized));
524
- const blob = await this.#dataTree.writeChild(joinTreePath(config.root, `${recordPath}.toml`), toml);
956
+ const text = await format.serialize(stripSymbols(normalized), config.format);
957
+ const blob = await this.#dataTree.writeChild(joinTreePath(this.#effectiveRoot(config), `${recordPath}${format.extension}`), text);
525
958
  tx.markMutated();
526
959
  this.#invalidateIndexes();
527
960
  return { blob, path: recordPath };
528
961
  }
529
962
  async #deleteInTx(tx, target) {
530
963
  const config = await this.readConfig();
964
+ const format = this.#getFormat(config);
531
965
  const recordPath = typeof target === 'string' ? target : await this.pathForRecord(target);
532
- const fullPath = joinTreePath(config.root, `${recordPath}.toml`);
966
+ const fullPath = joinTreePath(this.#effectiveRoot(config), `${recordPath}${format.extension}`);
533
967
  const existing = await this.#dataTree.getChild(fullPath);
534
968
  if (!existing) {
535
969
  throw new NotFoundError('record_not_found', `${this.#name}: no record at ${recordPath}`);
@@ -539,7 +973,7 @@ export class Sheet {
539
973
  // Per specs/behaviors/attachments.md the attachment dir is deleted in
540
974
  // the same operation.
541
975
  try {
542
- await this.#dataTree.deleteChild(joinTreePath(config.root, recordPath));
976
+ await this.#dataTree.deleteChild(joinTreePath(this.#effectiveRoot(config), recordPath));
543
977
  }
544
978
  catch {
545
979
  // No attachment dir — that's fine.
@@ -570,7 +1004,11 @@ export class Sheet {
570
1004
  return;
571
1005
  state.uniqueMap.clear();
572
1006
  state.multiMap.clear();
573
- for await (const record of this.query()) {
1007
+ // Index builds always use body-less reads. Indexing by body content is
1008
+ // not a supported use case; consumers needing the body should call
1009
+ // sheet.loadBody(record) after findByIndex returns.
1010
+ // @see specs/behaviors/content-types.md#lazy-body-loading
1011
+ for await (const record of this.query({}, { withBody: false })) {
574
1012
  const rawKey = state.keyFn(record);
575
1013
  if (rawKey === undefined || rawKey === null)
576
1014
  continue;
@@ -597,11 +1035,14 @@ export class Sheet {
597
1035
  state.treeHashAtBuild = currentHash;
598
1036
  state.built = true;
599
1037
  }
600
- async #readRecordFromBlob(blob, path) {
601
- const text = await readBlobTomlCached(blob);
1038
+ async #readRecordFromBlob(blob, path, config, opts = {}) {
1039
+ const text = await readBlobTextCached(blob);
1040
+ const format = this.#getFormat(config);
602
1041
  let parsed;
603
1042
  try {
604
- parsed = parseToml(text);
1043
+ parsed = opts.headerOnly
1044
+ ? format.parseHeaderOnly(text, config.format)
1045
+ : format.parse(text, config.format);
605
1046
  }
606
1047
  catch (err) {
607
1048
  throw new ConfigError('config_invalid', `failed to parse record at ${path}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });