@backloghq/opslog 0.4.1 → 0.5.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/README.md CHANGED
@@ -134,11 +134,41 @@ await store.open(dir, {
134
134
  version: 1, // Schema version
135
135
  migrate: (record, fromVersion) => record, // Migration function
136
136
  readOnly: false, // Open in read-only mode (default: false)
137
+ writeMode: "immediate", // "immediate" (default) or "group" (buffered, ~12x faster)
138
+ groupCommitSize: 50, // Group: flush after N ops (default: 50)
139
+ groupCommitMs: 100, // Group: flush after N ms (default: 100)
137
140
  agentId: "agent-A", // Enable multi-writer mode (optional)
138
141
  backend: new FsBackend(), // Custom storage backend (optional, default: FsBackend)
139
142
  });
140
143
  ```
141
144
 
145
+ ## Group Commit
146
+
147
+ Buffer writes in memory and flush as a single disk write. ~12x faster for sustained writes.
148
+
149
+ ```typescript
150
+ const store = new Store();
151
+ await store.open("./data", {
152
+ writeMode: "group", // Buffer ops, flush periodically
153
+ groupCommitSize: 50, // Flush when buffer has 50 ops
154
+ groupCommitMs: 100, // Or after 100ms idle
155
+ });
156
+
157
+ // Writes are buffered — no fsync per op
158
+ await store.set("a", valueA);
159
+ await store.set("b", valueB); // Both flushed together
160
+
161
+ // Explicit flush if needed
162
+ await store.flush();
163
+
164
+ // close() always flushes before shutting down
165
+ await store.close();
166
+ ```
167
+
168
+ **Safety:** Forced to `"immediate"` when `agentId` is set (multi-writer mode). Other agents can't see buffered ops, so group commit is single-writer only.
169
+
170
+ **Tradeoff:** A crash can lose ops that haven't been flushed yet (up to `groupCommitMs` milliseconds of data). Use `"immediate"` (default) when every write must be durable.
171
+
142
172
  ## Multi-Writer Mode
143
173
 
144
174
  Multiple agents can write to the same store concurrently. Each agent gets its own WAL file — no write contention.
package/dist/store.d.ts CHANGED
@@ -17,6 +17,11 @@ export declare class Store<T = Record<string, unknown>> {
17
17
  private backend;
18
18
  private agentId?;
19
19
  private clock;
20
+ private groupCommit;
21
+ private groupBuffer;
22
+ private groupSize;
23
+ private groupMs;
24
+ private groupTimer;
20
25
  private manifestVersion;
21
26
  /**
22
27
  * Serialize all state-mutating operations through a promise chain.
@@ -88,5 +93,8 @@ export declare class Store<T = Record<string, unknown>> {
88
93
  private applyOp;
89
94
  private reverseOp;
90
95
  private persistOp;
96
+ /** Flush buffered group commit ops to disk in a single write. */
97
+ flush(): Promise<void>;
98
+ private flushGroupBuffer;
91
99
  private defaultPeriod;
92
100
  }
package/dist/store.js CHANGED
@@ -27,6 +27,12 @@ export class Store {
27
27
  // Multi-writer state
28
28
  agentId;
29
29
  clock = null;
30
+ // Group commit state
31
+ groupCommit = false;
32
+ groupBuffer = [];
33
+ groupSize = 50;
34
+ groupMs = 100;
35
+ groupTimer = null;
30
36
  manifestVersion = null;
31
37
  /**
32
38
  * Serialize all state-mutating operations through a promise chain.
@@ -46,12 +52,25 @@ export class Store {
46
52
  async open(dir, options) {
47
53
  this.dir = dir;
48
54
  if (options) {
49
- const { backend, agentId, ...rest } = options;
55
+ const { backend, agentId, writeMode, groupCommitSize, groupCommitMs, ...rest } = options;
50
56
  this.coreOpts = { ...this.coreOpts, ...rest };
51
57
  if (backend)
52
58
  this.backend = backend;
53
59
  if (agentId)
54
60
  this.agentId = agentId;
61
+ // Group commit: enabled when writeMode is "group" AND not multi-writer
62
+ if (writeMode === "group") {
63
+ if (agentId) {
64
+ console.error("opslog: writeMode 'group' is not compatible with multi-writer (agentId). Using 'immediate'.");
65
+ }
66
+ else {
67
+ this.groupCommit = true;
68
+ if (groupCommitSize)
69
+ this.groupSize = groupCommitSize;
70
+ if (groupCommitMs)
71
+ this.groupMs = groupCommitMs;
72
+ }
73
+ }
55
74
  }
56
75
  this.backend ??= new FsBackend();
57
76
  await this.backend.initialize(dir, { readOnly: this.coreOpts.readOnly });
@@ -183,6 +202,10 @@ export class Store {
183
202
  async close() {
184
203
  this.ensureOpen();
185
204
  this.unwatch();
205
+ // Flush any buffered group commit ops before checkpoint
206
+ if (this.groupCommit && this.groupBuffer.length > 0) {
207
+ await this.serialize(() => this.flushGroupBuffer());
208
+ }
186
209
  if (!this.coreOpts.readOnly &&
187
210
  this.coreOpts.checkpointOnClose &&
188
211
  this.ops.length > 0) {
@@ -483,6 +506,9 @@ export class Store {
483
506
  return true;
484
507
  }
485
508
  async _compact() {
509
+ // Flush group buffer before checkpoint
510
+ if (this.groupCommit)
511
+ await this.flushGroupBuffer();
486
512
  if (this.isMultiWriter()) {
487
513
  await this._compactMultiWriter();
488
514
  return;
@@ -656,12 +682,43 @@ export class Store {
656
682
  }
657
683
  }
658
684
  async persistOp(op) {
659
- await this.backend.appendOps(this.activeOpsPath, [op]);
660
685
  this.ops.push(op);
686
+ if (this.groupCommit) {
687
+ // Buffer the op, flush when buffer is full or timer fires
688
+ this.groupBuffer.push(op);
689
+ if (this.groupBuffer.length >= this.groupSize) {
690
+ await this.flushGroupBuffer();
691
+ }
692
+ else if (!this.groupTimer) {
693
+ this.groupTimer = setTimeout(() => {
694
+ this.serialize(() => this.flushGroupBuffer()).catch(() => { });
695
+ }, this.groupMs);
696
+ }
697
+ }
698
+ else {
699
+ // Immediate: write to disk now
700
+ await this.backend.appendOps(this.activeOpsPath, [op]);
701
+ }
661
702
  if (this.ops.length >= this.coreOpts.checkpointThreshold) {
662
703
  await this._compact();
663
704
  }
664
705
  }
706
+ /** Flush buffered group commit ops to disk in a single write. */
707
+ async flush() {
708
+ if (!this.groupCommit || this.groupBuffer.length === 0)
709
+ return;
710
+ return this.serialize(() => this.flushGroupBuffer());
711
+ }
712
+ async flushGroupBuffer() {
713
+ if (this.groupBuffer.length === 0)
714
+ return;
715
+ if (this.groupTimer) {
716
+ clearTimeout(this.groupTimer);
717
+ this.groupTimer = null;
718
+ }
719
+ await this.backend.appendOps(this.activeOpsPath, this.groupBuffer);
720
+ this.groupBuffer = [];
721
+ }
665
722
  defaultPeriod() {
666
723
  const now = new Date();
667
724
  const q = Math.ceil((now.getMonth() + 1) / 3);
package/dist/types.d.ts CHANGED
@@ -58,6 +58,12 @@ export interface StoreOptions {
58
58
  backend?: StorageBackend;
59
59
  /** Agent ID for multi-writer mode. Enables per-agent WAL streams and LWW conflict resolution. */
60
60
  agentId?: string;
61
+ /** Write mode: "immediate" flushes every op (default, safe for multi-writer). "group" buffers ops and flushes periodically (~50x faster writes). Forced to "immediate" when agentId is set. */
62
+ writeMode?: "immediate" | "group";
63
+ /** Group commit: max ops to buffer before flush (default: 50). Only used when writeMode is "group". */
64
+ groupCommitSize?: number;
65
+ /** Group commit: max milliseconds before flush (default: 100). Only used when writeMode is "group". */
66
+ groupCommitMs?: number;
61
67
  }
62
68
  export interface StoreStats {
63
69
  activeRecords: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backloghq/opslog",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Embedded event-sourced document store. Append-only operation log with immutable snapshots, zero native dependencies.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",