@ontrails/core 1.0.0-beta.24 → 1.0.0-beta.29

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # @ontrails/core
2
2
 
3
+ ## 1.0.0-beta.29
4
+
5
+ ## 1.0.0-beta.28
6
+
7
+ ## 1.0.0-beta.27
8
+
9
+ ## 1.0.0-beta.26
10
+
11
+ ### Patch Changes
12
+
13
+ - 1307568: Centralize Trails config module path conventions, move local config overrides to root `trails.config.local.*`, scaffold the matching gitignore entries, and load project-local Warden rules from `.trails/rules.ts` or `.trails/rules/`.
14
+ - 371d19e: Move the default `trails.db` location to the per-user Trails state store, expose deterministic state-store path helpers, stop scaffolding disposable `.trails/cache` and `.trails/state` directories, and update topo-store documentation for the global-state substrate.
15
+
16
+ ## 1.0.0-beta.25
17
+
18
+ ### Patch Changes
19
+
20
+ - c36aca9: Preserve existing Result error boundaries directly and widen Warden pass-through
21
+ coaching beyond trail blazes.
22
+ - 3befcf1: Configure Trails SQLite read and write connections with a busy timeout so concurrent artifact readers and writers wait through transient lock contention instead of failing immediately.
23
+ - a4f9cf6: Reserve the `shift` error category and `WorkspaceShiftError` before the stable
24
+ cutover so surface mappings can distinguish moved-workspace retry verdicts.
25
+ Update Warden's error-mapping completeness examples to cover the reserved
26
+ category.
27
+ - 9bcf34e: Add trail-owned CLI command projection metadata and serialize resolved command
28
+ route facts for downstream tools.
29
+
3
30
  ## 1.0.0-beta.24
4
31
 
5
32
  ## 1.0.0-beta.23
package/README.md CHANGED
@@ -129,6 +129,7 @@ The current taxonomy is generated from the `errorClasses` owner registry and cat
129
129
  | `timeout` | 5 | 504 | -32603 | Yes | `TimeoutError` |
130
130
  | `rate_limit` | 6 | 429 | -32603 | Yes | `RateLimitError` |
131
131
  | `network` | 7 | 502 | -32603 | Yes | `NetworkError` |
132
+ | `shift` | 10 | 503 | -32603 | Yes | `WorkspaceShiftError` |
132
133
  | `internal` | 8 | 500 | -32603 | No | `AssertionError`, `InternalError`, `DerivationError`, `RecoverableCompletionError` |
133
134
  | `auth` | 9 | 401 | -32600 | No | `AuthError` |
134
135
  | `cancelled` | 130 | 499 | -32603 | No | `CancelledError` |
@@ -161,14 +162,14 @@ The developer returns `Result.err(new NotFoundError(...))`. The framework maps i
161
162
  The root package also exposes a few low-level contracts that other framework packages build on:
162
163
 
163
164
  - **Intrinsic tracing** -- `TraceRecord`, `TraceSink`, `TraceContext`, and the sink registry helpers are the core-owned execution record shape shared by `@ontrails/observe`, `@ontrails/tracing`, and adapters.
164
- - **Trails DB** -- `deriveTrailsDbPath`, `deriveTrailsDir`, `ensureSubsystemSchema`, `openReadTrailsDb`, and `openWriteTrailsDb` are the generic database primitive used by framework subsystems.
165
+ - **Trails DB** -- `deriveTrailsDbPath`, `deriveTrailsStateDir`, `deriveTrailsStateHome`, `deriveTrailsProjectKey`, `deriveTrailsDir`, `ensureSubsystemSchema`, `openReadTrailsDb`, and `openWriteTrailsDb` are the generic database primitive used by framework subsystems.
165
166
  - **Surface projection helpers** -- safe error projection, layer field projection, compose-batch validation, late-bound signal references, and Zod default-wrapper stripping are stable root exports for first-party surfaces, store helpers, and tests.
166
167
 
167
168
  See the [API Reference](../../docs/api-reference.md) for the full list.
168
169
 
169
170
  ## Migration: topo-store moved to `@ontrails/topographer`
170
171
 
