@tsdoctor/snapshot 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,8 @@ Incremental-build snapshot tracking for static documentation pipelines. The pack
10
10
  ## What you get
11
11
 
12
12
  - **`SnapshotService`** — an Effect service tag with typed operations: single and bulk lookup, transactional batch upsert (with a conditional `ON CONFLICT ... DO UPDATE ... WHERE` that avoids rewriting unchanged rows), deletion and stale-entry cleanup.
13
- - **`SnapshotServiceLive(dbPath)`** — the live layer over a SQLite file. Layer construction applies migrations through `@effected/store`'s ledger; a WAL checkpoint runs as a scope finalizer on clean shutdown.
13
+ - **`SnapshotService.layer(dbPath)`** — the live layer over a SQLite file. Layer construction applies migrations through `@effected/store`'s ledger; a WAL checkpoint runs as a scope finalizer on clean shutdown. It is a factory: call it once per database path and bind the result to a `const`, since layers memoize by reference.
14
+ - **`SnapshotService.makeTest(overrides)` / `SnapshotService.layerTest(overrides)`** — an in-memory double describing a build with no prior snapshot: every lookup misses, every write is accepted and discarded, and nothing is reported stale.
14
15
  - **`hashContent` / `hashFrontmatter` / `normalizeContent`** — pure SHA-256 helpers that normalize markdown bodies and frontmatter (excluding timestamp fields) into stable change-detection hashes.
15
16
  - **`FileSnapshot`** / **`SnapshotDbError`** — the tracked-file record and the typed error every operation can fail with.
16
17
 
@@ -27,10 +28,10 @@ This is an ESM-only package. `effect` (v4) and `@effected/store` are peer depend
27
28
  ## Quick start
28
29
 
