@haruhimemoe/next-kit 0.6.2 → 0.7.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/CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-10-05
10
+
11
+ ### Added
12
+ - `vcs` entry point: `createRevisionStore`, document history in MongoDB on top of `@haruhimemoe/vcs` (a new optional peer). Saves name their base revision and merge onto anything newer; conflicts write nothing and come back for the client to resolve. Also revert, diffs, author renames, history removal and autosave pruning.
13
+
9
14
  ## [0.6.2] - 2026-10-04
10
15
 
11
16
  ### Changed
package/README.md CHANGED
@@ -13,6 +13,7 @@ The Next.js server plumbing the haruhime.moe tools share. [packs.haruhime.moe](h
13
13
  - **`/testing`:** Vitest helpers: one in-memory MongoDB per run, an msw server that refuses unhandled requests, and a fake env.
14
14
  - **`/api-keys`:** the shared key format (an app prefix like `hpk_` plus 32 random bytes), a key store over `api_keys`, and the `/api/v1` guard with the standard limits.
15
15
  - **`/docs`:** a content registry for an app's docs, guides and legal pages: sections, entries, app-made extra entries (like bb's tag pages), the path helpers a dynamic route needs, and `mdxToMarkdown` to turn bb-flavored MDX into plain Markdown. No runtime imports. **`/docs/files`:** reads the markdown files a registry's entries point at and reports drift between the registry and disk (node:fs).
16
+ - **`/vcs`:** document history in MongoDB on top of `@haruhimemoe/vcs`: one line of revisions per document, saves merged onto whatever landed since their base, revert, diffs, and autosave pruning.
16
17
 
17
18
  Every name, path, limit and message comes from the caller. There is no root entry point; import a subpath.
18
19
 
@@ -37,6 +38,7 @@ bun add @haruhimemoe/next-kit zod
37
38
  | `api-keys` | `mongodb` ^7.6.0 |
38
39
  | `docs` | nothing else |
39
40
  | `docs/files` | nothing else (`node:fs` is built in) |
41
+ | `vcs` | `mongodb` ^7.6.0, `@haruhimemoe/vcs` ^0.1.0 |
40
42
 
41
43
  ## Use
42
44
 
@@ -391,6 +393,41 @@ No runtime imports.
391
393
  | `readContentMarkdown(content, section, slug, { root?, siteUrl, transforms? })` | Reads a registered entry's markdown file and converts it with `mdxToMarkdown` (using the entry's `title`). `root` defaults to `process.cwd()`. Returns null for an unregistered slug; rejects (ENOENT) when the slug is registered but its file is missing. |
392
394
  | `contentFileDrift(content, { root? })` | `{ missingFiles, unregistered }`: `missingFiles` lists registered entries with no file on disk (like `"guides/x.mdx"`); `unregistered` lists `.mdx` files on disk with no registry entry. `root` defaults to `process.cwd()`. |
393
395
 
396
+ ### vcs
397
+
398
+ | Export | What it does |
399
+ | --- | --- |
400
+ | `createRevisionStore({ db, collection, codec?, check?, maxRevisions?, maxBytes?, now? })` | A document history over one collection. `codec` is a `@haruhimemoe/vcs` codec (keyed lists, text and ignored paths). `check(value, kind)` runs before every write; throw to refuse (a content filter, say). Throws `TypeError` for a missing collection or a non-positive limit. |
401
+ | `revisionIndexSpecs(collection)` | Unique `(docId, seq)`, `authorId`, and `(docId, kind, createdAt)`, for the app's own index list. |
402
+ | `DEFAULT_MAX_REVISIONS` | 1000 per document; past it the oldest autosaves go. Saves are never deleted. |
403
+ | `DEFAULT_MAX_BYTES` | 1,000,000 bytes of canonical JSON per value; past it a write throws `RangeError`. |
404
+ | `COMMIT_ATTEMPTS` | 3: how often a commit reruns when another writer takes its seq. |
405
+ | `DEFAULT_LIST_LIMIT`, `MAX_LIST_LIMIT` | History page size: 50 by default, 200 at most. |
406
+
407
+ The store's methods:
408
+
409
+ | Method | What it does |
410
+ | --- | --- |
411
+ | `create(docId, value, author, message?)` | The root revision (seq 0). Throws if the document already has history. |
412
+ | `head(docId)`, `get(docId, id)` | One revision with its value, or null. |
413
+ | `list(docId, { before?, limit? })` | Revisions newest first, without values. `before` pages by seq. |
414
+ | `commit({ docId, base, value, author, kind?, message? })` | `base` is the `{ id, seq }` the client started from; `kind` is `"save"` (default) or `"autosave"`. Returns `committed` (base was the head), `merged` (the head moved on and the value merged onto it cleanly, written as kind `merge`), `unchanged` (the result equals the head, ignoring ignored paths; nothing written), `conflict` (nothing written; `head` and the `ValueMerge` with its conflicts, so the client can resolve and commit again on `head`) or `missing` (no such document, or no revision at or before `base` survives). A pruned `base` merges from the nearest earlier revision. |
415
+ | `revert(docId, id, author)` | Commits that revision's value again, as kind `revert`. |
416
+ | `diff(docId, fromId, toId)` | The `Change[]` between two revisions, or null. |
417
+ | `renameAuthor(authorId, name)` | Rewrites the author's name on every revision (renames, deleted accounts). |
418
+ | `removeDoc(docId)` | Deletes the whole history. |
419
+ | `pruneAutosaves(docId, olderThan)` | Deletes autosaves older than the date that a later save, merge or revert follows. |
420
+ | `indexSpecs()`, `ensureIndexes()` | The indexes, and building them (logs, never throws). |
421
+
422
+ Notes:
423
+
424
+ - Run `ensureIndexes()` (or build `revisionIndexSpecs` with your own list) before the first write. The unique `(docId, seq)` index is what stops two writers from both taking the next seq.
425
+ - `check` can run more than once for one commit (retries), so keep it free of side effects.
426
+ - A stale autosave that merges is kept as kind `merge`, like a save; only plain autosaves are pruned.
427
+ - A `base` whose id is another document's revision, or whose seq doesn't match, is `missing`. Only an id that no longer exists (pruned) falls back to the nearest earlier revision.
428
+
429
+ Who may read a history, and the routes around it, stay the app's.
430
+
394
431
  ## Migration
