@defensestation/ysync-go 0.1.0 → 0.2.0-dev.13.f029dd9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,6 +96,50 @@ documented in
96
96
  Generated protobuf descriptors are exported under `@defensestation/ysync-go/ysync/v1`
97
97
  if you need raw RPC access.
98
98
 
99
+ ## Compaction adapter (Node 18+)
100
+
101
+ The server's update log grows until the host folds it - **compaction is
102
+ required in production** (see the repository README). The snapshot must be
103
+ built with real Yjs (Go-side merges corrupt nested-XML documents -
104
+ BlockNote, ProseMirror, Tiptap), so this package ships the adapter:
105
+
106
+ ```ts
107
+ import { compactDocuments } from "@defensestation/ysync-go/compaction";
108
+
109
+ // AWS Lambda handler, Cloud Run job, or any Node 18+ process:
110
+ export const handler = async () => {
111
+ const run = await compactDocuments(
112
+ {
113
+ baseUrl: "https://collab.internal.example.com",
114
+ headers: () => ({ authorization: `Bearer ${process.env.COMPACT_TOKEN}` }),
115
+ threshold: 200, // fold once a doc exceeds this many log records
116
+ keep: 50, // newest records kept individually attributed
117
+ },
118
+ await listCandidateDocIds(), // doc discovery is the host app's job
119
+ );
120
+ if (run.errors.length > 0) {
121
+ throw new Error(`${run.errors.length} document(s) failed`);
122
+ }
123
+ return run; // { compacted, skipped, results, errors }
124
+ };
125
+ ```
126
+
127
+ `compactDocument(options, docId)` is the single-document primitive.
128
+ Everything is idempotent - retries and concurrent runs converge - and one
129
+ bad document never aborts a `compactDocuments` run.
130
+
131
+ For cron-style schedulers there is a CLI:
132
+
133
+ ```bash
134
+ npx ysync-compact --base-url http://localhost:8080 --docs doc-1,doc-2 \
135
+ --threshold 200 --keep 50 --header 'authorization: Bearer ...'
136
+ # or stream doc IDs, one per line:
137
+ psql -Atc 'select id from documents' | npx ysync-compact --base-url ... --docs -
138
+ ```
139
+
140
+ Compaction rewrites stored history: gate the server's `Authz.CanCompact`
141
+ so **only** this job's identity holds it.
142
+
99
143
  ## License
100
144
 