29
30
  ```ts
30
- import { SnapshotService, SnapshotServiceLive, hashContent } from "@tsdoctor/snapshot";
31
+ import { SnapshotService, hashContent } from "@tsdoctor/snapshot";
31
32
  import { Effect } from "effect";
32
33
 
33
- const layer = SnapshotServiceLive(".api-docs/snapshot/api-docs.db");
34
+ const layer = SnapshotService.layer(".api-docs/snapshot/api-docs.db");
34
35
 
35
36
  const program = Effect.gen(function* () {
36
37
  const snapshots = yield* SnapshotService;
@@ -1,4 +1,6 @@
1
- import { Context, Data } from "effect";
1
+ import { hashContent } from "./content-hash.js";
2
+ import { Store } from "@effected/store";
3
+ import { Context, Data, Effect, Layer, Option } from "effect";
2
4
 
3
5
  //#region src/SnapshotService.ts
4
6
  /**
@@ -16,12 +18,165 @@ var SnapshotDbError = class extends Data.TaggedError("SnapshotDbError") {
16
18
  * Effect service tag for the snapshot tracking store.
17
19
  *
18
20
  * @remarks
19
- * Provided by {@link SnapshotServiceLive}, which backs it with a
21
+ * Provided by {@link SnapshotService.layer}, which backs it with a
20
22
  * schema-versioned SQLite database via `@effected/store`.
21
23
  *
22
24
  * @public
23
25
  */
24
- var SnapshotService = class extends Context.Service()("@tsdoctor/snapshot/SnapshotService") {};
26
+ var SnapshotService = class SnapshotService extends Context.Service()("@tsdoctor/snapshot/SnapshotService") {
27
+ /**
28
+ * Builds the live {@link SnapshotService} layer over a SQLite database file.
29
+ *
30
+ * @remarks
31
+ * Backed by `@effected/store`'s `Store.layerSqlite`: layer construction opens
32
+ * the database (WAL mode), ensures the migration ledger and applies pending
33
+ * migrations. `checkpointOnClose` folds the WAL sidecar files back into the
34
+ * main database on clean shutdown.
35
+ *
36
+ * This is a parameterized layer factory: call it once per database path and
37
+ * bind the result to a `const` — layers memoize by reference, and a fresh
38
+ * call at each provide site would open the database more than once. The
39
+ * parent directory of `dbPath` must already exist.
40
+ *
41
+ * @param dbPath - Path to the SQLite database file
42
+ * @returns A layer providing {@link SnapshotService}
43
+ * @public
44
+ */
45
+ static layer = (dbPath) => make(dbPath);
46
+ /**
47
+ * An in-memory double: no SQLite file, no migrations, no WAL.
48
+ *
49
+ * @remarks
50
+ * Defaults describe a build with **no prior snapshot** — every lookup misses
51
+ * and every write is accepted and discarded, which is the state a first
52
+ * build or a fresh clone is in, and the state the disk-fallback path is
53
+ * written against.
54
+ *
55
+ * `cleanupStale` defaults to reporting nothing stale rather than echoing its
56
+ * input: a double that claimed files were stale would have the caller DELETE
57
+ * them from disk.
58
+ *
59
+ * @public
60
+ */
61
+ static makeTest = (overrides = {}) => ({
62
+ getSnapshot: overrides.getSnapshot ?? (() => Effect.succeed(Option.none())),
63
+ getAllForDirectory: overrides.getAllForDirectory ?? (() => Effect.succeed([])),
64
+ getFilePaths: overrides.getFilePaths ?? (() => Effect.succeed([])),
65
+ upsert: overrides.upsert ?? (() => Effect.succeed(true)),
66
+ batchUpsert: overrides.batchUpsert ?? ((snapshots) => Effect.succeed(snapshots.length)),
67
+ deleteSnapshot: overrides.deleteSnapshot ?? (() => Effect.void),
68
+ cleanupStale: overrides.cleanupStale ?? (() => Effect.succeed([]))
69
+ });
70
+ /**
71
+ * {@link SnapshotService.makeTest} behind a `Layer`.
72
+ *
73
+ * @public
74
+ */
75
+ static layerTest = (overrides = {}) => Layer.succeed(SnapshotService, SnapshotService.makeTest(overrides));
76
+ };
77
+ function toFileSnapshot(row) {
78
+ return {
79
+ outputDir: row.output_dir,
80
+ filePath: row.file_path,
81
+ publishedTime: row.published_time,
82
+ modifiedTime: row.modified_time,
83
+ contentHash: row.content_hash,
84
+ frontmatterHash: row.frontmatter_hash,
85
+ buildTime: row.build_time
86
+ };
87
+ }
88
+ function toSnapshotDbError(error) {
89
+ return new SnapshotDbError({
90
+ operation: "query",
91
+ dbPath: "snapshot-db",
92
+ reason: error instanceof Error ? error.message : String(error)
93
+ });
94
+ }
95
+ const migrations = [{
96
+ id: 1,
97
+ name: "001_create_snapshots",
98
+ up: (sql) => Effect.gen(function* () {
99
+ yield* sql`
100
+ CREATE TABLE IF NOT EXISTS file_snapshots (
101
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
102
+ output_dir TEXT NOT NULL,
103
+ file_path TEXT NOT NULL,
104
+ published_time TEXT NOT NULL,
105
+ modified_time TEXT NOT NULL,
106
+ content_hash TEXT NOT NULL,
107
+ frontmatter_hash TEXT NOT NULL,
108
+ build_time TEXT NOT NULL,
109
+ UNIQUE(output_dir, file_path)
110
+ )
111
+ `;
112
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_output_dir ON file_snapshots(output_dir)`;
113
+ yield* sql`CREATE INDEX IF NOT EXISTS idx_file_path ON file_snapshots(file_path)`;
114
+ })
115
+ }];
116
+ const make = (dbPath) => {
117
+ const StoreLive = Store.layerSqlite({
118
+ filename: dbPath,
119
+ migrations,
120
+ checkpointOnClose: true
121
+ });
122
+ const ServiceImpl = Layer.effect(SnapshotService, Effect.gen(function* () {
123
+ const sql = (yield* Store).client;
124
+ return {
125
+ hashContent,
126
+ getSnapshot: (outputDir, filePath) => sql`SELECT * FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${filePath}`.pipe(Effect.map((rows) => rows.length > 0 ? Option.some(toFileSnapshot(rows[0])) : Option.none()), Effect.mapError(toSnapshotDbError)),
127
+ getAllForDirectory: (outputDir) => sql`SELECT * FROM file_snapshots WHERE output_dir = ${outputDir}`.pipe(Effect.map((rows) => rows.map(toFileSnapshot)), Effect.mapError(toSnapshotDbError)),
128
+ getFilePaths: (outputDir) => sql`SELECT file_path FROM file_snapshots WHERE output_dir = ${outputDir}`.pipe(Effect.map((rows) => rows.map((r) => r.file_path)), Effect.mapError(toSnapshotDbError)),
129
+ upsert: (snapshot) => sql`INSERT INTO file_snapshots
130
+ (output_dir, file_path, published_time, modified_time,
131
+ content_hash, frontmatter_hash, build_time)
132
+ VALUES (${snapshot.outputDir}, ${snapshot.filePath},
133
+ ${snapshot.publishedTime}, ${snapshot.modifiedTime},
134
+ ${snapshot.contentHash}, ${snapshot.frontmatterHash},
135
+ ${snapshot.buildTime})
136
+ ON CONFLICT(output_dir, file_path) DO UPDATE SET
137
+ published_time = ${snapshot.publishedTime},
138
+ modified_time = ${snapshot.modifiedTime},
139
+ content_hash = ${snapshot.contentHash},
140
+ frontmatter_hash = ${snapshot.frontmatterHash},
141
+ build_time = ${snapshot.buildTime}
142
+ WHERE published_time != ${snapshot.publishedTime}
143
+ OR modified_time != ${snapshot.modifiedTime}
144
+ OR content_hash != ${snapshot.contentHash}
145
+ OR frontmatter_hash != ${snapshot.frontmatterHash}`.pipe(Effect.as(true), Effect.mapError(toSnapshotDbError)),
146
+ batchUpsert: (snapshots) => (snapshots.length === 0 ? Effect.succeed(0) : sql.withTransaction(Effect.forEach(snapshots, (s) => sql`INSERT INTO file_snapshots
147
+ (output_dir, file_path, published_time, modified_time,
148
+ content_hash, frontmatter_hash, build_time)
149
+ VALUES (${s.outputDir}, ${s.filePath},
150
+ ${s.publishedTime}, ${s.modifiedTime},
151
+ ${s.contentHash}, ${s.frontmatterHash},
152
+ ${s.buildTime})
153
+ ON CONFLICT(output_dir, file_path) DO UPDATE SET
154
+ published_time = ${s.publishedTime},
155
+ modified_time = ${s.modifiedTime},
156
+ content_hash = ${s.contentHash},
157
+ frontmatter_hash = ${s.frontmatterHash},
158
+ build_time = ${s.buildTime}
159
+ WHERE published_time != ${s.publishedTime}
160
+ OR modified_time != ${s.modifiedTime}
161
+ OR content_hash != ${s.contentHash}
162
+ OR frontmatter_hash != ${s.frontmatterHash}`, { concurrency: 1 })).pipe(Effect.map(() => snapshots.length))).pipe(Effect.mapError(toSnapshotDbError)),
163
+ deleteSnapshot: (outputDir, filePath) => sql`DELETE FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${filePath}`.pipe(Effect.asVoid, Effect.mapError(toSnapshotDbError)),
164
+ cleanupStale: (outputDir, currentFiles) => Effect.gen(function* () {
165
+ const rows = yield* sql`SELECT file_path FROM file_snapshots WHERE output_dir = ${outputDir}`;
166
+ const staleFiles = [];
167
+ for (const row of rows) {
168
+ const fp = row.file_path;
169
+ if (!currentFiles.has(fp)) {
170
+ yield* sql`DELETE FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${fp}`;
171
+ staleFiles.push(fp);
172
+ }
173
+ }
174
+ return staleFiles;
175
+ }).pipe(Effect.mapError(toSnapshotDbError))
176
+ };
177
+ }));
178
+ return Layer.provide(ServiceImpl, StoreLive);
179
+ };
25
180
 
