filegrc 0.5.0 → 0.6.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/src/files.js CHANGED
@@ -206,8 +206,9 @@ export async function applyResourceBatch(input, changes) {
206
206
  async function applyResourceBatchUnlocked(input, changes = {}) {
207
207
  const creates = changes.create || [];
208
208
  const updates = changes.update || [];
209
+ const moves = changes.movePaths || [];
209
210
  const expectedRevisions = changes.expectedRevisions || {};
210
- if (!Array.isArray(creates) || !Array.isArray(updates) || (!creates.length && !updates.length)) {
211
+ if (!Array.isArray(creates) || !Array.isArray(updates) || !Array.isArray(moves) || (!creates.length && !updates.length && !moves.length)) {
211
212
  throw new Error("A resource batch needs at least one create or update.");
212
213
  }
213
214
  if (Array.isArray(expectedRevisions) || typeof expectedRevisions !== "object") {
@@ -246,6 +247,7 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
246
247
  const existingById = new Map(loaded.entries.map((entry) => [entry.record.id, entry]));
247
248
  const ids = new Set();
248
249
  const writes = [];
250
+ const allowedPathMoves = new Set();
249
251
  for (const record of creates) {
250
252
  validateBatchRecord(record, ids);
251
253
  if (existingById.has(record.id)) throw new Error(`Resource "${record.id}" already exists.`);
@@ -256,24 +258,75 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
256
258
  validateBatchRecord(record, ids);
257
259
  const existing = existingById.get(record.id);
258
260
  if (!existing) throw new Error(`Resource "${record.id}" was not found.`);
259
- if (existing.record.type !== record.type) {
261
+ if (existing.record.type !== record.type && !targetModelVersion) {
260
262
  throw new Error(`Resource "${record.id}" cannot change type.`);
261
263
  }
264
+ if (existing.record.type !== record.type) {
265
+ const targetMarkdownByName = new Map(
266
+ markdownEntries(writeModel, record).map((entry) => [entry.name, entry.path])
267
+ );
268
+ for (const sourceMarkdown of markdownEntries(loaded.model, existing.record)) {
269
+ const targetMarkdown = targetMarkdownByName.get(sourceMarkdown.name);
270
+ if (targetMarkdown && targetMarkdown !== sourceMarkdown.path) {
271
+ allowedPathMoves.add(`${sourceMarkdown.path}\0${targetMarkdown}`);
272
+ }
273
+ }
274
+ }
262
275
  const path = resourcePath(loaded.root, writeModel, record);
263
- const previous = await readFile(path, "utf8");
264
- const mode = (await stat(path)).mode & 0o777;
276
+ const previousPath = existing.path;
277
+ const previous = await readFile(previousPath, "utf8");
278
+ const mode = (await stat(previousPath)).mode & 0o777;
265
279
  assertRevision(
266
280
  previous,
267
281
  expectedRevisions[record.id] || existing.revision,
268
282
  `Resource "${record.id}"`
269
283
  );
270
- writes.push({ operation: "update", path, record, previous, fileMode: mode });
284
+ if (path !== previousPath) {
285
+ try {
286
+ await stat(path);
287
+ throw new Error(`Migration destination for resource "${record.id}" already exists.`);
288
+ } catch (error) {
289
+ if (error.code !== "ENOENT") throw error;
290
+ }
291
+ }
292
+ writes.push({ operation: path === previousPath ? "update" : "move-update", path, previousPath, record, previous, fileMode: mode });
293
+ }
294
+ const pathMoves = [];
295
+ const seenMovePaths = new Set();
296
+ for (const move of moves) {
297
+ if (!move || Array.isArray(move) || typeof move !== "object") {
298
+ throw new Error("Every migration path move must name a companion Markdown source and destination.");
299
+ }
300
+ const moveKey = `${move.from}\0${move.to}`;
301
+ if (!targetModelVersion || !allowedPathMoves.has(moveKey)) {
302
+ throw new Error("A migration path move must match companion Markdown for a resource whose type changes.");
303
+ }
304
+ if (seenMovePaths.has(moveKey)) throw new Error("A migration path move may appear only once.");
305
+ seenMovePaths.add(moveKey);
306
+ const from = resolveDataPath(loaded.root, move.from);
307
+ const to = resolveDataPath(loaded.root, move.to);
308
+ if (from === to) continue;
309
+ const mode = (await stat(from)).mode & 0o777;
310
+ try {
311
+ await stat(to);
312
+ throw new Error(`Migration destination data/${move.to} already exists.`);
313
+ } catch (error) {
314
+ if (error.code !== "ENOENT") throw error;
315
+ }
316
+ pathMoves.push({ from, to, mode });
271
317
  }
272
318
  const written = [];
319
+ const moved = [];
273
320
  try {
274
321
  for (const item of writes) {
275
322
  await writeAtomic(item.path, item.record, { exclusive: item.operation === "create" });
276
323
  written.push(item);
324
+ if (item.operation === "move-update") await rm(item.previousPath);
325
+ }
326
+ for (const item of pathMoves) {
327
+ await mkdir(dirname(item.to), { recursive: true });
328
+ await rename(item.from, item.to);
329
+ moved.push(item);
277
330
  }
278
331
  let validation = null;
279
332
  if (!deferValidation) {
@@ -290,10 +343,21 @@ async function applyResourceBatchUnlocked(input, changes = {}) {
290
343
  };
291
344
  } catch (error) {
292
345
  const rollbackErrors = [];
346
+ for (const item of moved.reverse()) {
347
+ try {
348
+ await mkdir(dirname(item.from), { recursive: true });
349
+ await rename(item.to, item.from);
350
+ } catch (rollbackError) {
351
+ rollbackErrors.push(rollbackError.message);
352
+ }
353
+ }
293
354
  for (const item of written.reverse()) {
294
355
  try {
295
356
  if (item.operation === "create") await rm(item.path, { force: true });
296
- else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
357
+ else if (item.operation === "move-update") {
358
+ await rm(item.path, { force: true });
359
+ await writeTextAtomic(item.previousPath, item.previous, { mode: item.fileMode });
360
+ } else await writeTextAtomic(item.path, item.previous, { mode: item.fileMode });
297
361
  } catch (rollbackError) {
298
362
  rollbackErrors.push(rollbackError.message);
299
363
  }
package/src/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { existsSync } from "node:fs";
3
4
  import { rm } from "node:fs/promises";
4
5
  import { relative, resolve, sep } from "node:path";
@@ -6,13 +7,15 @@ import { performance } from "node:perf_hooks";
6
7
  import { isSafeGitName } from "./git-name.js";
7
8
  import { serializeWorkspaceMutation, withDeferredWorkspaceValidation } from "./mutation.js";
8
9
  import { resolveWorkspaceRoot } from "./paths.js";
9
- import { measureTiming, measureTimingSync, timingEnabled } from "./timing.js";
10
+ import { measureTiming, measureTimingSync, recordTiming, timingEnabled } from "./timing.js";
10
11
  import { fingerprintWorkspace, validateWorkspace } from "./validate.js";
11
12
  import { loadWorkspace } from "./workspace.js";
12
13
 
13
14
  const lastSuccessfulSynchronizations = new Map();
14
15
  const workspaceHistoryCache = new Map();
15
16
  const backgroundSynchronizations = new Map();
17
+ const browserRemotePrefetches = new Map();
18
+ const BROWSER_REMOTE_PREFETCH_MAX_AGE_MS = 30_000;
16
19
  export const BROWSER_VALIDATION = Symbol("filegrc.browserValidation");
17
20
 
18
21
  export function getGitSummary(input = process.cwd()) {
@@ -195,6 +198,34 @@ export async function runBrowserMutation(input, options, task) {
195
198
  });
196
199
  }
197
200
 
201
+ export async function prefetchBrowserRemote(input = process.cwd(), options = {}) {
202
+ return serializeWorkspaceMutation(input, async (root) => {
203
+ const config = await getRepositoryConfig(root);
204
+ if (config.mode !== "trunk" || options.allowNonAuthoritativeWrites === true) {
205
+ return { status: "not-needed", token: null, fetchedAt: null, expiresAt: null };
206
+ }
207
+ measureTimingSync("git-preconditions", () => requireTrunkPreconditions(root, config));
208
+ await fetchConfiguredRemote(root, config.remote);
209
+ const summary = getGitSummary(root);
210
+ const repository = inspectTrunkRepository(root, config, summary);
211
+ const fetchedAt = new Date().toISOString();
212
+ const token = randomUUID();
213
+ browserRemotePrefetches.set(root, {
214
+ token,
215
+ remote: config.remote,
216
+ currentCommit: summary.commit,
217
+ upstreamCommit: repository.upstreamCommit,
218
+ fetchedAt: Date.parse(fetchedAt)
219
+ });
220
+ return {
221
+ status: "checked",
222
+ token,
223
+ fetchedAt,
224
+ expiresAt: new Date(Date.parse(fetchedAt) + BROWSER_REMOTE_PREFETCH_MAX_AGE_MS).toISOString()
225
+ };
226
+ });
227
+ }
228
+
198
229
  export async function retryBrowserSync(input = process.cwd(), options = {}) {
199
230
  return serializeWorkspaceMutation(input, async (root) => {
200
231
  const config = await getRepositoryConfig(root);
@@ -238,9 +269,12 @@ export async function retryBrowserSync(input = process.cwd(), options = {}) {
238
269
  }
239
270
 
240
271
  async function runTrunkMutationUnlocked(root, config, options, task) {
241
- requireTrunkPreconditions(root, config);
242
- await fetchConfiguredRemote(root, config.remote);
243
- let synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
272
+ const beforeFetch = measureTimingSync("git-preconditions", () => requireTrunkPreconditions(root, config));
273
+ let synchronized = beforeFetch;
274
+ if (!consumeFreshBrowserRemotePrefetch(root, config, options?.prefetchToken, beforeFetch)) {
275
+ await fetchConfiguredRemote(root, config.remote);
276
+ synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
277
+ }
244
278
  if (synchronized.ahead > 0 && synchronized.behind > 0) {
245
279
  throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
246
280
  }
@@ -259,16 +293,18 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
259
293
  let subject;
260
294
  let validationProof;
261
295
  try {
262
- result = await withDeferredWorkspaceValidation(() => task(root));
296
+ result = await measureTiming("write", () => withDeferredWorkspaceValidation(() => task(root)));
263
297
  subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
264
298
  const validation = await validateWorkspace(root);
265
299
  if (!validation.ok) {
266
300
  throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
267
301
  }
268
- validationProof = {
269
- validation,
270
- fingerprint: (await fingerprintWorkspace(validation.loaded)).fingerprint
271
- };
302
+ validationProof = options?.includeValidationProof === false
303
+ ? null
304
+ : {
305
+ validation,
306
+ fingerprint: (await measureTiming("fingerprint", () => fingerprintWorkspace(validation.loaded))).fingerprint
307
+ };
272
308
  assertNoOutsideWorktreeChanges(root);
273
309
  } catch (error) {
274
310
  try {
@@ -279,7 +315,8 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
279
315
  throw error;
280
316
  }
281
317
 
282
- if (!getGitSummary(root).changes.length && options?.allowNoChanges === true) {
318
+ const changed = getGitSummary(root).changes.length > 0;
319
+ if (!changed && options?.allowNoChanges === true) {
283
320
  return withValidationProof({
284
321
  ...result,
285
322
  synchronization: {
@@ -292,13 +329,15 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
292
329
  }
293
330
  }, validationProof);
294
331
  }
295
- if (!getGitSummary(root).changes.length) {
332
+ if (!changed) {
296
333
  throw new Error("The browser action did not change any FileGRC workspace files.");
297
334
  }
298
335
  if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
299
336
  throw new Error("Configure git user.name and git user.email before browser changes can be committed. The saved files remain uncommitted and later browser changes are blocked.");
300
337
  }
301
- gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
338
+ measureTimingSync("stage", () => {
339
+ gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
340
+ });
302
341
  assertNoOutsideWorktreeChanges(root, false);
303
342
  assertOnlyWorkspaceFilesStaged(root);
304
343
  try {
@@ -325,12 +364,30 @@ async function runTrunkMutationUnlocked(root, config, options, task) {
325
364
  }
326
365
 
327
366
  function withValidationProof(result, proof) {
328
- if (result && typeof result === "object") {
367
+ if (proof && result && typeof result === "object") {
329
368
  Object.defineProperty(result, BROWSER_VALIDATION, { value: proof });
330
369
  }
331
370
  return result;
332
371
  }
333
372
 
373
+ function consumeFreshBrowserRemotePrefetch(root, config, token, repository) {
374
+ const prefetch = browserRemotePrefetches.get(root);
375
+ browserRemotePrefetches.delete(root);
376
+ if (
377
+ !token
378
+ || !prefetch
379
+ || prefetch.token !== token
380
+ || prefetch.remote !== config.remote
381
+ || Date.now() - prefetch.fetchedAt > BROWSER_REMOTE_PREFETCH_MAX_AGE_MS
382
+ ) {
383
+ return false;
384
+ }
385
+ const reusable = repository.currentCommit === prefetch.currentCommit
386
+ && repository.upstreamCommit === prefetch.upstreamCommit;
387
+ if (reusable) recordTiming("fetch-reused", 0);
388
+ return reusable;
389
+ }
390
+
334
391
  function queueBackgroundPush(root, config, committed, delayMs = 0) {
335
392
  backgroundSynchronizations.set(root, {
336
393
  status: "syncing",
package/src/index.js CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  getFileHistory,
42
42
  getGitSummary,
43
43
  getWorkspaceHistories,
44
+ prefetchBrowserRemote,
44
45
  pullWorkspace,
45
46
  pushWorkspace,
46
47
  retryBrowserSync,