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/cli/formats.d.ts +30 -0
- package/dist/cli/formats.d.ts.map +1 -0
- package/dist/cli/formats.js +145 -0
- package/dist/cli/formats.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +660 -41
- package/dist/cli/index.js.map +1 -1
- package/dist/format/index.d.ts +49 -0
- package/dist/format/index.d.ts.map +1 -0
- package/dist/format/index.js +60 -0
- package/dist/format/index.js.map +1 -0
- package/dist/format/markdown.d.ts +3 -0
- package/dist/format/markdown.d.ts.map +1 -0
- package/dist/format/markdown.js +131 -0
- package/dist/format/markdown.js.map +1 -0
- package/dist/format/toml.d.ts +3 -0
- package/dist/format/toml.d.ts.map +1 -0
- package/dist/format/toml.js +16 -0
- package/dist/format/toml.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/path-template/index.d.ts +21 -1
- package/dist/path-template/index.d.ts.map +1 -1
- package/dist/path-template/index.js +67 -5
- package/dist/path-template/index.js.map +1 -1
- package/dist/repository.d.ts +9 -0
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +13 -4
- package/dist/repository.js.map +1 -1
- package/dist/sheet.d.ts +130 -1
- package/dist/sheet.d.ts.map +1 -1
- package/dist/sheet.js +475 -34
- package/dist/sheet.js.map +1 -1
- package/dist/transaction.d.ts +7 -3
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +6 -3
- package/dist/transaction.js.map +1 -1
- package/package.json +5 -1
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
167
|
-
async function
|
|
168
|
-
const cached =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
524
|
-
const blob = await this.#dataTree.writeChild(joinTreePath(config
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 });
|