@tsdoctor/snapshot 0.1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/SnapshotService.js +27 -0
- package/SnapshotServiceLive.js +131 -0
- package/content-hash.js +91 -0
- package/index.d.ts +176 -0
- package/index.js +5 -0
- package/package.json +46 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @tsdoctor/snapshot
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@tsdoctor/snapshot)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
Incremental-build snapshot tracking for static documentation pipelines. The package stores per-file content hashes and timestamps in a schema-versioned SQLite database (built on `@effected/store`'s `Store`), so a build can skip writes for unchanged files, preserve SEO-critical publication timestamps, and clean up files that fell out of the source model.
|
|
9
|
+
|
|
10
|
+
## What you get
|
|
11
|
+
|
|
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.
|
|
14
|
+
- **`hashContent` / `hashFrontmatter` / `normalizeContent`** — pure SHA-256 helpers that normalize markdown bodies and frontmatter (excluding timestamp fields) into stable change-detection hashes.
|
|
15
|
+
- **`FileSnapshot`** / **`SnapshotDbError`** — the tracked-file record and the typed error every operation can fail with.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install @tsdoctor/snapshot
|
|
21
|
+
# or
|
|
22
|
+
pnpm add @tsdoctor/snapshot
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This is an ESM-only package. `effect` (v4) and `@effected/store` are peer dependencies.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { SnapshotService, SnapshotServiceLive, hashContent } from "@tsdoctor/snapshot";
|
|
31
|
+
import { Effect } from "effect";
|
|
32
|
+
|
|
33
|
+
const layer = SnapshotServiceLive(".api-docs/snapshot/api-docs.db");
|
|
34
|
+
|
|
35
|
+
const program = Effect.gen(function* () {
|
|
36
|
+
const snapshots = yield* SnapshotService;
|
|
37
|
+
const existing = yield* snapshots.getAllForDirectory("docs/api");
|
|
38
|
+
// ... compare hashContent(body) against existing entries, then:
|
|
39
|
+
yield* snapshots.batchUpsert(changed);
|
|
40
|
+
yield* snapshots.cleanupStale("docs/api", generatedFiles);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await Effect.runPromise(Effect.scoped(program.pipe(Effect.provide(layer))));
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The parent directory of the database path must exist before the layer is built; path policy (where the database lives) is the caller's concern.
|
|
47
|
+
|
|
48
|
+
## Provenance
|
|
49
|
+
|
|
50
|
+
Extracted in phase 2 of the tsdoctor consolidation from the snapshot tracking system inside `rspress-plugin-api-extractor`, rebuilt on `@effected/store` with the same SQL schema and query semantics.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Context, Data } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/SnapshotService.ts
|
|
4
|
+
/**
|
|
5
|
+
* Raised when a snapshot database operation fails.
|
|
6
|
+
*
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
var SnapshotDbError = class extends Data.TaggedError("SnapshotDbError") {
|
|
10
|
+
/** Formatted failure message combining operation, path and reason. */
|
|
11
|
+
get message() {
|
|
12
|
+
return `Snapshot DB error during '${this.operation}' at '${this.dbPath}': ${this.reason}`;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Effect service tag for the snapshot tracking store.
|
|
17
|
+
*
|
|
18
|
+
* @remarks
|
|
19
|
+
* Provided by {@link SnapshotServiceLive}, which backs it with a
|
|
20
|
+
* schema-versioned SQLite database via `@effected/store`.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
var SnapshotService = class extends Context.Service()("@tsdoctor/snapshot/SnapshotService") {};
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { SnapshotDbError, SnapshotService };
|
|
@@ -0,0 +1,131 @@
|
|
|
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. A WAL checkpoint (`PRAGMA wal_checkpoint(TRUNCATE)`) is
|
|
53
|
+
* registered as a scope finalizer so the sidecar files are folded back into
|
|
54
|
+
* the main database on clean shutdown.
|
|
55
|
+
*
|
|
56
|
+
* This is a parameterized layer factory: call it once per database path and
|
|
57
|
+
* bind the result to a `const` — layers memoize by reference, and a fresh
|
|
58
|
+
* call at each provide site would open the database more than once. The
|
|
59
|
+
* parent directory of `dbPath` must already exist.
|
|
60
|
+
*
|
|
61
|
+
* @param dbPath - Path to the SQLite database file
|
|
62
|
+
* @returns A layer providing {@link SnapshotService}
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
const SnapshotServiceLive = (dbPath) => {
|
|
66
|
+
const StoreLive = Store.layerSqlite({
|
|
67
|
+
filename: dbPath,
|
|
68
|
+
migrations
|
|
69
|
+
});
|
|
70
|
+
const ServiceImpl = Layer.effect(SnapshotService, Effect.gen(function* () {
|
|
71
|
+
const sql = (yield* Store).client;
|
|
72
|
+
yield* Effect.addFinalizer(() => sql`PRAGMA wal_checkpoint(TRUNCATE)`.pipe(Effect.ignore));
|
|
73
|
+
return {
|
|
74
|
+
hashContent,
|
|
75
|
+
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)),
|
|
76
|
+
getAllForDirectory: (outputDir) => sql`SELECT * FROM file_snapshots WHERE output_dir = ${outputDir}`.pipe(Effect.map((rows) => rows.map(toFileSnapshot)), Effect.mapError(toSnapshotDbError)),
|
|
77
|
+
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)),
|
|
78
|
+
upsert: (snapshot) => sql`INSERT INTO file_snapshots
|
|
79
|
+
(output_dir, file_path, published_time, modified_time,
|
|
80
|
+
content_hash, frontmatter_hash, build_time)
|
|
81
|
+
VALUES (${snapshot.outputDir}, ${snapshot.filePath},
|
|
82
|
+
${snapshot.publishedTime}, ${snapshot.modifiedTime},
|
|
83
|
+
${snapshot.contentHash}, ${snapshot.frontmatterHash},
|
|
84
|
+
${snapshot.buildTime})
|
|
85
|
+
ON CONFLICT(output_dir, file_path) DO UPDATE SET
|
|
86
|
+
published_time = ${snapshot.publishedTime},
|
|
87
|
+
modified_time = ${snapshot.modifiedTime},
|
|
88
|
+
content_hash = ${snapshot.contentHash},
|
|
89
|
+
frontmatter_hash = ${snapshot.frontmatterHash},
|
|
90
|
+
build_time = ${snapshot.buildTime}
|
|
91
|
+
WHERE published_time != ${snapshot.publishedTime}
|
|
92
|
+
OR modified_time != ${snapshot.modifiedTime}
|
|
93
|
+
OR content_hash != ${snapshot.contentHash}
|
|
94
|
+
OR frontmatter_hash != ${snapshot.frontmatterHash}`.pipe(Effect.as(true), Effect.mapError(toSnapshotDbError)),
|
|
95
|
+
batchUpsert: (snapshots) => (snapshots.length === 0 ? Effect.succeed(0) : sql.withTransaction(Effect.forEach(snapshots, (s) => sql`INSERT INTO file_snapshots
|
|
96
|
+
(output_dir, file_path, published_time, modified_time,
|
|
97
|
+
content_hash, frontmatter_hash, build_time)
|
|
98
|
+
VALUES (${s.outputDir}, ${s.filePath},
|
|
99
|
+
${s.publishedTime}, ${s.modifiedTime},
|
|
100
|
+
${s.contentHash}, ${s.frontmatterHash},
|
|
101
|
+
${s.buildTime})
|
|
102
|
+
ON CONFLICT(output_dir, file_path) DO UPDATE SET
|
|
103
|
+
published_time = ${s.publishedTime},
|
|
104
|
+
modified_time = ${s.modifiedTime},
|
|
105
|
+
content_hash = ${s.contentHash},
|
|
106
|
+
frontmatter_hash = ${s.frontmatterHash},
|
|
107
|
+
build_time = ${s.buildTime}
|
|
108
|
+
WHERE published_time != ${s.publishedTime}
|
|
109
|
+
OR modified_time != ${s.modifiedTime}
|
|
110
|
+
OR content_hash != ${s.contentHash}
|
|
111
|
+
OR frontmatter_hash != ${s.frontmatterHash}`, { concurrency: 1 })).pipe(Effect.map(() => snapshots.length))).pipe(Effect.mapError(toSnapshotDbError)),
|
|
112
|
+
deleteSnapshot: (outputDir, filePath) => sql`DELETE FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${filePath}`.pipe(Effect.asVoid, Effect.mapError(toSnapshotDbError)),
|
|
113
|
+
cleanupStale: (outputDir, currentFiles) => Effect.gen(function* () {
|
|
114
|
+
const rows = yield* sql`SELECT file_path FROM file_snapshots WHERE output_dir = ${outputDir}`;
|
|
115
|
+
const staleFiles = [];
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
const fp = row.file_path;
|
|
118
|
+
if (!currentFiles.has(fp)) {
|
|
119
|
+
yield* sql`DELETE FROM file_snapshots WHERE output_dir = ${outputDir} AND file_path = ${fp}`;
|
|
120
|
+
staleFiles.push(fp);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return staleFiles;
|
|
124
|
+
}).pipe(Effect.mapError(toSnapshotDbError))
|
|
125
|
+
};
|
|
126
|
+
}));
|
|
127
|
+
return Layer.provide(ServiceImpl, StoreLive);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
export { SnapshotServiceLive };
|
package/content-hash.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/content-hash.ts
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes content string for consistent hashing.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Applies the following transformations:
|
|
9
|
+
* - Converts all line endings to Unix-style (`\n`)
|
|
10
|
+
* - Trims leading and trailing whitespace
|
|
11
|
+
* - Collapses multiple consecutive blank lines to a single blank line
|
|
12
|
+
*
|
|
13
|
+
* @param content - The content string to normalize
|
|
14
|
+
* @returns Normalized content string
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { normalizeContent } from "@tsdoctor/snapshot";
|
|
19
|
+
*
|
|
20
|
+
* const normalized = normalizeContent("line1\r\n\r\n\r\nline2 ");
|
|
21
|
+
* // => "line1\n\nline2"
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
function normalizeContent(content) {
|
|
27
|
+
return content.replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim().replaceAll(/\n{3,}/g, "\n\n");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Generates a SHA-256 hash of normalized markdown content.
|
|
31
|
+
*
|
|
32
|
+
* @remarks
|
|
33
|
+
* The content is normalized before hashing to ensure consistent results
|
|
34
|
+
* regardless of line ending differences or trailing whitespace.
|
|
35
|
+
*
|
|
36
|
+
* @param content - The markdown content to hash (excluding frontmatter)
|
|
37
|
+
* @returns Hexadecimal SHA-256 hash string
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* import { hashContent } from "@tsdoctor/snapshot";
|
|
42
|
+
*
|
|
43
|
+
* const hash = hashContent("# My Title\n\nContent here");
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
function hashContent(content) {
|
|
49
|
+
const normalized = normalizeContent(content);
|
|
50
|
+
return createHash("sha256").update(normalized).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Generates a SHA-256 hash of frontmatter fields.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* Excludes timestamp-related fields (`publishedTime`, `modifiedTime`, `head`,
|
|
57
|
+
* `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.
|
|
60
|
+
*
|
|
61
|
+
* @param frontmatter - The frontmatter object to hash
|
|
62
|
+
* @returns Hexadecimal SHA-256 hash string
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* import { hashFrontmatter } from "@tsdoctor/snapshot";
|
|
67
|
+
*
|
|
68
|
+
* const hash = hashFrontmatter({
|
|
69
|
+
* title: "My Page",
|
|
70
|
+
* description: "Page description"
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* @public
|
|
75
|
+
*/
|
|
76
|
+
function hashFrontmatter(frontmatter) {
|
|
77
|
+
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;
|
|
81
|
+
}
|
|
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);
|
|
87
|
+
return createHash("sha256").update(json).digest("hex");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
export { hashContent, hashFrontmatter, normalizeContent };
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
2
|
+
import { StoreError, StoreMigrationError } from "@effected/store";
|
|
3
|
+
//#region src/content-hash.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Normalizes content string for consistent hashing.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Applies the following transformations:
|
|
9
|
+
* - Converts all line endings to Unix-style (`\n`)
|
|
10
|
+
* - Trims leading and trailing whitespace
|
|
11
|
+
* - Collapses multiple consecutive blank lines to a single blank line
|
|
12
|
+
*
|
|
13
|
+
* @param content - The content string to normalize
|
|
14
|
+
* @returns Normalized content string
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* import { normalizeContent } from "@tsdoctor/snapshot";
|
|
19
|
+
*
|
|
20
|
+
* const normalized = normalizeContent("line1\r\n\r\n\r\nline2 ");
|
|
21
|
+
* // => "line1\n\nline2"
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
declare function normalizeContent(content: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Generates a SHA-256 hash of normalized markdown content.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* The content is normalized before hashing to ensure consistent results
|
|
32
|
+
* regardless of line ending differences or trailing whitespace.
|
|
33
|
+
*
|
|
34
|
+
* @param content - The markdown content to hash (excluding frontmatter)
|
|
35
|
+
* @returns Hexadecimal SHA-256 hash string
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* import { hashContent } from "@tsdoctor/snapshot";
|
|
40
|
+
*
|
|
41
|
+
* const hash = hashContent("# My Title\n\nContent here");
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @public
|
|
45
|
+
*/
|
|
46
|
+
declare function hashContent(content: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Generates a SHA-256 hash of frontmatter fields.
|
|
49
|
+
*
|
|
50
|
+
* @remarks
|
|
51
|
+
* Excludes timestamp-related fields (`publishedTime`, `modifiedTime`, `head`,
|
|
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.
|
|
55
|
+
*
|
|
56
|
+
* @param frontmatter - The frontmatter object to hash
|
|
57
|
+
* @returns Hexadecimal SHA-256 hash string
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* import { hashFrontmatter } from "@tsdoctor/snapshot";
|
|
62
|
+
*
|
|
63
|
+
* const hash = hashFrontmatter({
|
|
64
|
+
* title: "My Page",
|
|
65
|
+
* description: "Page description"
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @public
|
|
70
|
+
*/
|
|
71
|
+
declare function hashFrontmatter(frontmatter: Record<string, unknown>): string;
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/SnapshotService.d.ts
|
|
74
|
+
/**
|
|
75
|
+
* A tracked snapshot of one generated file: its content/frontmatter hashes
|
|
76
|
+
* and the timestamps preserved across incremental builds.
|
|
77
|
+
*
|
|
78
|
+
* @public
|
|
79
|
+
*/
|
|
80
|
+
interface FileSnapshot {
|
|
81
|
+
/** Output directory the file was generated into. */
|
|
82
|
+
readonly outputDir: string;
|
|
83
|
+
/** File path relative to `outputDir`. */
|
|
84
|
+
readonly filePath: string;
|
|
85
|
+
/** ISO timestamp of first publication, preserved for unchanged files. */
|
|
86
|
+
readonly publishedTime: string;
|
|
87
|
+
/** ISO timestamp of last content change. */
|
|
88
|
+
readonly modifiedTime: string;
|
|
89
|
+
/** SHA-256 hash of the normalized file body. */
|
|
90
|
+
readonly contentHash: string;
|
|
91
|
+
/** SHA-256 hash of the frontmatter (timestamp fields excluded). */
|
|
92
|
+
readonly frontmatterHash: string;
|
|
93
|
+
/** ISO timestamp of the build that last wrote this snapshot. */
|
|
94
|
+
readonly buildTime: string;
|
|
95
|
+
}
|
|
96
|
+
declare const SnapshotDbError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
97
|
+
readonly _tag: "SnapshotDbError";
|
|
98
|
+
} & Readonly<A>;
|
|
99
|
+
/**
|
|
100
|
+
* Raised when a snapshot database operation fails.
|
|
101
|
+
*
|
|
102
|
+
* @public
|
|
103
|
+
*/
|
|
104
|
+
declare class SnapshotDbError extends SnapshotDbError_base<{
|
|
105
|
+
/** The database operation that failed (e.g. `"query"`). */
|
|
106
|
+
readonly operation: string;
|
|
107
|
+
/** Path or label of the database the operation ran against. */
|
|
108
|
+
readonly dbPath: string;
|
|
109
|
+
/** Human-readable failure reason. */
|
|
110
|
+
readonly reason: string;
|
|
111
|
+
}> {
|
|
112
|
+
/** Formatted failure message combining operation, path and reason. */
|
|
113
|
+
get message(): string;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The operations {@link SnapshotService} provides for tracking generated
|
|
117
|
+
* files across incremental builds.
|
|
118
|
+
*
|
|
119
|
+
* @public
|
|
120
|
+
*/
|
|
121
|
+
interface SnapshotServiceShape {
|
|
122
|
+
/** Hash normalized content with SHA-256 (pure, synchronous). */
|
|
123
|
+
readonly hashContent: (content: string) => string;
|
|
124
|
+
/** Look up a single snapshot by output directory and file path. */
|
|
125
|
+
readonly getSnapshot: (outputDir: string, filePath: string) => Effect.Effect<Option.Option<FileSnapshot>, SnapshotDbError>;
|
|
126
|
+
/** Load every snapshot recorded for an output directory. */
|
|
127
|
+
readonly getAllForDirectory: (outputDir: string) => Effect.Effect<ReadonlyArray<FileSnapshot>, SnapshotDbError>;
|
|
128
|
+
/** List the tracked file paths for an output directory. */
|
|
129
|
+
readonly getFilePaths: (outputDir: string) => Effect.Effect<ReadonlyArray<string>, SnapshotDbError>;
|
|
130
|
+
/** Insert or update a single snapshot. */
|
|
131
|
+
readonly upsert: (snapshot: FileSnapshot) => Effect.Effect<boolean, SnapshotDbError>;
|
|
132
|
+
/** Insert or update many snapshots in one transaction; returns the count. */
|
|
133
|
+
readonly batchUpsert: (snapshots: ReadonlyArray<FileSnapshot>) => Effect.Effect<number, SnapshotDbError>;
|
|
134
|
+
/** Remove a single snapshot row. */
|
|
135
|
+
readonly deleteSnapshot: (outputDir: string, filePath: string) => Effect.Effect<void, SnapshotDbError>;
|
|
136
|
+
/**
|
|
137
|
+
* Delete rows for files no longer generated; returns the stale file paths.
|
|
138
|
+
*/
|
|
139
|
+
readonly cleanupStale: (outputDir: string, currentFiles: ReadonlySet<string>) => Effect.Effect<ReadonlyArray<string>, SnapshotDbError>;
|
|
140
|
+
}
|
|
141
|
+
declare const SnapshotService_base: Context.ServiceClass<SnapshotService, "@tsdoctor/snapshot/SnapshotService", SnapshotServiceShape>;
|
|
142
|
+
/**
|
|
143
|
+
* Effect service tag for the snapshot tracking store.
|
|
144
|
+
*
|
|
145
|
+
* @remarks
|
|
146
|
+
* Provided by {@link SnapshotServiceLive}, which backs it with a
|
|
147
|
+
* schema-versioned SQLite database via `@effected/store`.
|
|
148
|
+
*
|
|
149
|
+
* @public
|
|
150
|
+
*/
|
|
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. A WAL checkpoint (`PRAGMA wal_checkpoint(TRUNCATE)`) is
|
|
161
|
+
* registered as a scope finalizer so the sidecar files are folded back into
|
|
162
|
+
* the 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
|
+
declare const SnapshotServiceLive: (dbPath: string) => Layer.Layer<SnapshotService, StoreError | StoreMigrationError>;
|
|
174
|
+
//#endregion
|
|
175
|
+
export { type FileSnapshot, SnapshotDbError, SnapshotService, SnapshotServiceLive, type SnapshotServiceShape, hashContent, hashFrontmatter, normalizeContent };
|
|
176
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { hashContent, hashFrontmatter, normalizeContent } from "./content-hash.js";
|
|
2
|
+
import { SnapshotDbError, SnapshotService } from "./SnapshotService.js";
|
|
3
|
+
import { SnapshotServiceLive } from "./SnapshotServiceLive.js";
|
|
4
|
+
|
|
5
|
+
export { SnapshotDbError, SnapshotService, SnapshotServiceLive, hashContent, hashFrontmatter, normalizeContent };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tsdoctor/snapshot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
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
|
+
"keywords": [
|
|
7
|
+
"snapshot",
|
|
8
|
+
"incremental-build",
|
|
9
|
+
"sqlite",
|
|
10
|
+
"content-hash",
|
|
11
|
+
"effect",
|
|
12
|
+
"documentation"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/spencerbeggs/tsdoctor#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/spencerbeggs/tsdoctor/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/spencerbeggs/tsdoctor.git",
|
|
21
|
+
"directory": "packages/snapshot"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "C. Spencer Beggs",
|
|
26
|
+
"email": "spencer@beggs.codes",
|
|
27
|
+
"url": "https://spencerbeg.gs"
|
|
28
|
+
},
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"type": "module",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./index.d.ts",
|
|
34
|
+
"import": "./index.js",
|
|
35
|
+
"default": "./index.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@effected/store": "^0.4.0",
|
|
41
|
+
"effect": "4.0.0-rc.109"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=24.11.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// This file is read by tools that parse documentation comments conforming to the TSDoc standard.
|
|
2
|
+
// It should be published with your NPM package. It should not be tracked by Git.
|
|
3
|
+
{
|
|
4
|
+
"tsdocVersion": "0.12",
|
|
5
|
+
"toolPackages": [
|
|
6
|
+
{
|
|
7
|
+
"packageName": "@microsoft/api-extractor",
|
|
8
|
+
"packageVersion": "7.59.0"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|