@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 +4 -3
- package/SnapshotService.js +158 -3
- package/content-hash.js +82 -11
- package/index.d.ts +54 -30
- package/index.js +1 -2
- package/package.json +1 -1
- package/SnapshotServiceLive.js +0 -130
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
|
-
- **`
|
|
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,
|
|
31
|
+
import { SnapshotService, hashContent } from "@tsdoctor/snapshot";
|
|
31
32
|
import { Effect } from "effect";
|
|
32
33
|
|
|
33
|
-
const layer =
|
|
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;
|
package/SnapshotService.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
59
|
-
*
|
|
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
|
|
79
|
-
if (key
|
|
80
|
-
filtered[key] =
|
|
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
|
|
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
|
|
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
|
|
54
|
-
*
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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": [
|
package/SnapshotServiceLive.js
DELETED
|
@@ -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 };
|