395
432
 
396
433
  Both apps can drop their copies for the subpaths above. Where the copies differed, this package keeps pools.haruhime.moe's behavior. What changes for packs.haruhime.moe:
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file src/vcs/commit.ts
3
+ * @desc create, commit and revert. A commit whose base is the head writes the value as is. When
4
+ * the head moved on, the value is merged onto it (base: the client's revision, or the
5
+ * nearest earlier one if that was pruned); a clean merge is written as kind "merge", a
6
+ * conflict writes nothing. A value that hashes like the head writes nothing either. When
7
+ * another writer takes the next seq first, the whole thing reruns, up to COMMIT_ATTEMPTS.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sun Oct 4, 2026
10
+ * @modified Sun Oct 4, 2026
11
+ */
12
+ import { type Revision } from "@haruhimemoe/vcs";
13
+ import { type StoreContext } from "./read.js";
14
+ import type { CommitInput, CommitResult, RevisionAuthor } from "./types.js";
15
+ /**
16
+ * @function createHistory
17
+ * @param ctx {StoreContext<T>} the store
18
+ * @param docId {string} the document
19
+ * @param value {T} its first value
20
+ * @param author {RevisionAuthor} who made it
21
+ * @param message {string | null | undefined} an optional note
22
+ * @returns {Promise<Revision<T>>} the root revision (seq 0)
23
+ * @throws {Error} when the document already has history
24
+ */
25
+ export declare const createHistory: <T>(ctx: StoreContext<T>, docId: string, value: T, author: RevisionAuthor, message?: string | null) => Promise<Revision<T>>;
26
+ /**
27
+ * @function commitRevision
28
+ * @param ctx {StoreContext<T>} the store
29
+ * @param input {CommitInput<T>} the document, the client's base revision, the value and author
30
+ * @returns {Promise<CommitResult<T>>} committed, merged, unchanged, conflict or missing
31
+ * @throws {Error} when other writers win COMMIT_ATTEMPTS times in a row; RangeError past
32
+ * maxBytes; whatever `check` throws
33
+ */
34
+ export declare const commitRevision: <T>(ctx: StoreContext<T>, { docId, base, value, author, kind, message }: CommitInput<T>) => Promise<CommitResult<T>>;
35
+ /**
36
+ * @function revertRevision
37
+ * @param ctx {StoreContext<T>} the store
38
+ * @param docId {string} the document
39
+ * @param id {string} the revision whose value to restore
40
+ * @param author {RevisionAuthor} who is reverting
41
+ * @returns {Promise<CommitResult<T>>} committed (kind "revert", base = id), unchanged when the
42
+ * head already holds that value, or missing
43
+ * @throws {Error} as commitRevision
44
+ */
45
+ export declare const revertRevision: <T>(ctx: StoreContext<T>, docId: string, id: string, author: RevisionAuthor) => Promise<CommitResult<T>>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * @file src/vcs/commit.ts
3
+ * @desc create, commit and revert. A commit whose base is the head writes the value as is. When
4
+ * the head moved on, the value is merged onto it (base: the client's revision, or the
5
+ * nearest earlier one if that was pruned); a clean merge is written as kind "merge", a
6
+ * conflict writes nothing. A value that hashes like the head writes nothing either. When
7
+ * another writer takes the next seq first, the whole thing reruns, up to COMMIT_ATTEMPTS.
8
+ * @author David @dvhsh (https://dvh.sh)
9
+ * @created Sun Oct 4, 2026
10
+ * @modified Sun Oct 4, 2026
11
+ */
12
+ import { mergeValue } from "@haruhimemoe/vcs";
13
+ import { isDuplicateKeyError } from "../mongo/duplicate.js";
14
+ import { COMMIT_ATTEMPTS } from "./options.js";
15
+ import { readAtOrBefore, readById, readHead, readOne } from "./read.js";
16
+ import { insertRevision, valueHash } from "./write.js";
17
+ /**
18
+ * @function createHistory
19
+ * @param ctx {StoreContext<T>} the store
20
+ * @param docId {string} the document
21
+ * @param value {T} its first value
22
+ * @param author {RevisionAuthor} who made it
23
+ * @param message {string | null | undefined} an optional note
24
+ * @returns {Promise<Revision<T>>} the root revision (seq 0)
25
+ * @throws {Error} when the document already has history
26
+ */
27
+ export const createHistory = async (ctx, docId, value, author, message) => {
28
+ try {
29
+ return await insertRevision(ctx, { docId, seq: 0, kind: "root", value, author, message });
30
+ }
31
+ catch (error) {
32
+ if (isDuplicateKeyError(error))
33
+ throw new Error(`createHistory: ${docId} already has history`);
34
+ throw error;
35
+ }
36
+ };
37
+ /** Writes at head.seq + 1 unless the value equals head. null: someone else took the seq. */
38
+ const writeNext = async (ctx, head, input, merged) => {
39
+ const hash = await valueHash(ctx, input.value);
40
+ if (hash === head.valueHash)
41
+ return { status: "unchanged", revision: head };
42
+ try {
43
+ const revision = await insertRevision(ctx, { ...input, seq: head.seq + 1 }, hash);
44
+ return { status: merged ? "merged" : "committed", revision };
45
+ }
46
+ catch (error) {
47
+ if (isDuplicateKeyError(error))
48
+ return null;
49
+ throw error;
50
+ }
51
+ };
52
+ /** The client's base, or the nearest earlier revision if it was pruned. null when the id
53
+ * belongs to another revision or document, or nothing earlier survives. */
54
+ const mergeBase = async (ctx, docId, base) => {
55
+ const found = await readById(ctx, base.id);
56
+ if (found)
57
+ return found.docId === docId && found.seq === base.seq ? found : null;
58
+ return readAtOrBefore(ctx, docId, base.seq - 1);
59
+ };
60
+ const tooBusy = (docId) => new Error(`commit: ${docId} kept changing; gave up after ${COMMIT_ATTEMPTS} attempts`);
61
+ /**
62
+ * @function commitRevision
63
+ * @param ctx {StoreContext<T>} the store
64
+ * @param input {CommitInput<T>} the document, the client's base revision, the value and author
65
+ * @returns {Promise<CommitResult<T>>} committed, merged, unchanged, conflict or missing
66
+ * @throws {Error} when other writers win COMMIT_ATTEMPTS times in a row; RangeError past
67
+ * maxBytes; whatever `check` throws
68
+ */
69
+ export const commitRevision = async (ctx, { docId, base, value, author, kind = "save", message = null }) => {
70
+ for (let attempt = 0; attempt < COMMIT_ATTEMPTS; attempt++) {
71
+ const head = await readHead(ctx, docId);
72
+ if (!head || base.seq > head.seq)
73
+ return { status: "missing" };
74
+ let result;
75
+ if (base.id === head.id) {
76
+ result = await writeNext(ctx, head, { docId, kind, value, author, message }, false);
77
+ }
78
+ else {
79
+ const from = await mergeBase(ctx, docId, base);
80
+ if (!from)
81
+ return { status: "missing" };
82
+ const merged = mergeValue(from.value, value, head.value, ctx.options.codec);
83
+ if (!merged.clean)
84
+ return { status: "conflict", head, merged };
85
+ result = await writeNext(ctx, head, { docId, kind: "merge", value: merged.value, author, message, base: from.id }, true);
86
+ }
87
+ if (result)
88
+ return result;
89
+ }
90
+ throw tooBusy(docId);
91
+ };
92
+ /**
93
+ * @function revertRevision
94
+ * @param ctx {StoreContext<T>} the store
95
+ * @param docId {string} the document
96
+ * @param id {string} the revision whose value to restore
97
+ * @param author {RevisionAuthor} who is reverting
98
+ * @returns {Promise<CommitResult<T>>} committed (kind "revert", base = id), unchanged when the
99
+ * head already holds that value, or missing
100
+ * @throws {Error} as commitRevision
101
+ */
102
+ export const revertRevision = async (ctx, docId, id, author) => {
103
+ for (let attempt = 0; attempt < COMMIT_ATTEMPTS; attempt++) {
104
+ const [head, target] = await Promise.all([readHead(ctx, docId), readOne(ctx, docId, id)]);
105
+ if (!head || !target)
106
+ return { status: "missing" };
107
+ const input = { docId, kind: "revert", value: target.value, author, base: id };
108
+ const result = await writeNext(ctx, head, input, false);
109
+ if (result)
110
+ return result;
111
+ }
112
+ throw tooBusy(docId);
113
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file src/vcs/docs.ts
3
+ * @desc The stored form of a revision (createdAt as a Date, _id as the id), the conversions to
4
+ * and from it, and the indexes the store needs. Internal.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import type { Revision, RevisionMeta } from "@haruhimemoe/vcs";
10
+ import type { IndexSpec } from "../mongo/indexes.js";
11
+ /** A revision as MongoDB holds it. */
12
+ export type RevisionDoc<T> = Omit<Revision<T>, "id" | "createdAt"> & {
13
+ _id: string;
14
+ createdAt: Date;
15
+ };
16
+ /** Every field but the value, for history lists. */
17
+ export declare const META_PROJECTION: {
18
+ readonly value: 0;
19
+ };
20
+ /**
21
+ * @function toMeta
22
+ * @param doc {Omit<RevisionDoc<unknown>, "value">} a stored revision without its value
23
+ * @returns {RevisionMeta} the public metadata, field by field (stray stored fields stay out)
24
+ */
25
+ export declare const toMeta: (doc: Omit<RevisionDoc<unknown>, "value">) => RevisionMeta;
26
+ /**
27
+ * @function toRevision
28
+ * @param doc {RevisionDoc<T>} a stored revision
29
+ * @returns {Revision<T>} the public shape (id, ISO createdAt) with its value
30
+ */
31
+ export declare const toRevision: <T>(doc: RevisionDoc<T>) => Revision<T>;
32
+ /**
33
+ * @function revisionIndexSpecs
34
+ * @param collection {string} the revisions collection
35
+ * @returns {IndexSpec[]} unique (docId, seq) (history order, and what catches two writers),
36
+ * authorId (renames), and (docId, kind, createdAt) (autosave pruning)
37
+ */
38
+ export declare const revisionIndexSpecs: (collection: string) => IndexSpec[];
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @file src/vcs/docs.ts
3
+ * @desc The stored form of a revision (createdAt as a Date, _id as the id), the conversions to
4
+ * and from it, and the indexes the store needs. Internal.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ /** Every field but the value, for history lists. */
10
+ export const META_PROJECTION = { value: 0 };
11
+ /**
12
+ * @function toMeta
13
+ * @param doc {Omit<RevisionDoc<unknown>, "value">} a stored revision without its value
14
+ * @returns {RevisionMeta} the public metadata, field by field (stray stored fields stay out)
15
+ */
16
+ export const toMeta = (doc) => ({
17
+ id: doc._id,
18
+ docId: doc.docId,
19
+ seq: doc.seq,
20
+ kind: doc.kind,
21
+ valueHash: doc.valueHash,
22
+ authorId: doc.authorId,
23
+ authorName: doc.authorName,
24
+ message: doc.message,
25
+ createdAt: doc.createdAt.toISOString(),
26
+ ...(doc.base === undefined ? {} : { base: doc.base }),
27
+ ...(doc.forkOf === undefined ? {} : { forkOf: doc.forkOf }),
28
+ ...(doc.upstream === undefined ? {} : { upstream: doc.upstream }),
29
+ });
30
+ /**
31
+ * @function toRevision
32
+ * @param doc {RevisionDoc<T>} a stored revision
33
+ * @returns {Revision<T>} the public shape (id, ISO createdAt) with its value
34
+ */
35
+ export const toRevision = (doc) => ({
36
+ ...toMeta(doc),
37
+ value: doc.value,
38
+ });
39
+ /**
40
+ * @function revisionIndexSpecs
41
+ * @param collection {string} the revisions collection
42
+ * @returns {IndexSpec[]} unique (docId, seq) (history order, and what catches two writers),
43
+ * authorId (renames), and (docId, kind, createdAt) (autosave pruning)
44
+ */
45
+ export const revisionIndexSpecs = (collection) => [
46
+ { collection, key: { docId: 1, seq: -1 }, unique: true },
47
+ { collection, key: { authorId: 1 } },
48
+ { collection, key: { docId: 1, kind: 1, createdAt: 1 } },
49
+ ];
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file src/vcs/index.ts
3
+ * @desc @haruhimemoe/next-kit/vcs: createRevisionStore, a document history in MongoDB on top of
4
+ * @haruhimemoe/vcs. One line of revisions per document; a save names the revision it
5
+ * started from and is merged onto anything that landed since. Routes, auth and who may see
6
+ * a history stay the app's. Server only: loads mongodb and @haruhimemoe/vcs.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sun Oct 4, 2026
9
+ * @modified Sun Oct 4, 2026
10
+ */
11
+ import type { RevisionStore, RevisionStoreOptions } from "./types.js";
12
+ export { revisionIndexSpecs } from "./docs.js";
13
+ export { COMMIT_ATTEMPTS, DEFAULT_MAX_BYTES, DEFAULT_MAX_REVISIONS, } from "./options.js";
14
+ export { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT } from "./read.js";
15
+ export type { CommitInput, CommitResult, RevisionAuthor, RevisionStore, RevisionStoreOptions, } from "./types.js";
16
+ /**
17
+ * @function createRevisionStore
18
+ * @param options {RevisionStoreOptions<T>} db, collection, and optional codec, check and limits
19
+ * @returns {RevisionStore<T>} the store
20
+ * @throws {TypeError} for a missing collection or a non-positive limit
21
+ */
22
+ export declare const createRevisionStore: <T>(options: RevisionStoreOptions<T>) => RevisionStore<T>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @file src/vcs/index.ts
3
+ * @desc @haruhimemoe/next-kit/vcs: createRevisionStore, a document history in MongoDB on top of
4
+ * @haruhimemoe/vcs. One line of revisions per document; a save names the revision it
5
+ * started from and is merged onto anything that landed since. Routes, auth and who may see
6
+ * a history stay the app's. Server only: loads mongodb and @haruhimemoe/vcs.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sun Oct 4, 2026
9
+ * @modified Sun Oct 4, 2026
10
+ */
11
+ import { ensureIndexes } from "../mongo/indexes.js";
12
+ import { commitRevision, createHistory, revertRevision } from "./commit.js";
13
+ import { revisionIndexSpecs } from "./docs.js";
14
+ import { diffRevisions, pruneAutosaves, removeHistory, renameAuthor } from "./maintenance.js";
15
+ import { resolveOptions } from "./options.js";
16
+ import { readHead, readList, readOne, storeContext } from "./read.js";
17
+ export { revisionIndexSpecs } from "./docs.js";
18
+ export { COMMIT_ATTEMPTS, DEFAULT_MAX_BYTES, DEFAULT_MAX_REVISIONS, } from "./options.js";
19
+ export { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT } from "./read.js";
20
+ /**
21
+ * @function createRevisionStore
22
+ * @param options {RevisionStoreOptions<T>} db, collection, and optional codec, check and limits
23
+ * @returns {RevisionStore<T>} the store
24
+ * @throws {TypeError} for a missing collection or a non-positive limit
25
+ */
26
+ export const createRevisionStore = (options) => {
27
+ const resolved = resolveOptions(options);
28
+ const ctx = storeContext(resolved);
29
+ return {
30
+ indexSpecs: () => revisionIndexSpecs(resolved.collection),
31
+ ensureIndexes: async () => {
32
+ await ensureIndexes(await resolved.db(), revisionIndexSpecs(resolved.collection));
33
+ },
34
+ create: (docId, value, author, message) => createHistory(ctx, docId, value, author, message),
35
+ head: (docId) => readHead(ctx, docId),
36
+ get: (docId, id) => readOne(ctx, docId, id),
37
+ list: (docId, listOptions) => readList(ctx, docId, listOptions),
38
+ commit: (input) => commitRevision(ctx, input),
39
+ revert: (docId, id, author) => revertRevision(ctx, docId, id, author),
40
+ diff: (docId, fromId, toId) => diffRevisions(ctx, docId, fromId, toId),
41
+ renameAuthor: (authorId, name) => renameAuthor(ctx, authorId, name),
42
+ removeDoc: (docId) => removeHistory(ctx, docId),
43
+ pruneAutosaves: (docId, olderThan) => pruneAutosaves(ctx, docId, olderThan),
44
+ };
45
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @file src/vcs/maintenance.ts
3
+ * @desc diff between two revisions, author renames, deleting a document's history and pruning
4
+ * old autosaves. Internal.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import { type Change } from "@haruhimemoe/vcs";
10
+ import { type StoreContext } from "./read.js";
11
+ /** The changes from one revision to another, or null if either is gone. */
12
+ export declare const diffRevisions: <T>(ctx: StoreContext<T>, docId: string, fromId: string, toId: string) => Promise<Change[] | null>;
13
+ /** Rewrites authorName on every revision by this author. */
14
+ export declare const renameAuthor: <T>(ctx: StoreContext<T>, authorId: string, name: string) => Promise<number>;
15
+ /** Deletes every revision of a document. */
16
+ export declare const removeHistory: <T>(ctx: StoreContext<T>, docId: string) => Promise<number>;
17
+ /** Deletes autosaves older than the date with a later non-autosave revision after them. */
18
+ export declare const pruneAutosaves: <T>(ctx: StoreContext<T>, docId: string, olderThan: Date) => Promise<number>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @file src/vcs/maintenance.ts
3
+ * @desc diff between two revisions, author renames, deleting a document's history and pruning
4
+ * old autosaves. Internal.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import { diffValue } from "@haruhimemoe/vcs";
10
+ import { readOne } from "./read.js";
11
+ /** The changes from one revision to another, or null if either is gone. */
12
+ export const diffRevisions = async (ctx, docId, fromId, toId) => {
13
+ const [from, to] = await Promise.all([readOne(ctx, docId, fromId), readOne(ctx, docId, toId)]);
14
+ return from && to ? diffValue(from.value, to.value, ctx.options.codec) : null;
15
+ };
16
+ /** Rewrites authorName on every revision by this author. */
17
+ export const renameAuthor = async (ctx, authorId, name) => (await (await ctx.collection()).updateMany({ authorId }, { $set: { authorName: name } }))
18
+ .modifiedCount;
19
+ /** Deletes every revision of a document. */
20
+ export const removeHistory = async (ctx, docId) => (await (await ctx.collection()).deleteMany({ docId })).deletedCount;
21
+ /** Deletes autosaves older than the date with a later non-autosave revision after them. */
22
+ export const pruneAutosaves = async (ctx, docId, olderThan) => {
23
+ const collection = await ctx.collection();
24
+ const lastSave = await collection.findOne({ docId, kind: { $ne: "autosave" } }, { sort: { seq: -1 }, projection: { seq: 1 } });
25
+ if (!lastSave)
26
+ return 0;
27
+ const result = await collection.deleteMany({
28
+ docId,
29
+ kind: "autosave",
30
+ seq: { $lt: lastSave.seq },
31
+ createdAt: { $lt: olderThan },
32
+ });
33
+ return result.deletedCount;
34
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @file src/vcs/options.ts
3
+ * @desc Checks createRevisionStore's options and fills in the defaults. Internal.
4
+ * @author David @dvhsh (https://dvh.sh)
5
+ * @created Sun Oct 4, 2026
6
+ * @modified Sun Oct 4, 2026
7
+ */
8
+ import { type Codec } from "@haruhimemoe/vcs";
9
+ import type { RevisionStoreOptions } from "./types.js";
10
+ /** Revisions kept per document before the oldest autosaves go. */
11
+ export declare const DEFAULT_MAX_REVISIONS = 1000;
12
+ /** Largest value, in bytes of canonical JSON. */
13
+ export declare const DEFAULT_MAX_BYTES = 1000000;
14
+ /** How many times commit retries when another writer takes its seq. */
15
+ export declare const COMMIT_ATTEMPTS = 3;
16
+ /** Options with every default filled in. */
17
+ export type ResolvedOptions<T> = Required<Omit<RevisionStoreOptions<T>, "check">> & {
18
+ check: NonNullable<RevisionStoreOptions<T>["check"]> | null;
19
+ codec: Codec;
20
+ };
21
+ /**
22
+ * @function resolveOptions
23
+ * @param options {RevisionStoreOptions<T>} the caller's options
24
+ * @returns {ResolvedOptions<T>} the same with defaults
25
+ * @throws {TypeError} for an empty collection name or a non-positive limit
26
+ */
27
+ export declare const resolveOptions: <T>(options: RevisionStoreOptions<T>) => ResolvedOptions<T>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file src/vcs/options.ts
3
+ * @desc Checks createRevisionStore's options and fills in the defaults. Internal.
4
+ * @author David @dvhsh (https://dvh.sh)
5
+ * @created Sun Oct 4, 2026
6
+ * @modified Sun Oct 4, 2026
7
+ */
8
+ import { defineCodec } from "@haruhimemoe/vcs";
9
+ /** Revisions kept per document before the oldest autosaves go. */
10
+ export const DEFAULT_MAX_REVISIONS = 1000;
11
+ /** Largest value, in bytes of canonical JSON. */
12
+ export const DEFAULT_MAX_BYTES = 1_000_000;
13
+ /** How many times commit retries when another writer takes its seq. */
14
+ export const COMMIT_ATTEMPTS = 3;
15
+ const positiveInt = (name, value) => {
16
+ if (!Number.isInteger(value) || value < 1)
17
+ throw new TypeError(`createRevisionStore: ${name} must be a positive integer`);
18
+ return value;
19
+ };
20
+ /**
21
+ * @function resolveOptions
22
+ * @param options {RevisionStoreOptions<T>} the caller's options
23
+ * @returns {ResolvedOptions<T>} the same with defaults
24
+ * @throws {TypeError} for an empty collection name or a non-positive limit
25
+ */
26
+ export const resolveOptions = (options) => {
27
+ if (!options.collection)
28
+ throw new TypeError("createRevisionStore: collection is required");
29
+ return {
30
+ db: options.db,
31
+ collection: options.collection,
32
+ codec: options.codec ?? defineCodec({}),
33
+ check: options.check ?? null,
34
+ maxRevisions: positiveInt("maxRevisions", options.maxRevisions ?? DEFAULT_MAX_REVISIONS),
35
+ maxBytes: positiveInt("maxBytes", options.maxBytes ?? DEFAULT_MAX_BYTES),
36
+ now: options.now ?? Date.now,
37
+ };
38
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file src/vcs/read.ts
3
+ * @desc The store's context (options plus the typed collection) and its reads: head, one
4
+ * revision, the nearest revision at or before a seq, and history pages. Internal.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import type { Revision, RevisionMeta } from "@haruhimemoe/vcs";
10
+ import type { Collection } from "mongodb";
11
+ import { type RevisionDoc } from "./docs.js";
12
+ import type { ResolvedOptions } from "./options.js";
13
+ /** Largest history page. */
14
+ export declare const MAX_LIST_LIMIT = 200;
15
+ /** History page size when none is asked for. */
16
+ export declare const DEFAULT_LIST_LIMIT = 50;
17
+ /** What every store function shares. */
18
+ export type StoreContext<T> = {
19
+ options: ResolvedOptions<T>;
20
+ collection: () => Promise<Collection<RevisionDoc<T>>>;
21
+ };
22
+ /**
23
+ * @function storeContext
24
+ * @param options {ResolvedOptions<T>} resolved options
25
+ * @returns {StoreContext<T>} the context
26
+ */
27
+ export declare const storeContext: <T>(options: ResolvedOptions<T>) => StoreContext<T>;
28
+ /** The latest revision of a document, or null. */
29
+ export declare const readHead: <T>(ctx: StoreContext<T>, docId: string) => Promise<Revision<T> | null>;
30
+ /** One revision of a document, or null. */
31
+ export declare const readOne: <T>(ctx: StoreContext<T>, docId: string, id: string) => Promise<Revision<T> | null>;
32
+ /** A revision by id alone, whatever its document, or null. */
33
+ export declare const readById: <T>(ctx: StoreContext<T>, id: string) => Promise<Revision<T> | null>;
34
+ /** The revision with the largest seq at or below `seq`, or null. */
35
+ export declare const readAtOrBefore: <T>(ctx: StoreContext<T>, docId: string, seq: number) => Promise<Revision<T> | null>;
36
+ /** A history page, newest first, without values. */
37
+ export declare const readList: <T>(ctx: StoreContext<T>, docId: string, { before, limit }?: {
38
+ before?: number;
39
+ limit?: number;
40
+ }) => Promise<RevisionMeta[]>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file src/vcs/read.ts
3
+ * @desc The store's context (options plus the typed collection) and its reads: head, one
4
+ * revision, the nearest revision at or before a seq, and history pages. Internal.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import { META_PROJECTION, toMeta, toRevision } from "./docs.js";
10
+ /** Largest history page. */
11
+ export const MAX_LIST_LIMIT = 200;
12
+ /** History page size when none is asked for. */
13
+ export const DEFAULT_LIST_LIMIT = 50;
14
+ /**
15
+ * @function storeContext
16
+ * @param options {ResolvedOptions<T>} resolved options
17
+ * @returns {StoreContext<T>} the context
18
+ */
19
+ export const storeContext = (options) => ({
20
+ options,
21
+ collection: async () => (await options.db()).collection(options.collection),
22
+ });
23
+ /** The latest revision of a document, or null. */
24
+ export const readHead = async (ctx, docId) => {
25
+ const doc = await (await ctx.collection()).findOne({ docId }, { sort: { seq: -1 } });
26
+ return doc ? toRevision(doc) : null;
27
+ };
28
+ /** One revision of a document, or null. */
29
+ export const readOne = async (ctx, docId, id) => {
30
+ const doc = await (await ctx.collection()).findOne({ _id: id, docId });
31
+ return doc ? toRevision(doc) : null;
32
+ };
33
+ /** A revision by id alone, whatever its document, or null. */
34
+ export const readById = async (ctx, id) => {
35
+ const doc = await (await ctx.collection()).findOne({ _id: id });
36
+ return doc ? toRevision(doc) : null;
37
+ };
38
+ /** The revision with the largest seq at or below `seq`, or null. */
39
+ export const readAtOrBefore = async (ctx, docId, seq) => {
40
+ const doc = await (await ctx.collection()).findOne({ docId, seq: { $lte: seq } }, { sort: { seq: -1 } });
41
+ return doc ? toRevision(doc) : null;
42
+ };
43
+ /** A history page, newest first, without values. */
44
+ export const readList = async (ctx, docId, { before, limit = DEFAULT_LIST_LIMIT } = {}) => {
45
+ const size = Number.isFinite(limit)
46
+ ? Math.max(1, Math.min(MAX_LIST_LIMIT, Math.floor(limit)))
47
+ : DEFAULT_LIST_LIMIT;
48
+ const filter = before !== undefined && Number.isInteger(before) ? { docId, seq: { $lt: before } } : { docId };
49
+ const docs = await (await ctx.collection())
50
+ .find(filter, { projection: META_PROJECTION, sort: { seq: -1 }, limit: size })
51
+ .toArray();
52
+ return docs.map((doc) => toMeta(doc));
53
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @file src/vcs/types.ts
3
+ * @desc The revision store's public shapes: its options, who is writing, what commit and revert
4
+ * return, and the store itself.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ import type { Change, Codec, Revision, RevisionKind, RevisionMeta, RevisionRef, ValueMerge } from "@haruhimemoe/vcs";
10
+ import type { Db } from "mongodb";
11
+ import type { IndexSpec } from "../mongo/indexes.js";
12
+ /** Who is writing a revision. */
13
+ export type RevisionAuthor = {
14
+ id: string;
15
+ name: string;
16
+ };
17
+ /** createRevisionStore's options. */
18
+ export type RevisionStoreOptions<T> = {
19
+ /** The connected database. */
20
+ db: () => Promise<Db>;
21
+ /** The app's revisions collection, like "pool_revisions". */
22
+ collection: string;
23
+ /** Keyed lists, text paths and ignored paths of T (default: none). */
24
+ codec?: Codec;
25
+ /** Runs before every write (create, commit, merge, revert). Throw to refuse the value. */
26
+ check?: (value: T, kind: RevisionKind) => void | Promise<void>;
27
+ /** Per document. Past it, the oldest autosaves go; saves are never deleted. Default 1000. */
28
+ maxRevisions?: number;
29
+ /** Largest value, in bytes of canonical JSON. Default 1,000,000. */
30
+ maxBytes?: number;
31
+ /** The clock (ms). Default Date.now. */
32
+ now?: () => number;
33
+ };
34
+ /** What commit and revert return. Only "committed" and "merged" wrote anything. */
35
+ export type CommitResult<T> = {
36
+ status: "committed";
37
+ revision: Revision<T>;
38
+ } | {
39
+ status: "merged";
40
+ revision: Revision<T>;
41
+ } | {
42
+ status: "unchanged";
43
+ revision: Revision<T>;
44
+ } | {
45
+ status: "conflict";
46
+ head: Revision<T>;
47
+ merged: ValueMerge<T>;
48
+ } | {
49
+ status: "missing";
50
+ };
51
+ /** commit's input. */
52
+ export type CommitInput<T> = {
53
+ docId: string;
54
+ /** The revision the client started from (its id and seq). */
55
+ base: RevisionRef;
56
+ value: T;
57
+ author: RevisionAuthor;
58
+ kind?: "save" | "autosave";
59
+ message?: string | null;
60
+ };
61
+ /** A revision store over one collection. */
62
+ export type RevisionStore<T> = {
63
+ /** The indexes the store needs, for the app's own index list. */
64
+ indexSpecs: () => IndexSpec[];
65
+ /** Builds those indexes (logs, never throws). */
66
+ ensureIndexes: () => Promise<void>;
67
+ /** Starts a document's history (seq 0). Throws if it already has one. */
68
+ create: (docId: string, value: T, author: RevisionAuthor, message?: string | null) => Promise<Revision<T>>;
69
+ head: (docId: string) => Promise<Revision<T> | null>;
70
+ get: (docId: string, id: string) => Promise<Revision<T> | null>;
71
+ /** Newest first, without values. `before`: only revisions with a lower seq. */
72
+ list: (docId: string, options?: {
73
+ before?: number;
74
+ limit?: number;
75
+ }) => Promise<RevisionMeta[]>;
76
+ commit: (input: CommitInput<T>) => Promise<CommitResult<T>>;
77
+ /** Commits an earlier revision's value again (kind "revert", base = that revision). */
78
+ revert: (docId: string, id: string, author: RevisionAuthor) => Promise<CommitResult<T>>;
79
+ /** The changes from one revision to another, or null if either is gone. */
80
+ diff: (docId: string, fromId: string, toId: string) => Promise<Change[] | null>;
81
+ /** Rewrites an author's name on every revision (renames, deleted accounts). */
82
+ renameAuthor: (authorId: string, name: string) => Promise<number>;
83
+ /** Deletes a document's whole history. */
84
+ removeDoc: (docId: string) => Promise<number>;
85
+ /** Deletes autosaves older than the date that a later non-autosave revision follows. */
86
+ pruneAutosaves: (docId: string, olderThan: Date) => Promise<number>;
87
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @file src/vcs/types.ts
3
+ * @desc The revision store's public shapes: its options, who is writing, what commit and revert
4
+ * return, and the store itself.
5
+ * @author David @dvhsh (https://dvh.sh)
6
+ * @created Sun Oct 4, 2026
7
+ * @modified Sun Oct 4, 2026
8
+ */
9
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @file src/vcs/write.ts
3
+ * @desc The one way a revision gets written: size cap, then the app's check, then an insert at a
4
+ * given seq. The unique (docId, seq) index makes a second writer at the same seq fail with
5
+ * a duplicate key, which the caller retries. After an insert, the oldest autosaves past
6
+ * maxRevisions go. Internal.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sun Oct 4, 2026
9
+ * @modified Sun Oct 4, 2026
10
+ */
11
+ import { type Revision } from "@haruhimemoe/vcs";
12
+ import type { RevisionDoc } from "./docs.js";
13
+ import type { StoreContext } from "./read.js";
14
+ import type { RevisionAuthor } from "./types.js";
15
+ /** What insertRevision writes besides the value. */
16
+ export type NewRevision<T> = Pick<RevisionDoc<T>, "docId" | "seq" | "kind"> & {
17
+ value: T;
18
+ author: RevisionAuthor;
19
+ message?: string | null | undefined;
20
+ base?: string | undefined;
21
+ };
22
+ /**
23
+ * @function valueHash
24
+ * @param ctx {StoreContext<T>} the store
25
+ * @param value {T} a value
26
+ * @returns {Promise<string>} the hash of the value without the codec's ignored paths
27
+ */
28
+ export declare const valueHash: <T>(ctx: StoreContext<T>, value: T) => Promise<string>;
29
+ /**
30
+ * @function insertRevision
31
+ * @param ctx {StoreContext<T>} the store
32
+ * @param input {NewRevision<T>} the revision to write
33
+ * @param hash {string | undefined} the value's hash, when the caller already has it
34
+ * @returns {Promise<Revision<T>>} the written revision
35
+ * @throws {RangeError} past maxBytes; whatever `check` throws; a duplicate key error when the
36
+ * seq is taken
37
+ */
38
+ export declare const insertRevision: <T>(ctx: StoreContext<T>, input: NewRevision<T>, hash?: string) => Promise<Revision<T>>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @file src/vcs/write.ts
3
+ * @desc The one way a revision gets written: size cap, then the app's check, then an insert at a
4
+ * given seq. The unique (docId, seq) index makes a second writer at the same seq fail with
5
+ * a duplicate key, which the caller retries. After an insert, the oldest autosaves past
6
+ * maxRevisions go. Internal.
7
+ * @author David @dvhsh (https://dvh.sh)
8
+ * @created Sun Oct 4, 2026
9
+ * @modified Sun Oct 4, 2026
10
+ */
11
+ import { canonicalJson, hashValue, withoutIgnored } from "@haruhimemoe/vcs";
12
+ import { toRevision } from "./docs.js";
13
+ /**
14
+ * @function valueHash
15
+ * @param ctx {StoreContext<T>} the store
16
+ * @param value {T} a value
17
+ * @returns {Promise<string>} the hash of the value without the codec's ignored paths
18
+ */
19
+ export const valueHash = (ctx, value) => hashValue(withoutIgnored(value, ctx.options.codec));
20
+ /**
21
+ * @function insertRevision
22
+ * @param ctx {StoreContext<T>} the store
23
+ * @param input {NewRevision<T>} the revision to write
24
+ * @param hash {string | undefined} the value's hash, when the caller already has it
25
+ * @returns {Promise<Revision<T>>} the written revision
26
+ * @throws {RangeError} past maxBytes; whatever `check` throws; a duplicate key error when the
27
+ * seq is taken
28
+ */
29
+ export const insertRevision = async (ctx, input, hash) => {
30
+ const { options } = ctx;
31
+ const size = new TextEncoder().encode(canonicalJson(input.value)).length;
32
+ if (size > options.maxBytes)
33
+ throw new RangeError(`revision is ${size} bytes, over the ${options.maxBytes} limit`);
34
+ await options.check?.(input.value, input.kind);
35
+ const doc = {
36
+ _id: globalThis.crypto.randomUUID(),
37
+ docId: input.docId,
38
+ seq: input.seq,
39
+ kind: input.kind,
40
+ valueHash: hash ?? (await valueHash(ctx, input.value)),
41
+ authorId: input.author.id,
42
+ authorName: input.author.name,
43
+ message: input.message ?? null,
44
+ createdAt: new Date(options.now()),
45
+ ...(input.base === undefined ? {} : { base: input.base }),
46
+ value: input.value,
47
+ };
48
+ const collection = await ctx.collection();
49
+ await collection.insertOne(doc);
50
+ // Best effort: the revision is written, so a failed prune must not look like a failed save.
51
+ await pruneOverCap(ctx, input.docId, input.seq).catch((error) => {
52
+ console.error(`revisions: pruning ${input.docId} failed`, error);
53
+ });
54
+ return toRevision(doc);
55
+ };
56
+ /** Deletes the oldest autosaves (never the head) while the document is over maxRevisions. */
57
+ const pruneOverCap = async (ctx, docId, headSeq) => {
58
+ const collection = await ctx.collection();
59
+ const over = (await collection.countDocuments({ docId })) - ctx.options.maxRevisions;
60
+ if (over <= 0)
61
+ return;
62
+ const oldest = await collection
63
+ .find({ docId, kind: "autosave", seq: { $lt: headSeq } }, { projection: { _id: 1 } })
64
+ .sort({ seq: 1 })
65
+ .limit(over)
66
+ .toArray();
67
+ if (oldest.length > 0)
68
+ await collection.deleteMany({ _id: { $in: oldest.map((doc) => doc._id) } });
69
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haruhimemoe/next-kit",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "The Next.js plumbing the haruhime.moe tools share: JSON route helpers, rate limits and budgets in MongoDB, bearer machine auth, zod env parsing, a connect-once MongoDB client with safe index builds, better-auth with osu! sign-in, the signed-in marker and account store for the browser, SEO: metadata, robots.txt, sitemaps, JSON-LD and llms.txt, and per-app API keys and the /api/v1 guard.",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -70,6 +70,10 @@
70
70
  "types": "./dist/docs/files/index.d.ts",
71
71
  "default": "./dist/docs/files/index.js"
72
72
  },
73
+ "./vcs": {
74
+ "types": "./dist/vcs/index.d.ts",
75
+ "default": "./dist/vcs/index.js"
76
+ },
73
77
  "./package.json": "./package.json"
74
78
  },
75
79
  "files": [
@@ -101,6 +105,7 @@
101
105
  "peerDependencies": {
102
106
  "@haruhimemoe/osu": "^0.2.0 || ^0.3.0 || ^0.4.0",
103
107
  "@haruhimemoe/ui": "^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0",
108
+ "@haruhimemoe/vcs": "^0.1.0",
104
109
  "better-auth": "^1.7.5",
105
110
  "mongodb": "^7.6.0",
106
111
  "mongodb-memory-server": "^11.3.0",
@@ -118,6 +123,9 @@
118
123
  "@haruhimemoe/ui": {
119
124
  "optional": true
120
125
  },
126
+ "@haruhimemoe/vcs": {
127
+ "optional": true
128
+ },
121
129
  "better-auth": {
122
130
  "optional": true
123
131
  },
@@ -147,6 +155,7 @@
147
155
  "@biomejs/biome": "2.5.14",
148
156
  "@haruhimemoe/osu": "0.2.0",
149
157
  "@haruhimemoe/ui": "0.5.0",
158
+ "@haruhimemoe/vcs": "0.1.0",
150
159
  "@testing-library/dom": "10.4.2",
151
160
  "@testing-library/react": "16.3.3",
152
161
  "@types/node": "26.6.2",