101
145
  MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/compaction/cli.ts"],"names":[],"mappings":""}
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // ysync-compact: compaction CLI over the programmatic adapter in
3
+ // compaction.ts, for cron-style schedulers (Kubernetes CronJob, systemd
4
+ // timers). Lambda/Cloud Run users should import `compactDocuments` directly.
5
+ //
6
+ // Usage:
7
+ // ysync-compact --base-url http://localhost:8080 --docs doc-1,doc-2
8
+ // ysync-compact --base-url ... --docs - # doc IDs from stdin, one per line
9
+ // ysync-compact --base-url ... --docs ... \
10
+ // [--threshold 200] [--keep 50] \
11
+ // [--header 'authorization: Bearer ...']... [--user-id compact-sidecar]
12
+ //
13
+ // Exits 0 when every document succeeded, 1 when any document failed, 2 on
14
+ // usage errors. Gate the server's Authz.CanCompact to this job's identity.
15
+ import { compactDocuments } from "./compaction.js";
16
+ async function main() {
17
+ let args;
18
+ try {
19
+ args = parseArgs(process.argv.slice(2));
20
+ }
21
+ catch (error) {
22
+ console.error(String(error instanceof Error ? error.message : error));
23
+ return 2;
24
+ }
25
+ if (!args.baseUrl || !args.docs) {
26
+ console.error("usage: ysync-compact --base-url URL --docs id1,id2|- [--threshold N] " +
27
+ "[--keep N] [--header 'name: value']... [--user-id ID]");
28
+ return 2;
29
+ }
30
+ const docIds = args.docs === "-"
31
+ ? (await readStdin()).split("\n").map((line) => line.trim())
32
+ : args.docs.split(",").map((docId) => docId.trim());
33
+ const filtered = docIds.filter((docId) => docId.length > 0);
34
+ if (filtered.length === 0) {
35
+ console.error("no document IDs given");
36
+ return 2;
37
+ }
38
+ const headers = { ...args.headers };
39
+ if (args.userId) {
40
+ // Demo identity hint; real deployments attach identity via auth
41
+ // middleware and pass the credential with --header instead.
42
+ headers["x-user-id"] = args.userId;
43
+ }
44
+ const run = await compactDocuments({
45
+ baseUrl: args.baseUrl,
46
+ headers,
47
+ threshold: args.threshold === undefined ? undefined : parseCount("--threshold", args.threshold),
48
+ keep: args.keep === undefined ? undefined : parseCount("--keep", args.keep),
49
+ }, filtered);
50
+ for (const result of run.results) {
51
+ if (result.compacted) {
52
+ console.log(`${result.docId}: folded ${result.foldedRecords}/${result.records} records ` +
53
+ `through sequence ${result.foldedThroughSequence} into a ` +
54
+ `${result.snapshotBytes}-byte snapshot`);
55
+ }
56
+ else {
57
+ console.log(`${result.docId}: ${result.records} records, nothing to do`);
58
+ }
59
+ }
60
+ for (const failure of run.errors) {
61
+ console.error(`${failure.docId}: ${String(failure.error)}`);
62
+ }
63
+ return run.errors.length > 0 ? 1 : 0;
64
+ }
65
+ function parseArgs(argv) {
66
+ const args = { headers: {} };
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const flag = argv[i];
69
+ const value = argv[i + 1];
70
+ if (!flag.startsWith("--") || value === undefined) {
71
+ throw new Error(`unexpected argument: ${flag}`);
72
+ }
73
+ i++;
74
+ switch (flag) {
75
+ case "--base-url":
76
+ args.baseUrl = value;
77
+ break;
78
+ case "--docs":
79
+ args.docs = value;
80
+ break;
81
+ case "--threshold":
82
+ args.threshold = value;
83
+ break;
84
+ case "--keep":
85
+ args.keep = value;
86
+ break;
87
+ case "--user-id":
88
+ args.userId = value;
89
+ break;
90
+ case "--header": {
91
+ const colon = value.indexOf(":");
92
+ if (colon < 1) {
93
+ throw new Error(`--header expects 'name: value', got ${JSON.stringify(value)}`);
94
+ }
95
+ args.headers[value.slice(0, colon).trim()] = value.slice(colon + 1).trim();
96
+ break;
97
+ }
98
+ default:
99
+ throw new Error(`unknown flag: ${flag}`);
100
+ }
101
+ }
102
+ return args;
103
+ }
104
+ function parseCount(flag, value) {
105
+ const parsed = Number(value);
106
+ if (!Number.isInteger(parsed) || parsed < 0) {
107
+ throw new Error(`${flag} expects a non-negative integer, got ${JSON.stringify(value)}`);
108
+ }
109
+ return parsed;
110
+ }
111
+ function readStdin() {
112
+ return new Promise((resolve, reject) => {
113
+ let data = "";
114
+ process.stdin.setEncoding("utf8");
115
+ process.stdin.on("data", (chunk) => (data += chunk));
116
+ process.stdin.on("end", () => resolve(data));
117
+ process.stdin.on("error", reject);
118
+ });
119
+ }
120
+ main().then((code) => process.exit(code), (error) => {
121
+ console.error(String(error));
122
+ process.exit(1);
123
+ });
124
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/compaction/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ /** How to reach the ysync server and authenticate against it. */
2
+ export interface CompactionClientOptions {
3
+ /** Server base URL, e.g. "http://localhost:8080". */
4
+ baseUrl: string;
5
+ /**
6
+ * Extra request headers (an authenticated identity that passes
7
+ * `Authz.CanCompact`). A function is awaited per request, so short-lived
8
+ * tokens can be refreshed.
9
+ */
10
+ headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
11
+ /** Override the fetch implementation (tests, custom agents). */
12
+ fetch?: typeof globalThis.fetch;
13
+ }
14
+ /** When and how much to fold. Zero-config defaults match examples/compact-docs. */
15
+ export interface CompactionPolicy {
16
+ /**
17
+ * Compact only when the document has more than this many log records
18
+ * (snapshot record included). Default 200.
19
+ */
20
+ threshold?: number;
21
+ /**
22
+ * Newest records to keep individually attributed for change history.
23
+ * Default 50.
24
+ */
25
+ keep?: number;
26
+ }
27
+ export interface CompactDocumentOptions extends CompactionClientOptions, CompactionPolicy {
28
+ }
29
+ export interface CompactDocumentResult {
30
+ docId: string;
31
+ /** False when the log was at or below the threshold (nothing folded). */
32
+ compacted: boolean;
33
+ /** Log records before compaction. */
34
+ records: number;
35
+ /** Records folded into the snapshot (set when compacted). */
36
+ foldedRecords?: number;
37
+ /** Sequence the log was folded through (set when compacted). */
38
+ foldedThroughSequence?: string;
39
+ /** Encoded snapshot size in bytes (set when compacted). */
40
+ snapshotBytes?: number;
41
+ }
42
+ export interface CompactionRunResult {
43
+ results: CompactDocumentResult[];
44
+ /** Per-document failures; one bad document never aborts the run. */
45
+ errors: {
46
+ docId: string;
47
+ error: unknown;
48
+ }[];
49
+ compacted: number;
50
+ skipped: number;
51
+ }
52
+ /**
53
+ * Compacts one document if its update log exceeds the policy threshold:
54
+ * lists the stored log, replays all but the newest `keep` records into a
55
+ * fresh Y.Doc, encodes the state with `Y.encodeStateAsUpdate`, and calls
56
+ * `CompactDocument` for the covered sequence. Idempotent — re-running
57
+ * converges to the same folded log, and updates pushed concurrently are
58
+ * unaffected (they sort after `compactThroughSequence`).
59
+ */
60
+ export declare function compactDocument(options: CompactDocumentOptions, docId: string): Promise<CompactDocumentResult>;
61
+ /**
62
+ * Compacts many documents sequentially (compaction is background work;
63
+ * predictable server load beats speed here). Per-document failures are
64
+ * collected in `errors` instead of aborting the run.
65
+ */
66
+ export declare function compactDocuments(options: CompactDocumentOptions, docIds: Iterable<string>): Promise<CompactionRunResult>;
67
+ //# sourceMappingURL=compaction.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.d.ts","sourceRoot":"","sources":["../../src/compaction/compaction.ts"],"names":[],"mappings":"AAmBA,iEAAiE;AACjE,MAAM,WAAW,uBAAuB;IACtC,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,OAAO,CAAC,EACJ,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACtB,CAAC,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IACrE,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC;AAED,mFAAmF;AACnF,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,sBACf,SAAQ,uBAAuB,EAC7B,gBAAgB;CAAG;AAEvB,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,SAAS,EAAE,OAAO,CAAC;IACnB,qCAAqC;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gEAAgE;IAChE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,2DAA2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,oEAAoE;IACpE,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,EAAE,CAAC;IAC5C,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAWD;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,sBAAsB,EAC/B,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,qBAAqB,CAAC,CAuDhC;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,sBAAsB,EAC/B,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,GACvB,OAAO,CAAC,mBAAmB,CAAC,CAqB9B"}
@@ -0,0 +1,153 @@
1
+ // Compaction adapter for ysync-go servers.
2
+ //
3
+ // The server's update log grows until the host folds it (see the repository
4
+ // README's Compaction section). This module implements the recommended
5
+ // policy — once a document exceeds `threshold` log records, fold everything
6
+ // except the newest `keep` records into one snapshot — using **real Yjs** to
7
+ // build the snapshot. That matters: Go-side merges corrupt documents with
8
+ // nested XML content (BlockNote, ProseMirror, Tiptap); see
9
+ // adapters/yjs/ygo/README.md and its xmlrepro/ cross-implementation check.
10
+ //
11
+ // The functions are runtime-agnostic on purpose: call `compactDocuments`
12
+ // from an AWS Lambda handler, a Cloud Run job, a Kubernetes CronJob (the
13
+ // bundled `ysync-compact` CLI), or any Node 18+ process with `fetch`.
14
+ //
15
+ // Security: `CompactDocument` rewrites stored history, so gate it with
16
+ // `Authz.CanCompact` on the server and give **only** this job's identity
17
+ // that permission. Pass the credential via `headers`.
18
+ import * as Y from "yjs";
19
+ const DEFAULT_THRESHOLD = 200;
20
+ const DEFAULT_KEEP = 50;
21
+ /**
22
+ * Compacts one document if its update log exceeds the policy threshold:
23
+ * lists the stored log, replays all but the newest `keep` records into a
24
+ * fresh Y.Doc, encodes the state with `Y.encodeStateAsUpdate`, and calls
25
+ * `CompactDocument` for the covered sequence. Idempotent — re-running
26
+ * converges to the same folded log, and updates pushed concurrently are
27
+ * unaffected (they sort after `compactThroughSequence`).
28
+ */
29
+ export async function compactDocument(options, docId) {
30
+ if (!docId) {
31
+ throw new Error("docId is required");
32
+ }
33
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
34
+ const keep = options.keep ?? DEFAULT_KEEP;
35
+ if (threshold < 1 || keep < 0) {
36
+ throw new Error(`invalid policy: threshold ${threshold}, keep ${keep}`);
37
+ }
38
+ const listed = (await rpc(options, "ListDocumentUpdates", { docId }));
39
+ const updates = listed.updates ?? [];
40
+ const skip = {
41
+ docId,
42
+ compacted: false,
43
+ records: updates.length,
44
+ };
45
+ if (updates.length <= threshold) {
46
+ return skip;
47
+ }
48
+ // Records arrive in sequence order; the first may already be a snapshot
49
+ // from a prior run. Folding only that snapshot again would be a no-op.
50
+ const cutoff = updates.length - keep;
51
+ if (cutoff < 1 || (cutoff === 1 && updates[0]?.isSnapshot)) {
52
+ return skip;
53
+ }
54
+ const target = updates[cutoff - 1];
55
+ if (target?.sequence === undefined) {
56
+ throw new Error(`${docId}: log record ${cutoff - 1} has no sequence`);
57
+ }
58
+ const doc = new Y.Doc();
59
+ for (const record of updates.slice(0, cutoff)) {
60
+ if (record.yjsUpdate) {
61
+ Y.applyUpdate(doc, fromBase64(record.yjsUpdate));
62
+ }
63
+ }
64
+ const snapshot = Y.encodeStateAsUpdate(doc);
65
+ doc.destroy();
66
+ await rpc(options, "CompactDocument", {
67
+ docId,
68
+ compactUpdate: toBase64(snapshot),
69
+ compactThroughSequence: target.sequence,
70
+ });
71
+ return {
72
+ docId,
73
+ compacted: true,
74
+ records: updates.length,
75
+ foldedRecords: cutoff,
76
+ foldedThroughSequence: String(target.sequence),
77
+ snapshotBytes: snapshot.byteLength,
78
+ };
79
+ }
80
+ /**
81
+ * Compacts many documents sequentially (compaction is background work;
82
+ * predictable server load beats speed here). Per-document failures are
83
+ * collected in `errors` instead of aborting the run.
84
+ */
85
+ export async function compactDocuments(options, docIds) {
86
+ const run = {
87
+ results: [],
88
+ errors: [],
89
+ compacted: 0,
90
+ skipped: 0,
91
+ };
92
+ for (const docId of docIds) {
93
+ try {
94
+ const result = await compactDocument(options, docId);
95
+ run.results.push(result);
96
+ if (result.compacted) {
97
+ run.compacted++;
98
+ }
99
+ else {
100
+ run.skipped++;
101
+ }
102
+ }
103
+ catch (error) {
104
+ run.errors.push({ docId, error });
105
+ }
106
+ }
107
+ return run;
108
+ }
109
+ /** Plain Connect JSON: POST /<service>/<method> with a JSON body. */
110
+ async function rpc(options, method, body) {
111
+ const headers = typeof options.headers === "function"
112
+ ? await options.headers()
113
+ : (options.headers ?? {});
114
+ const doFetch = options.fetch ?? globalThis.fetch;
115
+ const base = options.baseUrl.replace(/\/+$/, "");
116
+ const response = await doFetch(`${base}/ysync.v1.YjsSyncService/${method}`, {
117
+ method: "POST",
118
+ headers: { "content-type": "application/json", ...headers },
119
+ body: JSON.stringify(body),
120
+ });
121
+ if (!response.ok) {
122
+ const detail = await response.text().catch(() => "");
123
+ throw new Error(`${method}: HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
124
+ }
125
+ return response.json();
126
+ }
127
+ function nodeBuffer() {
128
+ return globalThis.Buffer;
129
+ }
130
+ function toBase64(bytes) {
131
+ const buffer = nodeBuffer();
132
+ if (buffer) {
133
+ return buffer.from(bytes).toString("base64");
134
+ }
135
+ let binary = "";
136
+ for (const byte of bytes) {
137
+ binary += String.fromCharCode(byte);
138
+ }
139
+ return btoa(binary);
140
+ }
141
+ function fromBase64(encoded) {
142
+ const buffer = nodeBuffer();
143
+ if (buffer) {
144
+ return Uint8Array.from(buffer.from(encoded, "base64"));
145
+ }
146
+ const binary = atob(encoded);
147
+ const out = new Uint8Array(binary.length);
148
+ for (let i = 0; i < binary.length; i++) {
149
+ out[i] = binary.charCodeAt(i);
150
+ }
151
+ return out;
152
+ }
153
+ //# sourceMappingURL=compaction.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compaction.js","sourceRoot":"","sources":["../../src/compaction/compaction.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export { compactDocument, compactDocuments } from "./compaction.js";
2
+ export type { CompactionClientOptions, CompactionPolicy, CompactDocumentOptions, CompactDocumentResult, CompactionRunResult, } from "./compaction.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/compaction/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACpE,YAAY,EACV,uBAAuB,EACvB,gBAAgB,EAChB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { compactDocument, compactDocuments } from "./compaction.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/compaction/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defensestation/ysync-go",
3
- "version": "0.1.0",
3
+ "version": "0.2.0-dev.13.f029dd9",
4
4
  "description": "Browser Yjs providers (Connect RPC and WebSocket) for the ysync-go collaboration backend",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,14 +30,20 @@
30
30
  "types": "./dist/index.d.ts",
31
31
  "import": "./dist/index.js"
32
32
  },
33
+ "./compaction": {
34
+ "types": "./dist/compaction/index.d.ts",
35
+ "import": "./dist/compaction/index.js"
36
+ },
33
37
  "./ysync/v1": {
34
38
  "types": "./dist/gen/ysync/v1/sync_pb.d.ts",
35
39
  "import": "./dist/gen/ysync/v1/sync_pb.js"
36
40
  }
37
41
  },
42
+ "bin": {
43
+ "ysync-compact": "dist/compaction/cli.js"
44
+ },
38
45
  "files": [
39
- "dist",
40
- "src"
46
+ "dist"
41
47
  ],
42
48
  "scripts": {
43
49
  "generate": "cd .. && PATH=\"./npm/node_modules/.bin:$PATH\" buf generate --template npm/buf.gen.yaml",