26
181
  //#endregion
27
182
  export { SnapshotDbError, SnapshotService };
package/content-hash.js CHANGED
@@ -49,14 +49,89 @@ function hashContent(content) {
49
49
  const normalized = normalizeContent(content);
50
50
  return createHash("sha256").update(normalized).digest("hex");
51
51
  }
52
+ const TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
53
+ "publishedTime",
54
+ "modifiedTime",
55
+ "article:published_time",
56
+ "article:modified_time"
57
+ ]);
58
+ const JSON_LD_DATE_KEYS = /* @__PURE__ */ new Set([
59
+ "datePublished",
60
+ "dateModified",
61
+ "uploadDate"
62
+ ]);
63
+ const JSON_LD_BODY_KEYS = /* @__PURE__ */ new Set([
64
+ "children",
65
+ "innerHTML",
66
+ "textContent"
67
+ ]);
68
+ function isRecord(value) {
69
+ return typeof value === "object" && value !== null && !Array.isArray(value);
70
+ }
71
+ /**
72
+ * Strips a JSON-LD script body of its date fields.
73
+ *
74
+ * @remarks
75
+ * The body arrives as a string, so it must be parsed before its dates can be
76
+ * removed. A body that does not parse as JSON is returned unchanged rather
77
+ * than throwing — an unparseable body is still content worth hashing.
78
+ */
79
+ function stripJsonLdBody(body) {
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(body);
83
+ } catch {
84
+ return body;
85
+ }
86
+ return JSON.stringify(stripTimestamps(parsed));
87
+ }
88
+ /**
89
+ * Recursively removes timestamp-valued entries from a frontmatter value.
90
+ *
91
+ * @remarks
92
+ * Timestamps appear in two shapes. In the meta-pair form the value lives in a
93
+ * `content` field whose sibling `property`/`name` names a timestamp
94
+ * (`article:published_time`, `article:modified_time`). In the JSON-LD form it
95
+ * is an object key (`datePublished`, `dateModified`) inside a script body.
96
+ * Both are stripped; everything else survives so that an `og:image`,
97
+ * `og:description`, canonical `href` or JSON-LD version change is visible to
98
+ * change detection.
99
+ *
100
+ * The walk must be recursive: `head` is an array of `[tagName, attrs]` pairs,
101
+ * so a shallow pass would see nothing.
102
+ */
103
+ function stripTimestamps(value) {
104
+ if (Array.isArray(value)) return value.map(stripTimestamps);
105
+ if (!isRecord(value)) return value;
106
+ const property = value["property"] ?? value["name"];
107
+ const isTimestampTag = typeof property === "string" && TIMESTAMP_KEYS.has(property);
108
+ const isJsonLd = value["type"] === "application/ld+json";
109
+ const result = {};
110
+ for (const key of Object.keys(value).sort()) {
111
+ if (JSON_LD_DATE_KEYS.has(key)) continue;
112
+ if (isTimestampTag && key === "content") continue;
113
+ const entry = value[key];
114
+ if (isJsonLd && JSON_LD_BODY_KEYS.has(key) && typeof entry === "string") {
115
+ result[key] = stripJsonLdBody(entry);
116
+ continue;
117
+ }
118
+ result[key] = stripTimestamps(entry);
119
+ }
120
+ return result;
121
+ }
52
122
  /**
53
123
  * Generates a SHA-256 hash of frontmatter fields.
54
124
  *
55
125
  * @remarks
56
- * Excludes timestamp-related fields (`publishedTime`, `modifiedTime`, `head`,
126
+ * Excludes the top-level timestamp fields (`publishedTime`, `modifiedTime`,
57
127
  * `article:published_time`, `article:modified_time`) to prevent circular
58
- * dependencies in change detection. Keys are sorted alphabetically before
59
- * hashing to ensure consistent results regardless of object key order.
128
+ * dependencies in change detection, and strips timestamp-valued entries
129
+ * recursively from every remaining value including the `head` array's meta
130
+ * pairs and the date fields inside a JSON-LD script body. Everything else in
131
+ * `head` participates in the hash, so an `og:image`, `og:description` or
132
+ * canonical URL change marks the page modified. Keys are sorted
133
+ * alphabetically before hashing to ensure consistent results regardless of
134
+ * object key order.
60
135
  *
61
136
  * @param frontmatter - The frontmatter object to hash
62
137
  * @returns Hexadecimal SHA-256 hash string
@@ -75,15 +150,11 @@ function hashContent(content) {
75
150
  */