171
- Per [ADR-0042](../../docs/adr/0042-core-topographer-boundary-doctrine.md), the topo-store public API previously exported from `@ontrails/core` now lives in `@ontrails/topographer`. Generic `trails-db` helpers (`openReadTrailsDb`, `openWriteTrailsDb`, `ensureSubsystemSchema`, `deriveTrailsDbPath`, `deriveTrailsDir`) stay in core because tracing and other subsystems share them.
172
+ Per [ADR-0042](../../docs/adr/0042-core-topographer-boundary-doctrine.md), the topo-store public API previously exported from `@ontrails/core` now lives in `@ontrails/topographer`. Generic `trails-db` helpers (`openReadTrailsDb`, `openWriteTrailsDb`, `ensureSubsystemSchema`, `deriveTrailsDbPath`, `deriveTrailsStateDir`, `deriveTrailsStateHome`, `deriveTrailsProjectKey`, `deriveTrailsDir`) stay in core because tracing and other subsystems share them.
172
173
 
173
174
  Update consumer imports:
174
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ontrails/core",
3
- "version": "1.0.0-beta.24",
3
+ "version": "1.0.0-beta.29",
4
4
  "files": [
5
5
  "src/**/*.ts",
6
6
  "!src/**/__tests__/**",
package/src/derive.ts CHANGED
@@ -50,6 +50,57 @@ export interface FieldOverride {
50
50
  | undefined;
51
51
  }
52
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // CLI command route projection
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Authored CLI command path shape. Strings are split on whitespace. */
58
+ export type CliCommandPathInput = string | readonly string[];
59
+
60
+ /**
61
+ * Authored CLI command alias shape.
62
+ *
63
+ * String aliases are sibling leaf aliases. Array aliases are absolute command
64
+ * paths.
65
+ */
66
+ export type CliCommandAliasInput = string | readonly string[];
67
+
68
+ /** Source that produced a resolved CLI command route. */
69
+ export type CliCommandRouteSource = 'derived' | 'trail' | 'surface';
70
+
71
+ /** Whether a resolved CLI command route is canonical or an alias. */
72
+ export type CliCommandRouteKind = 'alias' | 'canonical';
73
+
74
+ /** Trail-authored CLI projection metadata. */
75
+ export interface TrailCliProjection {
76
+ readonly aliases?: readonly CliCommandAliasInput[] | undefined;
77
+ readonly path?: CliCommandPathInput | undefined;
78
+ }
79
+
80
+ /** A resolved command path accepted by a CLI surface for one trail. */
81
+ export interface CliCommandRoute {
82
+ readonly kind: CliCommandRouteKind;
83
+ readonly path: readonly string[];
84
+ readonly source: CliCommandRouteSource;
85
+ readonly target: string;
86
+ }
87
+
88
+ /** Resolved CLI projection for one trail. */
89
+ export interface TrailCliCommandProjection {
90
+ readonly path: readonly string[];
91
+ readonly routes: readonly CliCommandRoute[];
92
+ }
93
+
94
+ interface TrailCliProjectionInput {
95
+ readonly cli?: CliCommandPathInput | TrailCliProjection | undefined;
96
+ readonly id: string;
97
+ }
98
+
99
+ export interface DeriveTrailCliCommandProjectionOptions {
100
+ readonly aliases?: readonly CliCommandAliasInput[] | undefined;
101
+ readonly aliasSource?: Extract<CliCommandRouteSource, 'surface' | 'trail'>;
102
+ }
103
+
53
104
  // ---------------------------------------------------------------------------
54
105
  // Zod v4 internals accessor
55
106
  // ---------------------------------------------------------------------------
@@ -214,6 +265,149 @@ export const deriveCliPath = (trailId: string): string[] => {
214
265
  return segments;
215
266
  };
216
267
 
268
+ const hasWhitespace = (value: string): boolean => /\s/.test(value);
269
+
270
+ const validateCliSegment = (segment: string, context: string): string => {
271
+ const normalized = segment.trim();
272
+ if (normalized.length === 0) {
273
+ throw new ValidationError(`${context} cannot contain empty segments`);
274
+ }
275
+ if (hasWhitespace(normalized)) {
276
+ throw new ValidationError(
277
+ `${context} segment "${segment}" cannot contain whitespace`
278
+ );
279
+ }
280
+ return normalized;
281
+ };
282
+
283
+ const splitCliPathString = (value: string, context: string): string[] => {
284
+ const segments = value
285
+ .trim()
286
+ .split(/\s+/)
287
+ .filter((segment) => segment.length > 0);
288
+ if (segments.length === 0) {
289
+ throw new ValidationError(`${context} cannot be empty`);
290
+ }
291
+ return segments.map((segment) => validateCliSegment(segment, context));
292
+ };
293
+
294
+ /** Normalize an authored CLI command path. */
295
+ export const normalizeCliCommandPath = (
296
+ value: CliCommandPathInput,
297
+ context = 'CLI command path'
298
+ ): readonly string[] =>
299
+ typeof value === 'string'
300
+ ? splitCliPathString(value, context)
301
+ : value.map((segment) => validateCliSegment(segment, context));
302
+
303
+ const isTrailCliProjection = (
304
+ value: CliCommandPathInput | TrailCliProjection
305
+ ): value is TrailCliProjection =>
306
+ typeof value !== 'string' &&
307
+ !Array.isArray(value) &&
308
+ value !== null &&
309
+ typeof value === 'object';
310
+
311
+ const trailCliProjectionFor = (
312
+ trail: TrailCliProjectionInput
313
+ ): TrailCliProjection | undefined => {
314
+ if (trail.cli === undefined) {
315
+ return undefined;
316
+ }
317
+ return isTrailCliProjection(trail.cli) ? trail.cli : { path: trail.cli };
318
+ };
319
+
320
+ const deriveCanonicalCliRoute = (
321
+ trail: TrailCliProjectionInput
322
+ ): CliCommandRoute => {
323
+ const projection = trailCliProjectionFor(trail);
324
+ const path =
325
+ projection?.path === undefined
326
+ ? deriveCliPath(trail.id)
327
+ : normalizeCliCommandPath(
328
+ projection.path,
329
+ `CLI command path for trail "${trail.id}"`
330
+ );
331
+ return {
332
+ kind: 'canonical',
333
+ path,
334
+ source: projection?.path === undefined ? 'derived' : 'trail',
335
+ target: trail.id,
336
+ };
337
+ };
338
+
339
+ const normalizeCliAlias = ({
340
+ alias,
341
+ canonicalPath,
342
+ source,
343
+ target,
344
+ }: {
345
+ readonly alias: CliCommandAliasInput;
346
+ readonly canonicalPath: readonly string[];
347
+ readonly source: Extract<CliCommandRouteSource, 'surface' | 'trail'>;
348
+ readonly target: string;
349
+ }): CliCommandRoute => {
350
+ const context = `CLI command alias for trail "${target}"`;
351
+ if (typeof alias === 'string') {
352
+ const segment = alias.trim();
353
+ if (segment.length === 0) {
354
+ throw new ValidationError(`${context} cannot be empty`);
355
+ }
356
+ if (hasWhitespace(segment)) {
357
+ throw new ValidationError(
358
+ `${context} must be a single command segment; use a string array for absolute paths`
359
+ );
360
+ }
361
+ return {
362
+ kind: 'alias',
363
+ path: [
364
+ ...canonicalPath.slice(0, -1),
365
+ validateCliSegment(segment, context),
366
+ ],
367
+ source,
368
+ target,
369
+ };
370
+ }
371
+ return {
372
+ kind: 'alias',
373
+ path: normalizeCliCommandPath(alias, context),
374
+ source,
375
+ target,
376
+ };
377
+ };
378
+
379
+ /** Derive resolved CLI command routes for one trail. */
380
+ export const deriveTrailCliCommandProjection = (
381
+ trail: TrailCliProjectionInput,
382
+ options?: DeriveTrailCliCommandProjectionOptions
383
+ ): TrailCliCommandProjection => {
384
+ const canonical = deriveCanonicalCliRoute(trail);
385
+ const projection = trailCliProjectionFor(trail);
386
+ const trailAliases =
387
+ projection?.aliases?.map((alias) =>
388
+ normalizeCliAlias({
389
+ alias,
390
+ canonicalPath: canonical.path,
391
+ source: 'trail',
392
+ target: trail.id,
393
+ })
394
+ ) ?? [];
395
+ const surfaceAliases =
396
+ options?.aliases?.map((alias) =>
397
+ normalizeCliAlias({
398
+ alias,
399
+ canonicalPath: canonical.path,
400
+ source: options.aliasSource ?? 'surface',
401
+ target: trail.id,
402
+ })
403
+ ) ?? [];
404
+
405
+ return {
406
+ path: canonical.path,
407
+ routes: [canonical, ...trailAliases, ...surfaceAliases],
408
+ };
409
+ };
410
+
217
411
  // ---------------------------------------------------------------------------
218
412
  // Public API
219
413
  // ---------------------------------------------------------------------------
package/src/errors.ts CHANGED
@@ -18,6 +18,7 @@ export const errorCategories = [
18
18
  'timeout',
19
19
  'rate_limit',
20
20
  'network',
21
+ 'shift',
21
22
  'internal',
22
23
  'auth',
23
24
  'cancelled',
@@ -179,6 +180,22 @@ export class NetworkError extends TrailsError {
179
180
  readonly retryable = true as const;
180
181
  }
181
182
 
183
+ /**
184
+ * @example
185
+ * ```ts
186
+ * return Result.err(new WorkspaceShiftError('workspace changed during check'));
187
+ * ```
188
+ *
189
+ * Raised when the observed workspace substrate moves during one run.
190
+ *
191
+ * A shift voids the run's verdict, including passes. Callers can retry on
192
+ * stable ground without changing their request.
193
+ */
194
+ export class WorkspaceShiftError extends TrailsError {
195
+ readonly category = 'shift' as const;
196
+ readonly retryable = true as const;
197
+ }
198
+
182
199
  export class InternalError extends TrailsError {
183
200
  readonly category: ErrorCategory = 'internal';
184
201
  readonly retryable = false as const;
@@ -344,6 +361,12 @@ export const errorClasses = [
344
361
  name: 'NetworkError',
345
362
  retryable: true,
346
363
  },
364
+ {
365
+ category: 'shift',
366
+ ctor: WorkspaceShiftError,
367
+ name: 'WorkspaceShiftError',
368
+ retryable: true,
369
+ },
347
370
  {
348
371
  category: 'internal',
349
372
  ctor: InternalError,
@@ -397,6 +420,7 @@ export const codesByCategory = {
397
420
  not_found: { exit: 2, http: 404, jsonRpc: -32_601 },
398
421
  permission: { exit: 4, http: 403, jsonRpc: -32_600 },
399
422
  rate_limit: { exit: 6, http: 429, jsonRpc: -32_603 },
423
+ shift: { exit: 10, http: 503, jsonRpc: -32_603 },
400
424
  timeout: { exit: 5, http: 504, jsonRpc: -32_603 },
401
425
  validation: { exit: 1, http: 400, jsonRpc: -32_602 },
402
426
  } as const satisfies Record<ErrorCategory, ErrorCategoryCodes>;
@@ -414,6 +438,7 @@ const deriveCodeMap = <TCode extends keyof ErrorCategoryCodes>(
414
438
  not_found: codesByCategory.not_found[code],
415
439
  permission: codesByCategory.permission[code],
416
440
  rate_limit: codesByCategory.rate_limit[code],
441
+ shift: codesByCategory.shift[code],
417
442
  timeout: codesByCategory.timeout[code],
418
443
  validation: codesByCategory.validation[code],
419
444
  });
@@ -436,6 +461,7 @@ export const retryableMap: Record<ErrorCategory, boolean> = {
436
461
  not_found: false,
437
462
  permission: false,
438
463
  rate_limit: true,
464
+ shift: true,
439
465
  timeout: true,
440
466
  validation: false,
441
467
  } as const;
package/src/execute.ts CHANGED
@@ -388,7 +388,7 @@ const prepareContext = async (
388
388
  );
389
389
  const permitted = enforcePermitRequirement(trail, baseCtx);
390
390
  if (permitted.isErr()) {
391
- return Result.err(permitted.error);
391
+ return permitted;
392
392
  }
393
393
 
394
394
  const resources = await createResources(
@@ -1412,7 +1412,7 @@ const executeRequestedTrailVersion = async (
1412
1412
 
1413
1413
  const resolved = resolveTrailVersion(trail, reference);
1414
1414
  if (resolved.isErr()) {
1415
- return Result.err(resolved.error);
1415
+ return resolved;
1416
1416
  }
1417
1417
 
1418
1418
  if (resolved.value.current) {
@@ -1496,14 +1496,14 @@ const validateContextLayerInputs = (
1496
1496
  ): Result<TrailContext, ValidationError> => {
1497
1497
  const layerInputs = readContextLayerInputs(ctx);
1498
1498
  if (layerInputs.isErr()) {
1499
- return Result.err(layerInputs.error);
1499
+ return layerInputs;
1500
1500
  }
1501
1501
  if (layerInputs.value === undefined) {
1502
1502
  return Result.ok(ctx);
1503
1503
  }
1504
1504
  const validated = validateLayerInputs(layers, layerInputs.value);
1505
1505
  if (validated.isErr()) {
1506
- return Result.err(validated.error);
1506
+ return validated;
1507
1507
  }
1508
1508
  return Result.ok({
1509
1509
  ...ctx,
@@ -1540,7 +1540,7 @@ const executeTrailInternal = async (
1540
1540
 
1541
1541
  const resolvedCtx = await prepareContext(trail, options);
1542
1542
  if (resolvedCtx.isErr()) {
1543
- return Result.err(resolvedCtx.error);
1543
+ return resolvedCtx;
1544
1544
  }
1545
1545
 
1546
1546
  const layers = composeAttachedLayers(trail, options);
@@ -1550,7 +1550,7 @@ const executeTrailInternal = async (
1550
1550
  layers
1551
1551
  );
1552
1552
  if (layerCtx.isErr()) {
1553
- return Result.err(layerCtx.error);
1553
+ return layerCtx;
1554
1554
  }
1555
1555
  return await runTrail(
1556
1556
  trail,
package/src/fire.ts CHANGED
@@ -1003,7 +1003,7 @@ export const createFireFn = (
1003
1003
  traceSink
1004
1004
  );
1005
1005
  if (dispatch.isErr()) {
1006
- return Result.err(dispatch.error);
1006
+ return dispatch;
1007
1007
  }
1008
1008
  await recordSignalLifecycleTrace(
1009
1009
  trackedProducerCtx,
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  TimeoutError,
18
18
  RateLimitError,
19
19
  NetworkError,
20
+ WorkspaceShiftError,
20
21
  InternalError,
21
22
  AuthError,
22
23
  CancelledError,
@@ -391,6 +392,9 @@ export type { Topo, TopoIdentity } from './topo.js';
391
392
  export {
392
393
  deriveTrailsDbPath,
393
394
  deriveTrailsDir,
395
+ deriveTrailsProjectKey,
396
+ deriveTrailsStateDir,
397
+ deriveTrailsStateHome,
394
398
  ensureSubsystemSchema,
395
399
  ensureTrailsWorkspace,
396
400
  openReadTrailsDb,
@@ -441,8 +445,24 @@ export type {
441
445
  } from './layer-projection.js';
442
446
 
443
447
  // Derive
444
- export { deriveCliPath, deriveFields } from './derive.js';
445
- export type { Field, FieldOverride } from './derive.js';
448
+ export {
449
+ deriveCliPath,
450
+ deriveFields,
451
+ deriveTrailCliCommandProjection,
452
+ normalizeCliCommandPath,
453
+ } from './derive.js';
454
+ export type {
455
+ CliCommandAliasInput,
456
+ CliCommandPathInput,
457
+ CliCommandRoute,
458
+ CliCommandRouteKind,
459
+ CliCommandRouteSource,
460
+ DeriveTrailCliCommandProjectionOptions,
461
+ Field,
462
+ FieldOverride,
463
+ TrailCliCommandProjection,
464
+ TrailCliProjection,
465
+ } from './derive.js';
446
466
 
447
467
  // Compose schema
448
468
  export { buildComposeValidationSchema } from './compose-schema.js';
@@ -401,7 +401,7 @@ const doCreateResourceInstance = async (
401
401
  try {
402
402
  const created = await declaredResource.create(resourceContext);
403
403
  if (created.isErr()) {
404
- return Result.err(created.error);
404
+ return created;
405
405
  }
406
406
 
407
407
  const instance = created.unwrap();
@@ -591,7 +591,7 @@ export const createScheduleRuntime = (
591
591
 
592
592
  const validated = validateTopo(graph);
593
593
  if (validated.isErr()) {
594
- return Result.err(validated.error);
594
+ return validated;
595
595
  }
596
596
 
597
597
  const registrations = collectScheduleActivations(graph);
@@ -18,6 +18,7 @@ import {
18
18
  RateLimitError,
19
19
  RetryExhaustedError,
20
20
  TimeoutError,
21
+ WorkspaceShiftError,
21
22
  errorClasses,
22
23
  isTrailsError,
23
24
  } from './errors.js';
@@ -89,6 +90,7 @@ const errorFactories: Record<ErrorCategory, ErrorFactory> = {
89
90
  }
90
91
  return new RateLimitError(msg, rlOpts);
91
92
  },
93
+ shift: (msg, opts) => new WorkspaceShiftError(msg, opts),
92
94
  timeout: (msg, opts) => new TimeoutError(msg, opts),
93
95
  validation: (msg, opts) => new ValidationError(msg, opts),
94
96
  };
package/src/trail.ts CHANGED
@@ -12,7 +12,11 @@ import {
12
12
  isActivationSource,
13
13
  } from './activation-source.js';
14
14
  import type { AnyContour } from './contour.js';
15
- import type { FieldOverride } from './derive.js';
15
+ import type {
16
+ FieldOverride,
17
+ CliCommandPathInput,
18
+ TrailCliProjection,
19
+ } from './derive.js';
16
20
  import type { Layer } from './layer.js';
17
21
  import type { Result } from './result.js';
18
22
  import type { AnyResource } from './resource.js';
@@ -413,6 +417,8 @@ export interface TrailSpec<
413
417
  readonly layers?: readonly Layer[] | undefined;
414
418
  /** Per-field overrides for deriveFields() (labels, hints, options) */
415
419
  readonly fields?: Readonly<Record<string, FieldOverride>> | undefined;
420
+ /** CLI projection metadata for canonical command path overrides and aliases. */
421
+ readonly cli?: CliCommandPathInput | TrailCliProjection | undefined;
416
422
  /** Contours this trail operates on. */
417
423
  readonly contours?: readonly AnyContour[] | undefined;
418
424
  /** IDs or trail objects of downstream trails this trail may invoke via ctx.compose() */
package/src/trails-db.ts CHANGED
@@ -1,44 +1,36 @@
1
1
  import { Database } from 'bun:sqlite';
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { dirname, join, resolve } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, join, resolve } from 'node:path';
4
6
 
5
7
  import { NotFoundError } from './errors.js';
6
8
 
7
9
  const TRAILS_DIR = '.trails';
8
10
  const TRAILS_DB_FILE = 'trails.db';
9
- const TRAILS_CACHE_DIR = 'cache';
10
- const TRAILS_STATE_DIR = 'state';
11
+ const TRAILS_STORE_DIR = 'trails';
12
+ const TRAILS_PROJECTS_DIR = 'projects';
11
13
  const SCHEMA_VERSION_TABLE = 'meta_schema_versions';
12
- const WORKSPACE_SUBDIRS = [TRAILS_CACHE_DIR, TRAILS_STATE_DIR] as const;
14
+ const SQLITE_BUSY_TIMEOUT_MS = 5000;
15
+ const PROJECT_KEY_HASH_LENGTH = 16;
16
+ const PROJECT_KEY_NAME_FALLBACK = 'project';
13
17
 
14
18
  /**
15
- * The canonical lines written to a freshly-bootstrapped
16
- * `.trails/.gitignore`. Kept as the source of truth for every consumer that
17
- * needs to either write the file (scaffold) or audit its content (tests).
19
+ * Legacy no-op compatibility export.
18
20
  *
19
- * @see {@link WORKSPACE_GITIGNORE_CONTENT} for the rendered string form.
21
+ * `.trails/` is committed project control, not disposable cache/state. New
22
+ * code should not write a `.trails/.gitignore`; keep this export available for
23
+ * older callers during the pre-1.0 cutover.
20
24
  */
21
- export const WORKSPACE_GITIGNORE_LINES = [
22
- '# Local config overrides',
23
- 'config.local.js',
24
- 'config.local.ts',
25
- '',
26
- '# Rebuildable cache',
27
- 'cache/',
28
- '',
29
- '# Mutable runtime state',
30
- 'state/',
31
- '',
32
- ] as const;
25
+ export const WORKSPACE_GITIGNORE_LINES = [] as const;
33
26
 
34
27
  /**
35
- * The canonical rendered `.trails/.gitignore` content. Use this when writing
36
- * the file eagerly (e.g. during `trails create` scaffolding) or when asserting
37
- * on the workspace bootstrap output.
28
+ * Legacy no-op compatibility export. See {@link WORKSPACE_GITIGNORE_LINES}.
38
29
  */
39
- export const WORKSPACE_GITIGNORE_CONTENT = `${WORKSPACE_GITIGNORE_LINES.join('\n').trimEnd()}\n`;
30
+ export const WORKSPACE_GITIGNORE_CONTENT = '';
40
31
 
41
32
  export interface TrailsDbLocationOptions {
33
+ readonly env?: Record<string, string | undefined>;
42
34
  readonly path?: string;
43
35
  readonly rootDir?: string;
44
36
  }
@@ -56,74 +48,81 @@ interface SchemaVersionRow {
56
48
  const deriveRootDir = (rootDir?: string): string =>
57
49
  resolve(rootDir ?? process.cwd());
58
50
 
59
- export const deriveTrailsDir = (options?: TrailsDbLocationOptions): string =>
60
- join(deriveRootDir(options?.rootDir), TRAILS_DIR);
51
+ const sanitizeProjectKeyName = (name: string): string => {
52
+ const normalized = name.replaceAll(/[^a-zA-Z0-9._-]+/g, '-');
53
+ return normalized.length > 0 ? normalized : PROJECT_KEY_NAME_FALLBACK;
54
+ };
61
55
 
62
- export const deriveTrailsDbPath = (options?: TrailsDbLocationOptions): string =>
63
- options?.path
64
- ? resolve(options.path)
65
- : join(deriveTrailsDir(options), TRAILS_STATE_DIR, TRAILS_DB_FILE);
56
+ const projectHash = (rootDir: string): string =>
57
+ createHash('sha256')
58
+ .update(rootDir)
59
+ .digest('hex')
60
+ .slice(0, PROJECT_KEY_HASH_LENGTH);
66
61
 
67
- const ensureDbParentDir = (dbPath: string): void => {
68
- mkdirSync(dirname(dbPath), { recursive: true });
62
+ export const deriveTrailsProjectKey = (
63
+ options?: TrailsDbLocationOptions
64
+ ): string => {
65
+ const rootDir = deriveRootDir(options?.rootDir);
66
+ return `${sanitizeProjectKeyName(basename(rootDir))}-${projectHash(rootDir)}`;
69
67
  };
70
68
 
71
- const appendMissingGitignoreLines = (
72
- gitignorePath: string,
73
- content: string
74
- ): void => {
75
- const existingLines = new Set(content.split('\n').map((l) => l.trim()));
76
- const missing = WORKSPACE_GITIGNORE_LINES.filter(
77
- (line) => line !== '' && !existingLines.has(line)
69
+ export const deriveTrailsStateHome = (
70
+ options?: TrailsDbLocationOptions
71
+ ): string => {
72
+ const env = options?.env ?? process.env;
73
+ return resolve(
74
+ env['TRAILS_STATE_HOME'] ??
75
+ env['XDG_STATE_HOME'] ??
76
+ join(homedir(), '.local', 'state')
78
77
  );
79
-
80
- if (missing.length === 0) {
81
- return;
82
- }
83
-
84
- const next = `${content.trimEnd()}\n\n${missing.join('\n')}`;
85
- writeFileSync(gitignorePath, `${next.trimEnd()}\n`);
86
78
  };
87
79
 
88
- const ensureWorkspaceGitignore = (trailsDir: string): void => {
89
- const gitignorePath = join(trailsDir, '.gitignore');
80
+ export const deriveTrailsStateDir = (
81
+ options?: TrailsDbLocationOptions
82
+ ): string =>
83
+ join(
84
+ deriveTrailsStateHome(options),
85
+ TRAILS_STORE_DIR,
86
+ TRAILS_PROJECTS_DIR,
87
+ deriveTrailsProjectKey(options)
88
+ );
89
+
90
+ export const deriveTrailsDir = (options?: TrailsDbLocationOptions): string =>
91
+ join(deriveRootDir(options?.rootDir), TRAILS_DIR);
90
92
 
91
- if (!existsSync(gitignorePath)) {
92
- writeFileSync(gitignorePath, WORKSPACE_GITIGNORE_CONTENT);
93
- return;
94
- }
93
+ export const deriveTrailsDbPath = (options?: TrailsDbLocationOptions): string =>
94
+ options?.path
95
+ ? resolve(options.path)
96
+ : join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
95
97
 
96
- appendMissingGitignoreLines(
97
- gitignorePath,
98
- readFileSync(gitignorePath, 'utf8')
99
- );
98
+ const ensureDbParentDir = (dbPath: string): void => {
99
+ mkdirSync(dirname(dbPath), { recursive: true });
100
100
  };
101
101
 
102
102
  /**
103
103
  * Bootstrap the `.trails/` workspace at `rootDir`.
104
104
  *
105
- * Creates the workspace directory plus the canonical `cache/` and `state/`
106
- * subdirectories, then either writes a fresh `.gitignore` matching
107
- * {@link WORKSPACE_GITIGNORE_CONTENT} or appends any missing canonical lines
108
- * to an existing one. Safe to call repeatedly. This is the single canonical
109
- * source of truth for workspace layout — scaffolding, configuration loading,
110
- * and runtime DB initialization all flow through here.
105
+ * Creates only the committed-control directory. Derived cache and observed
106
+ * state live in the per-user Trails store, so this helper intentionally does
107
+ * not create `.trails/cache`, `.trails/state`, or `.trails/.gitignore`.
111
108
  */
112
109
  export const ensureTrailsWorkspace = (rootDir: string): void => {
113
110
  const trailsDir = deriveTrailsDir({ rootDir });
114
111
  mkdirSync(trailsDir, { recursive: true });
115
- for (const subdir of WORKSPACE_SUBDIRS) {
116
- mkdirSync(join(trailsDir, subdir), { recursive: true });
117
- }
118
- ensureWorkspaceGitignore(trailsDir);
119
112
  };
120
113
 
121
114
  const initializeWritePragmas = (db: Database): void => {
115
+ db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS.toString()}`);
122
116
  db.run('PRAGMA journal_mode = WAL');
123
117
  db.run('PRAGMA synchronous = NORMAL');
124
118
  db.run('PRAGMA foreign_keys = ON');
125
119
  };
126
120
 
121
+ const initializeReadPragmas = (db: Database): void => {
122
+ db.run(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS.toString()}`);
123
+ db.run('PRAGMA foreign_keys = ON');
124
+ };
125
+
127
126
  const ensureSchemaVersionTable = (db: Database): void => {
128
127
  db.run(`CREATE TABLE IF NOT EXISTS ${SCHEMA_VERSION_TABLE} (
129
128
  subsystem TEXT PRIMARY KEY,
@@ -160,15 +159,14 @@ export const openWriteTrailsDb = (
160
159
  options?: TrailsDbLocationOptions
161
160
  ): Database => {
162
161
  const rootDir = deriveRootDir(options?.rootDir);
163
- const dbPath = deriveTrailsDbPath(
164
- options?.path ? { path: options.path, rootDir } : { rootDir }
165
- );
162
+ const locationOptions: TrailsDbLocationOptions = {
163
+ ...(options?.env === undefined ? {} : { env: options.env }),
164
+ ...(options?.path === undefined ? {} : { path: options.path }),
165
+ rootDir,
166
+ };
167
+ const dbPath = deriveTrailsDbPath(locationOptions);
166
168
 
167
- if (options?.path === undefined) {
168
- ensureTrailsWorkspace(rootDir);
169
- } else {
170
- ensureDbParentDir(dbPath);
171
- }
169
+ ensureDbParentDir(dbPath);
172
170
 
173
171
  const db = new Database(dbPath, { create: true });
174
172
  initializeWritePragmas(db);
@@ -186,7 +184,7 @@ export const openReadTrailsDb = (
186
184
  );
187
185
  }
188
186
  const db = new Database(dbPath, { readonly: true });
189
- db.run('PRAGMA foreign_keys = ON');
187
+ initializeReadPragmas(db);
190
188
  return db;
191
189
  };
192
190