@omgbase/git-adapter 0.0.0-stage → 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 +38 -2
- package/dist/src/config.d.ts +62 -0
- package/dist/src/config.js +91 -0
- package/dist/src/cursor.d.ts +24 -0
- package/dist/src/cursor.js +47 -0
- package/dist/src/exclude.d.ts +5 -0
- package/dist/src/exclude.js +41 -0
- package/dist/src/git.d.ts +157 -0
- package/dist/src/git.js +356 -0
- package/dist/src/ids.d.ts +48 -0
- package/dist/src/ids.js +84 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +17 -0
- package/dist/src/provider.d.ts +63 -0
- package/dist/src/provider.js +113 -0
- package/dist/src/reader.d.ts +44 -0
- package/dist/src/reader.js +190 -0
- package/dist/src/remote.d.ts +113 -0
- package/dist/src/remote.js +631 -0
- package/dist/src/scheduler.d.ts +20 -0
- package/dist/src/scheduler.js +37 -0
- package/dist/src/trailers.d.ts +41 -0
- package/dist/src/trailers.js +124 -0
- package/dist/src/worker.d.ts +50 -0
- package/dist/src/worker.js +202 -0
- package/package.json +51 -3
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function createCheckpointScheduler(opts) {
|
|
2
|
+
const set = opts.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
3
|
+
const clear = opts.clearTimeout ?? ((h) => clearTimeout(h));
|
|
4
|
+
let handle = null;
|
|
5
|
+
const run = () => {
|
|
6
|
+
try {
|
|
7
|
+
opts.run();
|
|
8
|
+
}
|
|
9
|
+
catch (e) {
|
|
10
|
+
opts.onError?.(e);
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const cancel = () => {
|
|
14
|
+
if (handle !== null)
|
|
15
|
+
clear(handle);
|
|
16
|
+
handle = null;
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
notify() {
|
|
20
|
+
if (opts.cadence === "every") {
|
|
21
|
+
run();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
cancel();
|
|
25
|
+
handle = set(() => { handle = null; run(); }, opts.cadence.debounce_ms);
|
|
26
|
+
},
|
|
27
|
+
flush() {
|
|
28
|
+
if (handle === null)
|
|
29
|
+
return;
|
|
30
|
+
cancel();
|
|
31
|
+
run();
|
|
32
|
+
},
|
|
33
|
+
close: cancel,
|
|
34
|
+
pending: () => handle !== null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface DocTrailer {
|
|
2
|
+
docId: string;
|
|
3
|
+
/** the storage path (no leading slash) as of the commit */
|
|
4
|
+
path: string;
|
|
5
|
+
/** the head token minted, or null for a deleted document (`-` on the wire) */
|
|
6
|
+
head: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface CommitTrailers {
|
|
9
|
+
epoch: string;
|
|
10
|
+
/** the feed seq this commit covers — `from === to` for a single row */
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
ts: string;
|
|
14
|
+
commits: string[];
|
|
15
|
+
origin: string | null;
|
|
16
|
+
actor: string | null;
|
|
17
|
+
plan: string | null;
|
|
18
|
+
docs: DocTrailer[];
|
|
19
|
+
/** `omgbase-rescan: <epoch>:<head>` — the commit is a full snapshot */
|
|
20
|
+
rescan: {
|
|
21
|
+
epoch: string;
|
|
22
|
+
head: number;
|
|
23
|
+
} | null;
|
|
24
|
+
/** `omgbase-merge: <remote tip>` (17.6) — the commit merged the remote's tip */
|
|
25
|
+
merge: string | null;
|
|
26
|
+
/** `omgbase-content: <sha>` (17.6) — on the ids ref: the content commit this ids commit belongs to */
|
|
27
|
+
content: string | null;
|
|
28
|
+
}
|
|
29
|
+
export declare const TRAILER_PREFIX = "omgbase-";
|
|
30
|
+
/** `<epoch>:<seq>` or `<epoch>:<from>-<to>`. */
|
|
31
|
+
export declare function seqValue(epoch: string, from: number, to: number): string;
|
|
32
|
+
/** Render the message: subject, blank line, trailers in the pinned order, a final newline. */
|
|
33
|
+
export declare function renderMessage(subject: string, t: CommitTrailers): string;
|
|
34
|
+
/** The subject line (the first line) of a message. */
|
|
35
|
+
export declare function subjectOf(message: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Parse a message's trailers. Every `omgbase-*: value` line after the subject
|
|
38
|
+
* is read (other lines are ignored); a message without `omgbase-seq` is not one
|
|
39
|
+
* of ours → null.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parseMessage(message: string): CommitTrailers | null;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The commit message of a git history commit (spec/store §5.12 "Trailers"):
|
|
2
|
+
// a SUBJECT line, a blank line, then the trailers — `token: value` lines the
|
|
3
|
+
// reader parses back (never through `git interpret-trailers`: both engines
|
|
4
|
+
// parse the same text the same way). The trailers carry what replay and the
|
|
5
|
+
// reader need: the feed position (`omgbase-seq`), the exact instant
|
|
6
|
+
// (`omgbase-ts`), the feed row(s) folded (`omgbase-commit`), the provenance
|
|
7
|
+
// (`omgbase-origin`, `omgbase-actor`, `omgbase-plan`), one `omgbase-doc` per
|
|
8
|
+
// touched document (`<doc_id> <path> <head>`, a deleted document's head `-`)
|
|
9
|
+
// and, on a rescan, `omgbase-rescan: <epoch>:<head>`. Since 17.6 (Phase 6b) a
|
|
10
|
+
// merge commit carries `omgbase-merge: <remote tip>` and a commit of the ids
|
|
11
|
+
// ref `omgbase-content: <content commit sha>` (the content commit it records
|
|
12
|
+
// the identities of).
|
|
13
|
+
export const TRAILER_PREFIX = "omgbase-";
|
|
14
|
+
/** `<epoch>:<seq>` or `<epoch>:<from>-<to>`. */
|
|
15
|
+
export function seqValue(epoch, from, to) {
|
|
16
|
+
return from === to ? `${epoch}:${from}` : `${epoch}:${from}-${to}`;
|
|
17
|
+
}
|
|
18
|
+
function parseSeq(v) {
|
|
19
|
+
const m = /^(\S+):(\d+)(?:-(\d+))?$/.exec(v.trim());
|
|
20
|
+
if (!m)
|
|
21
|
+
return null;
|
|
22
|
+
const from = Number(m[2]);
|
|
23
|
+
return { epoch: m[1], from, to: m[3] !== undefined ? Number(m[3]) : from };
|
|
24
|
+
}
|
|
25
|
+
/** Render the message: subject, blank line, trailers in the pinned order, a final newline. */
|
|
26
|
+
export function renderMessage(subject, t) {
|
|
27
|
+
const lines = [];
|
|
28
|
+
lines.push(`omgbase-seq: ${seqValue(t.epoch, t.from, t.to)}`);
|
|
29
|
+
lines.push(`omgbase-ts: ${t.ts}`);
|
|
30
|
+
for (const c of t.commits)
|
|
31
|
+
lines.push(`omgbase-commit: ${c}`);
|
|
32
|
+
if (t.origin !== null)
|
|
33
|
+
lines.push(`omgbase-origin: ${t.origin}`);
|
|
34
|
+
if (t.actor !== null)
|
|
35
|
+
lines.push(`omgbase-actor: ${t.actor}`);
|
|
36
|
+
if (t.plan !== null)
|
|
37
|
+
lines.push(`omgbase-plan: ${t.plan}`);
|
|
38
|
+
if (t.rescan !== null)
|
|
39
|
+
lines.push(`omgbase-rescan: ${t.rescan.epoch}:${t.rescan.head}`);
|
|
40
|
+
if (t.merge !== null)
|
|
41
|
+
lines.push(`omgbase-merge: ${t.merge}`);
|
|
42
|
+
if (t.content !== null)
|
|
43
|
+
lines.push(`omgbase-content: ${t.content}`);
|
|
44
|
+
for (const d of t.docs)
|
|
45
|
+
lines.push(`omgbase-doc: ${d.docId} ${d.path} ${d.head ?? "-"}`);
|
|
46
|
+
// A subject never holds a newline (the feed's `reason` may: it is flattened).
|
|
47
|
+
const subjectLine = subject.replace(/\s*\n\s*/g, " ").trim();
|
|
48
|
+
return `${subjectLine}\n\n${lines.join("\n")}\n`;
|
|
49
|
+
}
|
|
50
|
+
/** The subject line (the first line) of a message. */
|
|
51
|
+
export function subjectOf(message) {
|
|
52
|
+
return message.split("\n", 1)[0] ?? "";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parse a message's trailers. Every `omgbase-*: value` line after the subject
|
|
56
|
+
* is read (other lines are ignored); a message without `omgbase-seq` is not one
|
|
57
|
+
* of ours → null.
|
|
58
|
+
*/
|
|
59
|
+
export function parseMessage(message) {
|
|
60
|
+
const lines = message.split("\n");
|
|
61
|
+
let seq = null;
|
|
62
|
+
let ts = null;
|
|
63
|
+
const commits = [];
|
|
64
|
+
let origin = null;
|
|
65
|
+
let actor = null;
|
|
66
|
+
let plan = null;
|
|
67
|
+
const docs = [];
|
|
68
|
+
let rescan = null;
|
|
69
|
+
let merge = null;
|
|
70
|
+
let content = null;
|
|
71
|
+
for (const line of lines.slice(1)) {
|
|
72
|
+
const m = /^omgbase-([a-z]+): (.*)$/.exec(line);
|
|
73
|
+
if (!m)
|
|
74
|
+
continue;
|
|
75
|
+
const [, key, value] = m;
|
|
76
|
+
switch (key) {
|
|
77
|
+
case "seq":
|
|
78
|
+
seq = parseSeq(value);
|
|
79
|
+
break;
|
|
80
|
+
case "ts":
|
|
81
|
+
ts = value.trim();
|
|
82
|
+
break;
|
|
83
|
+
case "commit":
|
|
84
|
+
commits.push(value.trim());
|
|
85
|
+
break;
|
|
86
|
+
case "origin":
|
|
87
|
+
origin = value.trim();
|
|
88
|
+
break;
|
|
89
|
+
case "actor":
|
|
90
|
+
actor = value.trim();
|
|
91
|
+
break;
|
|
92
|
+
case "plan":
|
|
93
|
+
plan = value.trim();
|
|
94
|
+
break;
|
|
95
|
+
case "rescan": {
|
|
96
|
+
const r = parseSeq(value);
|
|
97
|
+
if (r)
|
|
98
|
+
rescan = { epoch: r.epoch, head: r.to };
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "merge":
|
|
102
|
+
merge = value.trim();
|
|
103
|
+
break;
|
|
104
|
+
case "content":
|
|
105
|
+
content = value.trim();
|
|
106
|
+
break;
|
|
107
|
+
case "doc": {
|
|
108
|
+
// `<doc_id> <path> <head>`: the id is the first token, the head the last; the path is everything between (it may hold spaces).
|
|
109
|
+
const first = value.indexOf(" ");
|
|
110
|
+
const last = value.lastIndexOf(" ");
|
|
111
|
+
if (first < 0 || last <= first)
|
|
112
|
+
break;
|
|
113
|
+
const head = value.slice(last + 1).trim();
|
|
114
|
+
docs.push({ docId: value.slice(0, first), path: value.slice(first + 1, last), head: head === "-" ? null : head });
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
default: break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!seq)
|
|
121
|
+
return null;
|
|
122
|
+
return { epoch: seq.epoch, from: seq.from, to: seq.to, ts: ts ?? "", commits, origin, actor, plan, docs, rescan, merge, content };
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=trailers.js.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Store, CommitRecord, HistoryCheckpointResult } from "@omgbase/core";
|
|
2
|
+
import { GitRepo, type GitIdentity } from "./git.js";
|
|
3
|
+
import type { GitHistoryConfig } from "./config.js";
|
|
4
|
+
export interface WorkerContext {
|
|
5
|
+
store: Store;
|
|
6
|
+
repoId: string;
|
|
7
|
+
git: GitRepo;
|
|
8
|
+
config: GitHistoryConfig;
|
|
9
|
+
/** the committer (and the author's email); the author's NAME is the row's actor when it has one */
|
|
10
|
+
identity: GitIdentity;
|
|
11
|
+
}
|
|
12
|
+
/** The feed seq the branch tip covers (its trailer), or null without a tip or with a foreign/malformed one. */
|
|
13
|
+
export declare function tipPosition(git: GitRepo, branch: string): {
|
|
14
|
+
tip: string;
|
|
15
|
+
epoch: string;
|
|
16
|
+
seq: number;
|
|
17
|
+
} | null;
|
|
18
|
+
/**
|
|
19
|
+
* The resume rule (17.6): the position the worker resumes from — the tip's
|
|
20
|
+
* trailer when it is of this epoch, refined by the `$git` row when the row
|
|
21
|
+
* names the tip and stands further on; the row alone when the tip has no
|
|
22
|
+
* usable trailer but the row names it; null otherwise (a rescan).
|
|
23
|
+
*/
|
|
24
|
+
export declare function resumePosition(ctx: WorkerContext, epoch: string): {
|
|
25
|
+
tip: string;
|
|
26
|
+
seq: number;
|
|
27
|
+
} | null;
|
|
28
|
+
/**
|
|
29
|
+
* A SNAPSHOT commit (spec/store §5.12 "Rescan"): the full current tree at the
|
|
30
|
+
* feed head, `omgbase-seq: <epoch>:<head>`, `omgbase-rescan: <epoch>:<head>`;
|
|
31
|
+
* the baseline `history enable` writes (`record` given: subject `history
|
|
32
|
+
* enabled`, `omgbase-commit`/`origin` of the baseline feed row) and the
|
|
33
|
+
* recovery from an `expired_cursor` or a tipless branch (subject `rescan: <n>
|
|
34
|
+
* document(s)`).
|
|
35
|
+
*/
|
|
36
|
+
export declare function commitSnapshot(ctx: WorkerContext, now: string, record: CommitRecord | null): {
|
|
37
|
+
tip: string;
|
|
38
|
+
documents: number;
|
|
39
|
+
head: number;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The worker, once (spec/store §5.12): resume by the resume rule (none → a
|
|
43
|
+
* RESCAN: one snapshot commit at the head), page `changes_since` from there,
|
|
44
|
+
* one commit per row under `every` or one commit over every pending row under
|
|
45
|
+
* `{ debounce_ms }` — a group whose tree is its parent's writes none — and
|
|
46
|
+
* report `{ committed, from_seq, to_seq, tip, rescanned }`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function runCheckpoint(ctx: WorkerContext, opts?: {
|
|
49
|
+
now?: string;
|
|
50
|
+
}): HistoryCheckpointResult;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { changesSince, ExpiredCursor, faultPoint, feedStatus, reconstructContent, repoEpoch, storagePath, clearProviderUnavailable } from "@omgbase/core";
|
|
2
|
+
import { GitRepo } from "./git.js";
|
|
3
|
+
import { parseMessage, renderMessage } from "./trailers.js";
|
|
4
|
+
import { readGitCursor, writeGitCursor } from "./cursor.js";
|
|
5
|
+
import { syncIds } from "./ids.js";
|
|
6
|
+
/** The feed seq the branch tip covers (its trailer), or null without a tip or with a foreign/malformed one. */
|
|
7
|
+
export function tipPosition(git, branch) {
|
|
8
|
+
const tip = git.tip(branch);
|
|
9
|
+
if (tip === null)
|
|
10
|
+
return null;
|
|
11
|
+
const t = parseMessage(git.message(tip));
|
|
12
|
+
if (!t)
|
|
13
|
+
return null;
|
|
14
|
+
return { tip, epoch: t.epoch, seq: t.to };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The resume rule (17.6): the position the worker resumes from — the tip's
|
|
18
|
+
* trailer when it is of this epoch, refined by the `$git` row when the row
|
|
19
|
+
* names the tip and stands further on; the row alone when the tip has no
|
|
20
|
+
* usable trailer but the row names it; null otherwise (a rescan).
|
|
21
|
+
*/
|
|
22
|
+
export function resumePosition(ctx, epoch) {
|
|
23
|
+
const tip = ctx.git.tip(ctx.config.branch);
|
|
24
|
+
if (tip === null)
|
|
25
|
+
return null;
|
|
26
|
+
const trailer = parseMessage(ctx.git.message(tip));
|
|
27
|
+
const fromTrailer = trailer && trailer.epoch === epoch ? trailer.to : null;
|
|
28
|
+
const row = readGitCursor(ctx.store.db, ctx.repoId);
|
|
29
|
+
const fromRow = row && row.tip === tip && row.epoch === epoch ? row.seq : null;
|
|
30
|
+
if (fromTrailer === null && fromRow === null)
|
|
31
|
+
return null;
|
|
32
|
+
return { tip, seq: Math.max(fromTrailer ?? -1, fromRow ?? -1) };
|
|
33
|
+
}
|
|
34
|
+
function authorFor(identity, actor) {
|
|
35
|
+
return { name: actor ?? identity.name, email: identity.email };
|
|
36
|
+
}
|
|
37
|
+
/** Write the blob of a live document whose current bytes are what the entry recorded (or whatever they are, when `anyHash`). */
|
|
38
|
+
function stageDoc(ctx, pending, docId, path, entryHash) {
|
|
39
|
+
const db = ctx.store.db;
|
|
40
|
+
const doc = db.prepare("SELECT file_hash, deleted_at FROM docs WHERE doc_id = ?").get(docId);
|
|
41
|
+
if (!doc || doc.deleted_at !== null || !doc.file_hash)
|
|
42
|
+
return; // gone since: a later row removes the path
|
|
43
|
+
if (entryHash !== null && doc.file_hash.toString("hex") !== entryHash)
|
|
44
|
+
return; // superseded within the pending span
|
|
45
|
+
const bytes = reconstructContent(db, docId);
|
|
46
|
+
if (bytes === null)
|
|
47
|
+
return;
|
|
48
|
+
pending.removes.delete(path);
|
|
49
|
+
pending.adds.set(path, ctx.git.writeBlob(bytes));
|
|
50
|
+
}
|
|
51
|
+
function stageDigest(ctx, pending, d) {
|
|
52
|
+
for (const r of d.revisions) {
|
|
53
|
+
const path = storagePath(r._path);
|
|
54
|
+
if (r.kind === "deleted") {
|
|
55
|
+
pending.adds.delete(path);
|
|
56
|
+
pending.removes.add(path);
|
|
57
|
+
pending.docs.set(r._id, { docId: r._id, path, head: null });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (r.kind === "moved" && r.from_path !== undefined) {
|
|
61
|
+
const from = storagePath(r.from_path);
|
|
62
|
+
pending.adds.delete(from);
|
|
63
|
+
pending.removes.add(from);
|
|
64
|
+
}
|
|
65
|
+
const head = ctx.store.db.prepare("SELECT head FROM feed_entries WHERE doc_id = ? AND seq = ? AND repo_id = ?").get(r._id, d.seq, ctx.repoId)?.head ?? null;
|
|
66
|
+
pending.docs.set(r._id, { docId: r._id, path, head });
|
|
67
|
+
stageDoc(ctx, pending, r._id, path, r._hash);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* One git commit over the staged changes — or none, when the staged tree is
|
|
72
|
+
* the parent's (the no-op rule; never for a snapshot) — then the ids commit;
|
|
73
|
+
* the new tip and whether a content commit was written.
|
|
74
|
+
*/
|
|
75
|
+
function commitPending(ctx, pending, parent, subject, trailers, actor, snapshot) {
|
|
76
|
+
const baseTree = snapshot || parent === null ? null : ctx.git.treeOf(parent);
|
|
77
|
+
const tree = ctx.git.buildTree(baseTree, [...pending.adds].map(([path, blob]) => ({ path, blob })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), [...pending.removes].sort());
|
|
78
|
+
const touched = [...pending.docs.keys()];
|
|
79
|
+
if (!snapshot && parent !== null && tree === baseTree) {
|
|
80
|
+
// The no-op rule (17.6): the bytes are already in the tree; the cursor row advances, the identities are still recorded.
|
|
81
|
+
writeGitCursor(ctx.store.db, ctx.repoId, { tip: parent, epoch: trailers.epoch, seq: trailers.to });
|
|
82
|
+
syncIds({ store: ctx.store, repoId: ctx.repoId, git: ctx.git, config: ctx.config, identity: ctx.identity, docs: touched, epoch: trailers.epoch, from: trailers.from, to: trailers.to, ts: trailers.ts, content: parent, actor });
|
|
83
|
+
return { tip: parent, committed: false };
|
|
84
|
+
}
|
|
85
|
+
const message = renderMessage(subject, trailers);
|
|
86
|
+
const sha = ctx.git.commitTree(tree, parent, message, { author: authorFor(ctx.identity, actor), committer: ctx.identity, ts: trailers.ts });
|
|
87
|
+
// OMGBASE_FAULT=git.commit (spec/store §9.7): the objects are written, the ref has not moved.
|
|
88
|
+
faultPoint("git.commit");
|
|
89
|
+
ctx.git.updateRef(ctx.config.branch, sha, parent);
|
|
90
|
+
writeGitCursor(ctx.store.db, ctx.repoId, { tip: sha, epoch: trailers.epoch, seq: trailers.to });
|
|
91
|
+
syncIds({ store: ctx.store, repoId: ctx.repoId, git: ctx.git, config: ctx.config, identity: ctx.identity, docs: snapshot ? null : touched, epoch: trailers.epoch, from: trailers.from, to: trailers.to, ts: trailers.ts, content: sha, actor });
|
|
92
|
+
return { tip: sha, committed: true };
|
|
93
|
+
}
|
|
94
|
+
/** The whole live tree of the repository staged (a rescan / the baseline). */
|
|
95
|
+
function stageSnapshot(ctx) {
|
|
96
|
+
const pending = { adds: new Map(), removes: new Set(), docs: new Map() };
|
|
97
|
+
const live = ctx.store.db.prepare("SELECT doc_id, path, head FROM docs WHERE repo_id = ? AND deleted_at IS NULL AND head IS NOT NULL ORDER BY path").all(ctx.repoId);
|
|
98
|
+
for (const d of live) {
|
|
99
|
+
pending.docs.set(d.doc_id, { docId: d.doc_id, path: d.path, head: d.head });
|
|
100
|
+
stageDoc(ctx, pending, d.doc_id, d.path, null);
|
|
101
|
+
}
|
|
102
|
+
return pending;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* A SNAPSHOT commit (spec/store §5.12 "Rescan"): the full current tree at the
|
|
106
|
+
* feed head, `omgbase-seq: <epoch>:<head>`, `omgbase-rescan: <epoch>:<head>`;
|
|
107
|
+
* the baseline `history enable` writes (`record` given: subject `history
|
|
108
|
+
* enabled`, `omgbase-commit`/`origin` of the baseline feed row) and the
|
|
109
|
+
* recovery from an `expired_cursor` or a tipless branch (subject `rescan: <n>
|
|
110
|
+
* document(s)`).
|
|
111
|
+
*/
|
|
112
|
+
export function commitSnapshot(ctx, now, record) {
|
|
113
|
+
// A purged (or never initialised) repository is created afresh: the snapshot is its first commit.
|
|
114
|
+
ctx.git.init(ctx.config.branch);
|
|
115
|
+
const status = feedStatus(ctx.store.db, ctx.repoId);
|
|
116
|
+
const pending = stageSnapshot(ctx);
|
|
117
|
+
const docs = [...pending.docs.values()];
|
|
118
|
+
const trailers = {
|
|
119
|
+
epoch: status.epoch, from: status.head, to: status.head, ts: record?.ts ?? now,
|
|
120
|
+
commits: record ? [record.commitId] : [], origin: record?.origin ?? null, actor: record?.actor ?? null, plan: null,
|
|
121
|
+
docs, rescan: { epoch: status.epoch, head: status.head }, merge: null, content: null,
|
|
122
|
+
};
|
|
123
|
+
const subject = record ? (record.reason ?? `${record.origin}: ${docs.length} document(s)`) : `rescan: ${docs.length} document(s)`;
|
|
124
|
+
const parent = ctx.git.tip(ctx.config.branch);
|
|
125
|
+
const { tip } = commitPending(ctx, pending, parent, subject, trailers, record?.actor ?? null, true);
|
|
126
|
+
return { tip, documents: docs.length, head: status.head };
|
|
127
|
+
}
|
|
128
|
+
const PAGE = 200;
|
|
129
|
+
/**
|
|
130
|
+
* The worker, once (spec/store §5.12): resume by the resume rule (none → a
|
|
131
|
+
* RESCAN: one snapshot commit at the head), page `changes_since` from there,
|
|
132
|
+
* one commit per row under `every` or one commit over every pending row under
|
|
133
|
+
* `{ debounce_ms }` — a group whose tree is its parent's writes none — and
|
|
134
|
+
* report `{ committed, from_seq, to_seq, tip, rescanned }`.
|
|
135
|
+
*/
|
|
136
|
+
export function runCheckpoint(ctx, opts = {}) {
|
|
137
|
+
const now = opts.now ?? new Date().toISOString();
|
|
138
|
+
const { store, repoId, config } = ctx;
|
|
139
|
+
const epoch = repoEpoch(store.db, repoId);
|
|
140
|
+
const position = ctx.git.exists() ? resumePosition(ctx, epoch) : null;
|
|
141
|
+
const start = position?.seq ?? null;
|
|
142
|
+
const rescan = () => {
|
|
143
|
+
const snap = commitSnapshot(ctx, now, null);
|
|
144
|
+
clearProviderUnavailable(store.db, repoId, now);
|
|
145
|
+
return { committed: 1, from_seq: snap.head, to_seq: snap.head, tip: snap.tip, rescanned: true };
|
|
146
|
+
};
|
|
147
|
+
// No usable position — no tip (the repository was purged or never baselined), a tip of another epoch nobody placed — is a rescan.
|
|
148
|
+
if (start === null)
|
|
149
|
+
return rescan();
|
|
150
|
+
let cursor = start;
|
|
151
|
+
let committed = 0;
|
|
152
|
+
let tip = position.tip;
|
|
153
|
+
const digests = [];
|
|
154
|
+
// Collect every pending row first (paging), so a debounce cadence folds them all.
|
|
155
|
+
for (;;) {
|
|
156
|
+
let page;
|
|
157
|
+
try {
|
|
158
|
+
page = changesSince(store, repoId, { cursor, limit: PAGE });
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if (e instanceof ExpiredCursor)
|
|
162
|
+
return rescan();
|
|
163
|
+
throw e;
|
|
164
|
+
}
|
|
165
|
+
digests.push(...page.digests);
|
|
166
|
+
cursor = page.cursor;
|
|
167
|
+
if (!page.truncated)
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
if (digests.length === 0) {
|
|
171
|
+
clearProviderUnavailable(store.db, repoId, now);
|
|
172
|
+
return { committed: 0, from_seq: start, to_seq: start, tip, rescanned: false };
|
|
173
|
+
}
|
|
174
|
+
const groups = config.cadence === "every" ? digests.map((d) => [d]) : [digests];
|
|
175
|
+
for (const group of groups) {
|
|
176
|
+
const pending = { adds: new Map(), removes: new Set(), docs: new Map() };
|
|
177
|
+
for (const d of group)
|
|
178
|
+
stageDigest(ctx, pending, d);
|
|
179
|
+
const first = group[0];
|
|
180
|
+
const last = group[group.length - 1];
|
|
181
|
+
const docs = [...pending.docs.values()];
|
|
182
|
+
const trailers = {
|
|
183
|
+
epoch, from: first.seq, to: last.seq, ts: last.ts,
|
|
184
|
+
commits: group.map((d) => d.commit), origin: last.origin, actor: last.actor, plan: last.plan_id ?? null,
|
|
185
|
+
docs, rescan: null, merge: null, content: null,
|
|
186
|
+
};
|
|
187
|
+
const reason = group.length === 1 ? reasonOf(store, first.commit) : null;
|
|
188
|
+
const subject = group.length === 1 ? (reason ?? `${first.origin}: ${docs.length} document(s)`) : `checkpoint: ${group.length} commits, ${docs.length} document(s)`;
|
|
189
|
+
const r = commitPending(ctx, pending, tip, subject, trailers, last.actor, false);
|
|
190
|
+
tip = r.tip;
|
|
191
|
+
if (r.committed)
|
|
192
|
+
committed++;
|
|
193
|
+
}
|
|
194
|
+
clearProviderUnavailable(store.db, repoId, now);
|
|
195
|
+
return { committed, from_seq: start + 1, to_seq: cursor, tip, rescanned: false };
|
|
196
|
+
}
|
|
197
|
+
/** The feed row's `reason` (the digest carries `summary`, not the reason). */
|
|
198
|
+
function reasonOf(store, commitId) {
|
|
199
|
+
const row = store.db.prepare("SELECT reason FROM feed WHERE commit_id = ?").get(commitId);
|
|
200
|
+
return row?.reason ?? null;
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=worker.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omgbase/git-adapter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open Markdown Graph Base — the Git-backed history provider (modular-core Phase 6): a checkpoint worker that builds commits in a bare repository under .omgbase/git from the engine's change feed, a HistoryReader over it, and the remote half — push (never --force), fetch with a tree-level three-way merge whose inbound content enters through the source boundary, conflict sidecars, divergence remediations, the refs/omgbase/ids identity chain; attached by the CLI/MCP host through the core's provider registry",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/omgbase/omgbase.git",
|
|
9
|
+
"directory": "packages/git-adapter"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/omgbase/omgbase#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/omgbase/omgbase/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"markdown",
|
|
17
|
+
"knowledge-graph",
|
|
18
|
+
"omgbase",
|
|
19
|
+
"git",
|
|
20
|
+
"history"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/**/*.test.*",
|
|
31
|
+
"!dist/**/*.map",
|
|
32
|
+
"!dist/**/*.tsbuildinfo",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"type": "module",
|
|
36
|
+
"main": "./dist/src/index.js",
|
|
37
|
+
"types": "./dist/src/index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"import": "./dist/src/index.js",
|
|
41
|
+
"types": "./dist/src/index.d.ts"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@omgbase/core": "^0.33.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.json",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"lint": "eslint .",
|
|
52
|
+
"lint:fix": "eslint . --fix"
|
|
53
|
+
}
|
|
6
54
|
}
|