76
151
  function hashFrontmatter(frontmatter) {
77
152
  const filtered = {};
78
- for (const [key, value] of Object.entries(frontmatter)) {
79
- if (key === "publishedTime" || key === "modifiedTime" || key === "head" || key === "article:published_time" || key === "article:modified_time") continue;
80
- filtered[key] = value;
153
+ for (const key of Object.keys(frontmatter).sort()) {
154
+ if (TIMESTAMP_KEYS.has(key)) continue;
155
+ filtered[key] = stripTimestamps(frontmatter[key]);
81
156
  }
82
- const sorted = Object.keys(filtered).sort().reduce((acc, key) => {
83
- acc[key] = filtered[key];
84
- return acc;
85
- }, {});
86
- const json = JSON.stringify(sorted);
157
+ const json = JSON.stringify(filtered);
87
158
  return createHash("sha256").update(json).digest("hex");
88
159
  }
89
160
 
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Context, Effect, Layer, Option } from "effect";
2
1
  import { StoreError, StoreMigrationError } from "@effected/store";
2
+ import { Context, Effect, Layer, Option } from "effect";
3
3
  //#region src/content-hash.d.ts
4
4
  /**
5
5
  * Normalizes content string for consistent hashing.
@@ -48,10 +48,15 @@ declare function hashContent(content: string): string;
48
48
  * Generates a SHA-256 hash of frontmatter fields.
49
49
  *
50
50
  * @remarks
51
- * Excludes timestamp-related fields (`publishedTime`, `modifiedTime`, `head`,
51
+ * Excludes the top-level timestamp fields (`publishedTime`, `modifiedTime`,
52
52
  * `article:published_time`, `article:modified_time`) to prevent circular
53
- * dependencies in change detection. Keys are sorted alphabetically before
54
- * hashing to ensure consistent results regardless of object key order.
53
+ * dependencies in change detection, and strips timestamp-valued entries
54
+ * recursively from every remaining value including the `head` array's meta
55
+ * pairs and the date fields inside a JSON-LD script body. Everything else in
56
+ * `head` participates in the hash, so an `og:image`, `og:description` or
57
+ * canonical URL change marks the page modified. Keys are sorted
58
+ * alphabetically before hashing to ensure consistent results regardless of
59
+ * object key order.
55
60
  *
56
61
  * @param frontmatter - The frontmatter object to hash
57
62
  * @returns Hexadecimal SHA-256 hash string
@@ -119,8 +124,6 @@ declare class SnapshotDbError extends SnapshotDbError_base<{
119
124
  * @public
120
125
  */
121
126
  interface SnapshotServiceShape {
122
- /** Hash normalized content with SHA-256 (pure, synchronous). */
123
- readonly hashContent: (content: string) => string;
124
127
  /** Look up a single snapshot by output directory and file path. */
125
128
  readonly getSnapshot: (outputDir: string, filePath: string) => Effect.Effect<Option.Option<FileSnapshot>, SnapshotDbError>;
126
129
  /** Load every snapshot recorded for an output directory. */
@@ -143,33 +146,54 @@ declare const SnapshotService_base: Context.ServiceClass<SnapshotService, "@tsdo
143
146
  * Effect service tag for the snapshot tracking store.
144
147
  *
145
148
  * @remarks
146
- * Provided by {@link SnapshotServiceLive}, which backs it with a
149
+ * Provided by {@link SnapshotService.layer}, which backs it with a
147
150
  * schema-versioned SQLite database via `@effected/store`.
148
151
  *
149
152
  * @public
150
153
  */
151
- declare class SnapshotService extends SnapshotService_base {}
152
- //#endregion
153
- //#region src/SnapshotServiceLive.d.ts
154
- /**
155
- * Builds the live {@link SnapshotService} layer over a SQLite database file.
156
- *
157
- * @remarks
158
- * Backed by `@effected/store`'s `Store.layerSqlite`: layer construction opens
159
- * the database (WAL mode), ensures the migration ledger and applies pending
160
- * migrations. `checkpointOnClose` folds the WAL sidecar files back into the
161
- * main database on clean shutdown.
162
- *
163
- * This is a parameterized layer factory: call it once per database path and
164
- * bind the result to a `const` layers memoize by reference, and a fresh
165
- * call at each provide site would open the database more than once. The
166
- * parent directory of `dbPath` must already exist.
167
- *
168
- * @param dbPath - Path to the SQLite database file
169
- * @returns A layer providing {@link SnapshotService}
170
- * @public
171
- */
172
- declare const SnapshotServiceLive: (dbPath: string) => Layer.Layer<SnapshotService, StoreError | StoreMigrationError>;
154
+ declare class SnapshotService extends SnapshotService_base {
155
+ /**
156
+ * Builds the live {@link SnapshotService} layer over a SQLite database file.
157
+ *
158
+ * @remarks
159
+ * Backed by `@effected/store`'s `Store.layerSqlite`: layer construction opens
160
+ * the database (WAL mode), ensures the migration ledger and applies pending
161
+ * migrations. `checkpointOnClose` folds the WAL sidecar files back into the
162
+ * main database on clean shutdown.
163
+ *
164
+ * This is a parameterized layer factory: call it once per database path and
165
+ * bind the result to a `const` — layers memoize by reference, and a fresh
166
+ * call at each provide site would open the database more than once. The
167
+ * parent directory of `dbPath` must already exist.
168
+ *
169
+ * @param dbPath - Path to the SQLite database file
170
+ * @returns A layer providing {@link SnapshotService}
171
+ * @public
172
+ */
173
+ static readonly layer: (dbPath: string) => Layer.Layer<SnapshotService, StoreError | StoreMigrationError>;
174
+ /**
175
+ * An in-memory double: no SQLite file, no migrations, no WAL.
176
+ *
177
+ * @remarks
178
+ * Defaults describe a build with **no prior snapshot** — every lookup misses
179
+ * and every write is accepted and discarded, which is the state a first
180
+ * build or a fresh clone is in, and the state the disk-fallback path is
181
+ * written against.
182
+ *
183
+ * `cleanupStale` defaults to reporting nothing stale rather than echoing its
184
+ * input: a double that claimed files were stale would have the caller DELETE
185
+ * them from disk.
186
+ *
187
+ * @public
188
+ */
189
+ static readonly makeTest: (overrides?: Partial<SnapshotServiceShape>) => SnapshotServiceShape;
190
+ /**
191
+ * {@link SnapshotService.makeTest} behind a `Layer`.
192
+ *
193
+ * @public
194
+ */
195
+ static readonly layerTest: (overrides?: Partial<SnapshotServiceShape>) => Layer.Layer<SnapshotService>;
196
+ }
173
197
  //#endregion
174
- export { type FileSnapshot, SnapshotDbError, SnapshotService, SnapshotServiceLive, type SnapshotServiceShape, hashContent, hashFrontmatter, normalizeContent };
198
+ export { type FileSnapshot, SnapshotDbError, SnapshotService, type SnapshotServiceShape, hashContent, hashFrontmatter, normalizeContent };
175
199
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { hashContent, hashFrontmatter, normalizeContent } from "./content-hash.js";
2
2
  import { SnapshotDbError, SnapshotService } from "./SnapshotService.js";
3
- import { SnapshotServiceLive } from "./SnapshotServiceLive.js";
4
3
 
5
- export { SnapshotDbError, SnapshotService, SnapshotServiceLive, hashContent, hashFrontmatter, normalizeContent };
4
+ export { SnapshotDbError, SnapshotService, hashContent, hashFrontmatter, normalizeContent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsdoctor/snapshot",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "description": "Incremental-build snapshot tracking for static documentation pipelines: a schema-versioned SQLite store of per-file content hashes and timestamps, built on @effected/store, plus the pure SHA-256 content-hashing helpers that feed it.",
6
6
  "keywords": [
@@ -1,130 +0,0 @@
1
- import { hashContent } from "./content-hash.js";
2
- import { SnapshotDbError, SnapshotService } from "./SnapshotService.js";
3
- import { Effect, Layer, Option } from "effect";
4
- import { Store } from "@effected/store";
5
-
6
- //#region src/SnapshotServiceLive.ts
7
- function toFileSnapshot(row) {
8
- return {
9
- outputDir: row.output_dir,
10
- filePath: row.file_path,
11
- publishedTime: row.published_time,
12
- modifiedTime: row.modified_time,
13
- contentHash: row.content_hash,
14
- frontmatterHash: row.frontmatter_hash,
15
- buildTime: row.build_time
16
- };
17
- }
18
- function toSnapshotDbError(error) {
19
- return new SnapshotDbError({
20
- operation: "query",
21
- dbPath: "snapshot-db",
22
- reason: error instanceof Error ? error.message : String(error)
23
- });
24
- }
25
- const migrations = [{
26
- id: 1,
27
- name: "001_create_snapshots",
28
- up: (sql) => Effect.gen(function* () {
29
- yield* sql`
30
- CREATE TABLE IF NOT EXISTS file_snapshots (
31
- id INTEGER PRIMARY KEY AUTOINCREMENT,
32
- output_dir TEXT NOT NULL,
33
- file_path TEXT NOT NULL,
34
- published_time TEXT NOT NULL,
35
- modified_time TEXT NOT NULL,
36
- content_hash TEXT NOT NULL,
37
- frontmatter_hash TEXT NOT NULL,
38
- build_time TEXT NOT NULL,
39
- UNIQUE(output_dir, file_path)
40
- )
41
- `;
42
- yield* sql`CREATE INDEX IF NOT EXISTS idx_output_dir ON file_snapshots(output_dir)`;
43
- yield* sql`CREATE INDEX IF NOT EXISTS idx_file_path ON file_snapshots(file_path)`;
44
- })
45
- }];
46
- /**
47
- * Builds the live {@link SnapshotService} layer over a SQLite database file.
48
- *
49
- * @remarks
50
- * Backed by `@effected/store`'s `Store.layerSqlite`: layer construction opens
51
- * the database (WAL mode), ensures the migration ledger and applies pending
52
- * migrations. `checkpointOnClose` folds the WAL sidecar files back into the
53
- * main database on clean shutdown.
54
- *
55
- * This is a parameterized layer factory: call it once per database path and
56
- * bind the result to a `const` — layers memoize by reference, and a fresh
57
- * call at each provide site would open the database more than once. The
58
- * parent directory of `dbPath` must already exist.
59
- *
60
- * @param dbPath - Path to the SQLite database file
61
- * @returns A layer providing {@link SnapshotService}
62
- * @public
63
- */
64
- const SnapshotServiceLive = (dbPath) => {
65
- const StoreLive = Store.layerSqlite({
66
- filename: dbPath,
67
- migrations,
68
- checkpointOnClose: true
69
- });
70
- const ServiceImpl = Layer.effect(SnapshotService, Effect.gen(function* () {
71
- const sql = (yield* Store).client;
72
- return {
73
- hashContent,
74
- getSnapshot: (outputDir, filePath) => sql`SELECT * FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${filePath}`.pipe(Effect.map((rows) => rows.length > 0 ? Option.some(toFileSnapshot(rows[0])) : Option.none()), Effect.mapError(toSnapshotDbError)),
75
- getAllForDirectory: (outputDir) => sql`SELECT * FROM file_snapshots WHERE output_dir = ${outputDir}`.pipe(Effect.map((rows) => rows.map(toFileSnapshot)), Effect.mapError(toSnapshotDbError)),
76
- getFilePaths: (outputDir) => sql`SELECT file_path FROM file_snapshots WHERE output_dir = ${outputDir}`.pipe(Effect.map((rows) => rows.map((r) => r.file_path)), Effect.mapError(toSnapshotDbError)),
77
- upsert: (snapshot) => sql`INSERT INTO file_snapshots
78
- (output_dir, file_path, published_time, modified_time,
79
- content_hash, frontmatter_hash, build_time)
80
- VALUES (${snapshot.outputDir}, ${snapshot.filePath},
81
- ${snapshot.publishedTime}, ${snapshot.modifiedTime},
82
- ${snapshot.contentHash}, ${snapshot.frontmatterHash},
83
- ${snapshot.buildTime})
84
- ON CONFLICT(output_dir, file_path) DO UPDATE SET
85
- published_time = ${snapshot.publishedTime},
86
- modified_time = ${snapshot.modifiedTime},
87
- content_hash = ${snapshot.contentHash},
88
- frontmatter_hash = ${snapshot.frontmatterHash},
89
- build_time = ${snapshot.buildTime}
90
- WHERE published_time != ${snapshot.publishedTime}
91
- OR modified_time != ${snapshot.modifiedTime}
92
- OR content_hash != ${snapshot.contentHash}
93
- OR frontmatter_hash != ${snapshot.frontmatterHash}`.pipe(Effect.as(true), Effect.mapError(toSnapshotDbError)),
94
- batchUpsert: (snapshots) => (snapshots.length === 0 ? Effect.succeed(0) : sql.withTransaction(Effect.forEach(snapshots, (s) => sql`INSERT INTO file_snapshots
95
- (output_dir, file_path, published_time, modified_time,
96
- content_hash, frontmatter_hash, build_time)
97
- VALUES (${s.outputDir}, ${s.filePath},
98
- ${s.publishedTime}, ${s.modifiedTime},
99
- ${s.contentHash}, ${s.frontmatterHash},
100
- ${s.buildTime})
101
- ON CONFLICT(output_dir, file_path) DO UPDATE SET
102
- published_time = ${s.publishedTime},
103
- modified_time = ${s.modifiedTime},
104
- content_hash = ${s.contentHash},
105
- frontmatter_hash = ${s.frontmatterHash},
106
- build_time = ${s.buildTime}
107
- WHERE published_time != ${s.publishedTime}
108
- OR modified_time != ${s.modifiedTime}
109
- OR content_hash != ${s.contentHash}
110
- OR frontmatter_hash != ${s.frontmatterHash}`, { concurrency: 1 })).pipe(Effect.map(() => snapshots.length))).pipe(Effect.mapError(toSnapshotDbError)),
111
- deleteSnapshot: (outputDir, filePath) => sql`DELETE FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${filePath}`.pipe(Effect.asVoid, Effect.mapError(toSnapshotDbError)),
112
- cleanupStale: (outputDir, currentFiles) => Effect.gen(function* () {
113
- const rows = yield* sql`SELECT file_path FROM file_snapshots WHERE output_dir = ${outputDir}`;
114
- const staleFiles = [];
115
- for (const row of rows) {
116
- const fp = row.file_path;
117
- if (!currentFiles.has(fp)) {
118
- yield* sql`DELETE FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${fp}`;
119
- staleFiles.push(fp);
120
- }
121
- }
122
- return staleFiles;
123
- }).pipe(Effect.mapError(toSnapshotDbError))
124
- };
125
- }));
126
- return Layer.provide(ServiceImpl, StoreLive);
127
- };
128
-
129
- //#endregion
130
- export { SnapshotServiceLive };