@earendil-works/pi-durable 1.3.0 → 1.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.
Files changed (257) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +287 -82
  3. package/dist/codemode/codemode.d.ts +4 -4
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +21 -16
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.d.ts.map +1 -1
  8. package/dist/codemode/models.js +6 -4
  9. package/dist/codemode/models.js.map +1 -1
  10. package/dist/documents.d.ts +1 -1
  11. package/dist/documents.d.ts.map +1 -1
  12. package/dist/documents.js.map +1 -1
  13. package/dist/env/index.d.ts +12 -10
  14. package/dist/env/index.d.ts.map +1 -1
  15. package/dist/env/index.js.map +1 -1
  16. package/dist/env/node-watch.d.ts +3 -3
  17. package/dist/env/node-watch.d.ts.map +1 -1
  18. package/dist/env/node-watch.js +18 -16
  19. package/dist/env/node-watch.js.map +1 -1
  20. package/dist/env/node.d.ts +2 -2
  21. package/dist/env/node.d.ts.map +1 -1
  22. package/dist/env/node.js +12 -4
  23. package/dist/env/node.js.map +1 -1
  24. package/dist/errors.d.ts +18 -2
  25. package/dist/errors.d.ts.map +1 -1
  26. package/dist/errors.js +26 -3
  27. package/dist/errors.js.map +1 -1
  28. package/dist/harness/agent.d.ts +13 -2
  29. package/dist/harness/agent.d.ts.map +1 -1
  30. package/dist/harness/agent.js +48 -6
  31. package/dist/harness/agent.js.map +1 -1
  32. package/dist/harness/compaction.d.ts +3 -1
  33. package/dist/harness/compaction.d.ts.map +1 -1
  34. package/dist/harness/compaction.js +38 -6
  35. package/dist/harness/compaction.js.map +1 -1
  36. package/dist/harness/context.d.ts +23 -7
  37. package/dist/harness/context.d.ts.map +1 -1
  38. package/dist/harness/context.js +71 -25
  39. package/dist/harness/context.js.map +1 -1
  40. package/dist/harness/define.d.ts +7 -3
  41. package/dist/harness/define.d.ts.map +1 -1
  42. package/dist/harness/define.js +4 -0
  43. package/dist/harness/define.js.map +1 -1
  44. package/dist/harness/generation.d.ts +38 -5
  45. package/dist/harness/generation.d.ts.map +1 -1
  46. package/dist/harness/generation.js +497 -108
  47. package/dist/harness/generation.js.map +1 -1
  48. package/dist/harness/harness.d.ts +7 -2
  49. package/dist/harness/harness.d.ts.map +1 -1
  50. package/dist/harness/harness.js +147 -57
  51. package/dist/harness/harness.js.map +1 -1
  52. package/dist/harness/inbox.d.ts +7 -3
  53. package/dist/harness/inbox.d.ts.map +1 -1
  54. package/dist/harness/inbox.js +7 -4
  55. package/dist/harness/inbox.js.map +1 -1
  56. package/dist/harness/json.d.ts +20 -4
  57. package/dist/harness/json.d.ts.map +1 -1
  58. package/dist/harness/json.js +23 -5
  59. package/dist/harness/json.js.map +1 -1
  60. package/dist/harness/live.d.ts +122 -16
  61. package/dist/harness/live.d.ts.map +1 -1
  62. package/dist/harness/live.js +93 -12
  63. package/dist/harness/live.js.map +1 -1
  64. package/dist/harness/partial-changes.d.ts +54 -0
  65. package/dist/harness/partial-changes.d.ts.map +1 -0
  66. package/dist/harness/partial-changes.js +162 -0
  67. package/dist/harness/partial-changes.js.map +1 -0
  68. package/dist/harness/prompt.d.ts +1 -1
  69. package/dist/harness/prompt.d.ts.map +1 -1
  70. package/dist/harness/prompt.js +1 -1
  71. package/dist/harness/prompt.js.map +1 -1
  72. package/dist/harness/registry-reader.d.ts +11 -0
  73. package/dist/harness/registry-reader.d.ts.map +1 -0
  74. package/dist/harness/registry-reader.js +25 -0
  75. package/dist/harness/registry-reader.js.map +1 -0
  76. package/dist/harness/scheduler.d.ts +86 -14
  77. package/dist/harness/scheduler.d.ts.map +1 -1
  78. package/dist/harness/scheduler.js +1459 -369
  79. package/dist/harness/scheduler.js.map +1 -1
  80. package/dist/harness/submissions.d.ts +23 -11
  81. package/dist/harness/submissions.d.ts.map +1 -1
  82. package/dist/harness/submissions.js +104 -36
  83. package/dist/harness/submissions.js.map +1 -1
  84. package/dist/harness/task-graph.d.ts +28 -8
  85. package/dist/harness/task-graph.d.ts.map +1 -1
  86. package/dist/harness/task-graph.js +85 -36
  87. package/dist/harness/task-graph.js.map +1 -1
  88. package/dist/harness/telemetry.d.ts +307 -0
  89. package/dist/harness/telemetry.d.ts.map +1 -0
  90. package/dist/harness/telemetry.js +136 -0
  91. package/dist/harness/telemetry.js.map +1 -0
  92. package/dist/harness/tool.d.ts +2 -0
  93. package/dist/harness/tool.d.ts.map +1 -1
  94. package/dist/harness/tool.js +223 -61
  95. package/dist/harness/tool.js.map +1 -1
  96. package/dist/harness/transcript.d.ts +72 -0
  97. package/dist/harness/transcript.d.ts.map +1 -0
  98. package/dist/harness/transcript.js +264 -0
  99. package/dist/harness/transcript.js.map +1 -0
  100. package/dist/harness/types.d.ts +302 -48
  101. package/dist/harness/types.d.ts.map +1 -1
  102. package/dist/harness/types.js.map +1 -1
  103. package/dist/harness/util.d.ts +7 -1
  104. package/dist/harness/util.d.ts.map +1 -1
  105. package/dist/harness/util.js +13 -0
  106. package/dist/harness/util.js.map +1 -1
  107. package/dist/harness/view.d.ts +43 -22
  108. package/dist/harness/view.d.ts.map +1 -1
  109. package/dist/harness/view.js +130 -71
  110. package/dist/harness/view.js.map +1 -1
  111. package/dist/index.d.ts +13 -9
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +7 -5
  114. package/dist/index.js.map +1 -1
  115. package/dist/search.d.ts +19 -3
  116. package/dist/search.d.ts.map +1 -1
  117. package/dist/search.js +107 -44
  118. package/dist/search.js.map +1 -1
  119. package/dist/session/cached-storage.d.ts +71 -0
  120. package/dist/session/cached-storage.d.ts.map +1 -0
  121. package/dist/session/cached-storage.js +269 -0
  122. package/dist/session/cached-storage.js.map +1 -0
  123. package/dist/session/conversation-tails.d.ts +33 -0
  124. package/dist/session/conversation-tails.d.ts.map +1 -0
  125. package/dist/session/conversation-tails.js +101 -0
  126. package/dist/session/conversation-tails.js.map +1 -0
  127. package/dist/session/keys.d.ts +68 -0
  128. package/dist/session/keys.d.ts.map +1 -0
  129. package/dist/session/keys.js +161 -0
  130. package/dist/session/keys.js.map +1 -0
  131. package/dist/session/session.d.ts +181 -29
  132. package/dist/session/session.d.ts.map +1 -1
  133. package/dist/session/session.js +1073 -183
  134. package/dist/session/session.js.map +1 -1
  135. package/dist/session/transaction.d.ts +86 -4
  136. package/dist/session/transaction.d.ts.map +1 -1
  137. package/dist/session/transaction.js +250 -38
  138. package/dist/session/transaction.js.map +1 -1
  139. package/dist/session/writer.d.ts +74 -0
  140. package/dist/session/writer.d.ts.map +1 -0
  141. package/dist/session/writer.js +294 -0
  142. package/dist/session/writer.js.map +1 -0
  143. package/dist/storage/indexeddb.d.ts +68 -0
  144. package/dist/storage/indexeddb.d.ts.map +1 -0
  145. package/dist/storage/indexeddb.js +881 -0
  146. package/dist/storage/indexeddb.js.map +1 -0
  147. package/dist/storage/jsonl/storage.d.ts +30 -2
  148. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  149. package/dist/storage/jsonl/storage.js +88 -24
  150. package/dist/storage/jsonl/storage.js.map +1 -1
  151. package/dist/storage/memory.d.ts +7 -2
  152. package/dist/storage/memory.d.ts.map +1 -1
  153. package/dist/storage/memory.js +29 -3
  154. package/dist/storage/memory.js.map +1 -1
  155. package/dist/storage/sqlite/cloudflare.d.ts +2 -51
  156. package/dist/storage/sqlite/cloudflare.d.ts.map +1 -1
  157. package/dist/storage/sqlite/cloudflare.js +2 -133
  158. package/dist/storage/sqlite/cloudflare.js.map +1 -1
  159. package/dist/storage/sqlite/database.d.ts +1 -36
  160. package/dist/storage/sqlite/database.d.ts.map +1 -1
  161. package/dist/storage/sqlite/database.js.map +1 -1
  162. package/dist/storage/sqlite/index.d.ts +1 -1
  163. package/dist/storage/sqlite/index.d.ts.map +1 -1
  164. package/dist/storage/sqlite/index.js.map +1 -1
  165. package/dist/storage/sqlite/migrations.d.ts +2 -5
  166. package/dist/storage/sqlite/migrations.d.ts.map +1 -1
  167. package/dist/storage/sqlite/migrations.js +28 -28
  168. package/dist/storage/sqlite/migrations.js.map +1 -1
  169. package/dist/storage/sqlite/node.d.ts +2 -37
  170. package/dist/storage/sqlite/node.d.ts.map +1 -1
  171. package/dist/storage/sqlite/node.js +3 -174
  172. package/dist/storage/sqlite/node.js.map +1 -1
  173. package/dist/storage/sqlite/schema.d.ts +1 -3
  174. package/dist/storage/sqlite/schema.d.ts.map +1 -1
  175. package/dist/storage/sqlite/schema.js +2 -9
  176. package/dist/storage/sqlite/schema.js.map +1 -1
  177. package/dist/storage/sqlite/storage.d.ts +15 -3
  178. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  179. package/dist/storage/sqlite/storage.js +110 -36
  180. package/dist/storage/sqlite/storage.js.map +1 -1
  181. package/dist/storage/store/commit.d.ts +33 -0
  182. package/dist/storage/store/commit.d.ts.map +1 -0
  183. package/dist/storage/store/commit.js +286 -0
  184. package/dist/storage/store/commit.js.map +1 -0
  185. package/dist/storage/store/documents.d.ts +43 -0
  186. package/dist/storage/store/documents.d.ts.map +1 -0
  187. package/dist/storage/store/documents.js +102 -0
  188. package/dist/storage/store/documents.js.map +1 -0
  189. package/dist/storage/store/index.d.ts +2 -0
  190. package/dist/storage/store/index.d.ts.map +1 -0
  191. package/dist/storage/store/index.js +2 -0
  192. package/dist/storage/store/index.js.map +1 -0
  193. package/dist/storage/store/queries.d.ts +49 -0
  194. package/dist/storage/store/queries.d.ts.map +1 -0
  195. package/dist/storage/store/queries.js +93 -0
  196. package/dist/storage/store/queries.js.map +1 -0
  197. package/dist/storage/store/storage.d.ts +59 -0
  198. package/dist/storage/store/storage.d.ts.map +1 -0
  199. package/dist/storage/store/storage.js +438 -0
  200. package/dist/storage/store/storage.js.map +1 -0
  201. package/dist/storage/store/tables.d.ts +54 -0
  202. package/dist/storage/store/tables.d.ts.map +1 -0
  203. package/dist/storage/store/tables.js +108 -0
  204. package/dist/storage/store/tables.js.map +1 -0
  205. package/dist/testing/commit-writes.d.ts +5 -0
  206. package/dist/testing/commit-writes.d.ts.map +1 -0
  207. package/dist/testing/commit-writes.js +12 -0
  208. package/dist/testing/commit-writes.js.map +1 -0
  209. package/dist/testing/env-conformance.js +7 -7
  210. package/dist/testing/env-conformance.js.map +1 -1
  211. package/dist/testing/index.d.ts +1 -0
  212. package/dist/testing/index.d.ts.map +1 -1
  213. package/dist/testing/index.js +1 -0
  214. package/dist/testing/index.js.map +1 -1
  215. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  216. package/dist/testing/storage-benchmark.js +18 -17
  217. package/dist/testing/storage-benchmark.js.map +1 -1
  218. package/dist/testing/storage-conformance.d.ts.map +1 -1
  219. package/dist/testing/storage-conformance.js +369 -67
  220. package/dist/testing/storage-conformance.js.map +1 -1
  221. package/dist/timers.d.ts +21 -0
  222. package/dist/timers.d.ts.map +1 -0
  223. package/dist/timers.js +72 -0
  224. package/dist/timers.js.map +1 -0
  225. package/dist/tool-search/tool-search.d.ts +6 -12
  226. package/dist/tool-search/tool-search.d.ts.map +1 -1
  227. package/dist/tool-search/tool-search.js +7 -7
  228. package/dist/tool-search/tool-search.js.map +1 -1
  229. package/dist/tools/bash.d.ts +12 -12
  230. package/dist/tools/bash.d.ts.map +1 -1
  231. package/dist/tools/bash.js +16 -15
  232. package/dist/tools/bash.js.map +1 -1
  233. package/dist/tools/edit.d.ts +7 -7
  234. package/dist/tools/edit.d.ts.map +1 -1
  235. package/dist/tools/edit.js +9 -9
  236. package/dist/tools/edit.js.map +1 -1
  237. package/dist/tools/index.d.ts +32 -32
  238. package/dist/tools/read.d.ts +6 -6
  239. package/dist/tools/read.d.ts.map +1 -1
  240. package/dist/tools/read.js +19 -8
  241. package/dist/tools/read.js.map +1 -1
  242. package/dist/tools/write.d.ts +4 -4
  243. package/dist/tools/write.d.ts.map +1 -1
  244. package/dist/tools/write.js +5 -5
  245. package/dist/tools/write.js.map +1 -1
  246. package/dist/types.d.ts +331 -64
  247. package/dist/types.d.ts.map +1 -1
  248. package/dist/types.js.map +1 -1
  249. package/package.json +27 -10
  250. package/dist/harness/events.d.ts +0 -186
  251. package/dist/harness/events.d.ts.map +0 -1
  252. package/dist/harness/events.js +0 -373
  253. package/dist/harness/events.js.map +0 -1
  254. package/dist/session/observation.d.ts +0 -42
  255. package/dist/session/observation.d.ts.map +0 -1
  256. package/dist/session/observation.js +0 -281
  257. package/dist/session/observation.js.map +0 -1
@@ -1,9 +1,10 @@
1
1
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
2
2
  import { idFromNumber } from "../ids.js";
3
3
  import { ROOT_CONVERSATION_ID, } from "../types.js";
4
+ import { commitWrites } from "./commit-writes.js";
4
5
  const context = BACKGROUND_CONTEXT;
5
6
  async function createRoot(storage) {
6
- await storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context);
7
+ await commitWrites(storage, [{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context);
7
8
  return ROOT_CONVERSATION_ID;
8
9
  }
9
10
  function pendingTask(id, conversationId, phase = "ready") {
@@ -38,8 +39,42 @@ function assertionFacade(assertions) {
38
39
  },
39
40
  });
40
41
  }
42
+ /**
43
+ * `storage` with every commit checked against the receipt contract: one `receive` per stored batch, in order, with
44
+ * increasing seqs, all before the call settles.
45
+ */
46
+ function checkReceipts(storage, assertions) {
47
+ const commit = async (batches, receive, commitContext) => {
48
+ const seqs = [];
49
+ let settled = false;
50
+ const recording = (index, seq) => {
51
+ assertions.ok(!settled, "Storage.commit() received a batch after it settled");
52
+ assertions.strictEqual(index, seqs.length);
53
+ if (seqs.length > 0)
54
+ assertions.greaterThan(seq, seqs.at(-1));
55
+ seqs.push(seq);
56
+ receive(index, seq);
57
+ };
58
+ try {
59
+ const result = await storage.commit(batches, recording, commitContext);
60
+ assertions.strictEqual(seqs.length, result.stored);
61
+ return result;
62
+ }
63
+ finally {
64
+ settled = true;
65
+ }
66
+ };
67
+ return new Proxy(storage, {
68
+ get(target, property) {
69
+ if (property === "commit")
70
+ return commit;
71
+ const value = Reflect.get(target, property, target);
72
+ return typeof value === "function" ? value.bind(target) : value;
73
+ },
74
+ });
75
+ }
41
76
  function createCase(options, name, test) {
42
- return { name, run: () => options.withStorage(test) };
77
+ return { name, run: () => options.withStorage((storage) => test(checkReceipts(storage, options.assertions))) };
43
78
  }
44
79
  /** Creates runner-independent cases. `withStorage` must call and await its callback exactly once per case. */
45
80
  export function createStorageConformance(options) {
@@ -49,7 +84,30 @@ export function createStorageConformance(options) {
49
84
  expect(await storage.mintId()).toBe(2);
50
85
  await expect(createRoot(storage)).resolves.toBe(ROOT_CONVERSATION_ID);
51
86
  expect(await storage.conversation(ROOT_CONVERSATION_ID, context)).toEqual({ id: ROOT_CONVERSATION_ID });
52
- await expect(storage.commit([{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context)).rejects.toThrow(`ID ${ROOT_CONVERSATION_ID} already belongs to conversation`);
87
+ await expect(commitWrites(storage, [{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context)).rejects.toThrow(`ID ${ROOT_CONVERSATION_ID} already belongs to conversation`);
88
+ }),
89
+ createCase(options, "receives a durable commit once before resolving, and never a refused one", async (storage) => {
90
+ const rootId = await createRoot(storage);
91
+ const events = [];
92
+ const entryId = await storage.mintId();
93
+ let read;
94
+ let seq;
95
+ const result = await storage.commit([[{ type: "entry", value: entry(entryId, rootId) }]], (_index, received) => {
96
+ seq = received;
97
+ events.push(`receive ${received}`);
98
+ // The batch is durable when it is received: a read started now observes it.
99
+ read = storage.entry(entryId, context);
100
+ }, context);
101
+ events.push(`resolve ${result.stored}`);
102
+ expect(events).toEqual([`receive ${seq}`, "resolve 1"]);
103
+ expect((await read)?.commitSeq).toBe(seq);
104
+ events.length = 0;
105
+ const refused = await storage.commit([[{ type: "conversation", value: { id: rootId } }]], (_index, received) => events.push(`receive ${received}`), context);
106
+ expect(refused.stored).toBe(0);
107
+ expect(String(refused.refused)).toBe(`Error: ID ${rootId} already belongs to conversation`);
108
+ expect(events).toEqual([]);
109
+ const next = await commitWrites(storage, [{ type: "entry", value: entry(await storage.mintId(), rootId) }], context);
110
+ expect(next).toBeGreaterThan(seq);
53
111
  }),
54
112
  createCase(options, "commits mixed table writes atomically and rolls all of them back on failure", async (storage) => {
55
113
  const rootId = await createRoot(storage);
@@ -65,7 +123,7 @@ export function createStorageConformance(options) {
65
123
  status: "placed",
66
124
  entry: entryId,
67
125
  };
68
- const initialSeq = await storage.commit([
126
+ const initialSeq = await commitWrites(storage, [
69
127
  { type: "entry", value: entry(entryId, rootId, "user", { data: { text: "hello" } }) },
70
128
  { type: "task", value: task },
71
129
  { type: "submission", value: input },
@@ -82,7 +140,7 @@ export function createStorageConformance(options) {
82
140
  state: { status: "running", checkpoint: { phase: "effect" } },
83
141
  };
84
142
  const doneInput = { ...input, status: "done", answer: transientEntryId };
85
- await expect(storage.commit([
143
+ await expect(commitWrites(storage, [
86
144
  { type: "task", value: runningTask },
87
145
  { type: "submission", value: doneInput },
88
146
  { type: "entry", value: entry(transientEntryId, rootId, "assistant") },
@@ -91,7 +149,7 @@ export function createStorageConformance(options) {
91
149
  expect(await storage.task(taskId, context)).toEqual(task);
92
150
  expect(await storage.submission(submissionId, context)).toEqual(input);
93
151
  expect(await storage.entry(transientEntryId, context)).toBeUndefined();
94
- const afterRollbackSeq = await storage.commit([{ type: "entry", value: entry(await storage.mintId(), rootId, "after-rollback") }], context);
152
+ const afterRollbackSeq = await commitWrites(storage, [{ type: "entry", value: entry(await storage.mintId(), rootId, "after-rollback") }], context);
95
153
  expect(afterRollbackSeq).toBeGreaterThan(initialSeq);
96
154
  }),
97
155
  createCase(options, "detaches retained writes and every returned record", async (storage) => {
@@ -115,7 +173,7 @@ export function createStorageConformance(options) {
115
173
  reason: "failed",
116
174
  detail,
117
175
  };
118
- await storage.commit([
176
+ await commitWrites(storage, [
119
177
  { type: "entry", value: storedEntry },
120
178
  { type: "task", value: storedTask },
121
179
  { type: "submission", value: storedInput },
@@ -148,7 +206,7 @@ export function createStorageConformance(options) {
148
206
  const rootId = await createRoot(storage);
149
207
  const entryId = await storage.mintId();
150
208
  const data = JSON.parse('{"__proto__":{"polluted":false},"constructor":{"label":"stored"},"toString":"value"}');
151
- await storage.commit([{ type: "entry", value: entry(entryId, rootId, "note", { data }) }], context);
209
+ await commitWrites(storage, [{ type: "entry", value: entry(entryId, rootId, "note", { data }) }], context);
152
210
  Reflect.get(data, "__proto__").polluted = true;
153
211
  Reflect.get(data, "constructor").label = "mutated";
154
212
  const firstRead = (await storage.entry(entryId, context)).entry.data;
@@ -166,7 +224,7 @@ export function createStorageConformance(options) {
166
224
  }),
167
225
  createCase(options, "indexes entries committed out of ID order", async (storage) => {
168
226
  const rootId = await createRoot(storage);
169
- await storage.commit([
227
+ await commitWrites(storage, [
170
228
  { type: "entry", value: entry(idFromNumber(30), rootId) },
171
229
  { type: "entry", value: entry(idFromNumber(10), rootId) },
172
230
  {
@@ -184,7 +242,7 @@ export function createStorageConformance(options) {
184
242
  const oldestId = await storage.mintId();
185
243
  const middleId = await storage.mintId();
186
244
  const newestId = await storage.mintId();
187
- await storage.commit([
245
+ await commitWrites(storage, [
188
246
  { type: "entry", value: entry(oldestId, rootId) },
189
247
  { type: "entry", value: entry(middleId, rootId) },
190
248
  { type: "entry", value: entry(newestId, rootId) },
@@ -192,7 +250,7 @@ export function createStorageConformance(options) {
192
250
  const first = await storage.scanEntries({ conversationId: rootId }, 2, undefined, context);
193
251
  expect(first.items.map(({ id }) => id)).toEqual([newestId, middleId]);
194
252
  const appendedId = await storage.mintId();
195
- await storage.commit([{ type: "entry", value: entry(appendedId, rootId) }], context);
253
+ await commitWrites(storage, [{ type: "entry", value: entry(appendedId, rootId) }], context);
196
254
  const second = await storage.scanEntries({ conversationId: rootId }, 2, first.next, context);
197
255
  expect(second.items.map(({ id }) => id)).toEqual([oldestId]);
198
256
  expect(second.next).toBeUndefined();
@@ -201,7 +259,7 @@ export function createStorageConformance(options) {
201
259
  const rootId = await createRoot(storage);
202
260
  const secondId = await storage.mintId();
203
261
  const thirdId = await storage.mintId();
204
- await storage.commit([
262
+ await commitWrites(storage, [
205
263
  { type: "conversation", value: { id: thirdId } },
206
264
  { type: "conversation", value: { id: secondId } },
207
265
  ], context);
@@ -221,7 +279,7 @@ export function createStorageConformance(options) {
221
279
  const firstId = await storage.mintId();
222
280
  const secondId = await storage.mintId();
223
281
  const thirdId = await storage.mintId();
224
- await storage.commit([
282
+ await commitWrites(storage, [
225
283
  { type: "conversation", value: { id: otherOwnerId } },
226
284
  {
227
285
  type: "conversation",
@@ -250,7 +308,7 @@ export function createStorageConformance(options) {
250
308
  const rootFirst = await storage.mintId();
251
309
  const rootForkPoint = await storage.mintId();
252
310
  const rootExcludedSameCommit = await storage.mintId();
253
- const rootEntriesSeq = await storage.commit([
311
+ const rootEntriesSeq = await commitWrites(storage, [
254
312
  { type: "entry", value: entry(rootFirst, rootId) },
255
313
  {
256
314
  type: "entry",
@@ -259,7 +317,7 @@ export function createStorageConformance(options) {
259
317
  { type: "entry", value: entry(rootExcludedSameCommit, rootId) },
260
318
  ], context);
261
319
  const childId = await storage.mintId();
262
- await storage.commit([
320
+ await commitWrites(storage, [
263
321
  {
264
322
  type: "conversation",
265
323
  value: { id: childId, parent: { conversationId: rootId, at: rootForkPoint } },
@@ -267,14 +325,14 @@ export function createStorageConformance(options) {
267
325
  ], context);
268
326
  const childForkPoint = await storage.mintId();
269
327
  const childExcluded = await storage.mintId();
270
- await storage.commit([
328
+ await commitWrites(storage, [
271
329
  { type: "entry", value: entry(childForkPoint, childId, "note") },
272
330
  { type: "entry", value: entry(childExcluded, childId) },
273
331
  ], context);
274
332
  const rootExcludedLater = await storage.mintId();
275
- await storage.commit([{ type: "entry", value: entry(rootExcludedLater, rootId) }], context);
333
+ await commitWrites(storage, [{ type: "entry", value: entry(rootExcludedLater, rootId) }], context);
276
334
  const grandchildId = await storage.mintId();
277
- await storage.commit([
335
+ await commitWrites(storage, [
278
336
  {
279
337
  type: "conversation",
280
338
  value: { id: grandchildId, parent: { conversationId: childId, at: childForkPoint } },
@@ -282,7 +340,7 @@ export function createStorageConformance(options) {
282
340
  ], context);
283
341
  const grandchildHead = await storage.mintId();
284
342
  const grandchildTail = await storage.mintId();
285
- const grandchildEntriesSeq = await storage.commit([
343
+ const grandchildEntriesSeq = await commitWrites(storage, [
286
344
  {
287
345
  type: "entry",
288
346
  value: entry(grandchildHead, grandchildId, "marker", { head: grandchildHead }),
@@ -290,7 +348,7 @@ export function createStorageConformance(options) {
290
348
  { type: "entry", value: entry(grandchildTail, grandchildId) },
291
349
  ], context);
292
350
  const childExcludedLater = await storage.mintId();
293
- await storage.commit([{ type: "entry", value: entry(childExcludedLater, childId) }], context);
351
+ await commitWrites(storage, [{ type: "entry", value: entry(childExcludedLater, childId) }], context);
294
352
  const first = await storage.scanEntries({ conversationId: grandchildId }, 2, undefined, context);
295
353
  expect(first.items.map(({ id }) => id)).toEqual([grandchildTail, grandchildHead]);
296
354
  const second = await storage.scanEntries({ conversationId: grandchildId }, 2, first.next, context);
@@ -359,7 +417,7 @@ export function createStorageConformance(options) {
359
417
  const conversationId = await storage.mintId();
360
418
  const taskId = await storage.mintId();
361
419
  const submissionId = await storage.mintId();
362
- await storage.commit([
420
+ await commitWrites(storage, [
363
421
  { type: "conversation", value: { id: conversationId } },
364
422
  { type: "task", value: pendingTask(taskId, rootId) },
365
423
  {
@@ -405,7 +463,7 @@ export function createStorageConformance(options) {
405
463
  const first = { ...pendingTask(firstId, rootId), memos: { winner: "first" } };
406
464
  const second = { ...pendingTask(secondId, rootId), background: true };
407
465
  const third = { ...pendingTask(thirdId, rootId), abortRequested: true };
408
- await storage.commit([
466
+ await commitWrites(storage, [
409
467
  { type: "task", value: first },
410
468
  { type: "task", value: second },
411
469
  { type: "task", value: third },
@@ -415,7 +473,7 @@ export function createStorageConformance(options) {
415
473
  state: { status: "running", checkpoint: { phase: "effect", attempt: 1 } },
416
474
  abortRequested: true,
417
475
  };
418
- await storage.commit([{ type: "task", value: running }], context);
476
+ await commitWrites(storage, [{ type: "task", value: running }], context);
419
477
  expect(await storage.task(firstId, context)).toEqual(running);
420
478
  const terminal = {
421
479
  id: firstId,
@@ -427,7 +485,7 @@ export function createStorageConformance(options) {
427
485
  background: false,
428
486
  abortRequested: true,
429
487
  };
430
- await storage.commit([{ type: "task", value: terminal }], context);
488
+ await commitWrites(storage, [{ type: "task", value: terminal }], context);
431
489
  expect(await storage.task(firstId, context)).toEqual(terminal);
432
490
  const pendingPage = await storage.scanTasks({ status: "pending" }, 1, undefined, context);
433
491
  expect(pendingPage.items.map(({ id }) => id)).toEqual([secondId]);
@@ -455,7 +513,7 @@ export function createStorageConformance(options) {
455
513
  state: { status: "completing", outcome: { status: "failed", error: { message: "held" } } },
456
514
  };
457
515
  const writes = [owner, waiting, completing].map((value) => ({ type: "task", value }));
458
- await storage.commit(writes, context);
516
+ await commitWrites(storage, writes, context);
459
517
  expect(await storage.task(waitingId, context)).toEqual(waiting);
460
518
  expect(await storage.task(completingId, context)).toEqual(completing);
461
519
  const scan = async (status) => (await storage.scanTasks({ status }, 10, undefined, context)).items;
@@ -466,14 +524,31 @@ export function createStorageConformance(options) {
466
524
  ...completing,
467
525
  state: { status: "terminal", outcome: completing.state.outcome },
468
526
  };
469
- await storage.commit([{ type: "task", value: terminal }], context);
527
+ await commitWrites(storage, [{ type: "task", value: terminal }], context);
470
528
  expect(await scan("completing")).toEqual([]);
471
529
  expect(await scan("terminal")).toEqual([terminal]);
472
530
  }),
531
+ createCase(options, "stores and scans sleeping tasks by status", async (storage) => {
532
+ const rootId = await createRoot(storage);
533
+ const sleepingId = await storage.mintId();
534
+ const sleeping = {
535
+ ...pendingTask(sleepingId, rootId),
536
+ state: { status: "sleeping", checkpoint: { phase: "next" }, until: 61_000 },
537
+ memos: { kept: true },
538
+ };
539
+ await commitWrites(storage, [{ type: "task", value: sleeping }], context);
540
+ expect(await storage.task(sleepingId, context)).toEqual(sleeping);
541
+ const scan = async (status) => (await storage.scanTasks({ status }, 10, undefined, context)).items;
542
+ expect(await scan("sleeping")).toEqual([sleeping]);
543
+ const running = { ...sleeping, state: { status: "running", checkpoint: { phase: "next" } } };
544
+ await commitWrites(storage, [{ type: "task", value: running }], context);
545
+ expect(await scan("sleeping")).toEqual([]);
546
+ expect(await scan("running")).toEqual([running]);
547
+ }),
473
548
  createCase(options, "indexes request IDs per conversation and replaces complete submission records", async (storage) => {
474
549
  const rootId = await createRoot(storage);
475
550
  const secondConversationId = await storage.mintId();
476
- await storage.commit([{ type: "conversation", value: { id: secondConversationId } }], context);
551
+ await commitWrites(storage, [{ type: "conversation", value: { id: secondConversationId } }], context);
477
552
  const firstId = await storage.mintId();
478
553
  const secondId = await storage.mintId();
479
554
  const otherConversationId = await storage.mintId();
@@ -498,7 +573,7 @@ export function createStorageConformance(options) {
498
573
  type: "input",
499
574
  status: "queued",
500
575
  };
501
- await storage.commit([
576
+ await commitWrites(storage, [
502
577
  { type: "submission", value: first },
503
578
  { type: "submission", value: second },
504
579
  { type: "submission", value: otherConversation },
@@ -510,7 +585,7 @@ export function createStorageConformance(options) {
510
585
  status: "placed",
511
586
  entry: await storage.mintId(),
512
587
  };
513
- await storage.commit([{ type: "submission", value: placedSecond }], context);
588
+ await commitWrites(storage, [{ type: "submission", value: placedSecond }], context);
514
589
  expect(await storage.submission(secondId, context)).toEqual(placedSecond);
515
590
  expect(await storage.submissionByRequest(rootId, "other", context)).toEqual(placedSecond);
516
591
  const ids = async (query) => {
@@ -554,7 +629,7 @@ export function createStorageConformance(options) {
554
629
  type: "write",
555
630
  status: "queued",
556
631
  };
557
- await storage.commit([
632
+ await commitWrites(storage, [
558
633
  { type: "submission", value: queuedDone },
559
634
  { type: "submission", value: queuedFailed },
560
635
  ], context);
@@ -569,7 +644,7 @@ export function createStorageConformance(options) {
569
644
  reason: "closed",
570
645
  detail: { retryable: false },
571
646
  };
572
- await storage.commit([
647
+ await commitWrites(storage, [
573
648
  { type: "submission", value: done },
574
649
  { type: "submission", value: unanswered },
575
650
  ], context);
@@ -578,6 +653,44 @@ export function createStorageConformance(options) {
578
653
  expect(await storage.submission(failedId, context)).toEqual(unanswered);
579
654
  expect(await storage.submissionByRequest(rootId, "passive-failed", context)).toEqual(unanswered);
580
655
  }),
656
+ createCase(options, "stores continue submissions, which have no entry and are never queued", async (storage) => {
657
+ const rootId = await createRoot(storage);
658
+ const doneId = await storage.mintId();
659
+ const failedId = await storage.mintId();
660
+ const placedDone = {
661
+ id: doneId,
662
+ conversationId: rootId,
663
+ requestId: "continue-done",
664
+ type: "continue",
665
+ status: "placed",
666
+ };
667
+ const placedFailed = {
668
+ id: failedId,
669
+ conversationId: rootId,
670
+ requestId: "continue-failed",
671
+ type: "continue",
672
+ status: "placed",
673
+ };
674
+ await commitWrites(storage, [
675
+ { type: "submission", value: placedDone },
676
+ { type: "submission", value: placedFailed },
677
+ ], context);
678
+ expect(await storage.submission(doneId, context)).toEqual(placedDone);
679
+ expect((await storage.scanSubmissions({ status: "placed" }, 10, undefined, context)).items).toEqual([
680
+ placedDone,
681
+ placedFailed,
682
+ ]);
683
+ const done = { ...placedDone, status: "done", answer: await storage.mintId() };
684
+ const unanswered = { ...placedFailed, status: "unanswered", reason: "nothing_to_answer" };
685
+ await commitWrites(storage, [
686
+ { type: "submission", value: done },
687
+ { type: "submission", value: unanswered },
688
+ ], context);
689
+ expect(await storage.submission(doneId, context)).toEqual(done);
690
+ expect(await storage.submissionByRequest(rootId, "continue-done", context)).toEqual(done);
691
+ expect(await storage.submission(failedId, context)).toEqual(unanswered);
692
+ expect(await storage.submissionByRequest(rootId, "continue-failed", context)).toEqual(unanswered);
693
+ }),
581
694
  createCase(options, "reconstructs rewindable documents and preserves half-open incarnations", async (storage) => {
582
695
  const rootId = await createRoot(storage);
583
696
  const firstId = await storage.mintId();
@@ -589,7 +702,7 @@ export function createStorageConformance(options) {
589
702
  fork: "asOf",
590
703
  };
591
704
  const initial = { items: ["a"], nested: { count: 1 } };
592
- const createdAt = await storage.commit([
705
+ const createdAt = await commitWrites(storage, [
593
706
  {
594
707
  type: "document.create",
595
708
  record: firstRecord,
@@ -601,7 +714,7 @@ export function createStorageConformance(options) {
601
714
  ["p", ["items"], 1, 0, appended],
602
715
  ["s", ["nested", "count"], 2],
603
716
  ];
604
- const changedAt = await storage.commit([{ type: "document.change", id: firstId, content: { kind: "delta", version: 1, ops } }], context);
717
+ const changedAt = await commitWrites(storage, [{ type: "document.change", id: firstId, content: { kind: "delta", version: 1, ops } }], context);
605
718
  initial.items.push("caller mutation");
606
719
  appended.push("caller mutation");
607
720
  expect(await storage.document(firstId, createdAt, context)).toMatchObject({
@@ -617,14 +730,14 @@ export function createStorageConformance(options) {
617
730
  items: ["a", "b"],
618
731
  nested: { count: 2 },
619
732
  });
620
- const checkpointAt = await storage.commit([
733
+ const checkpointAt = await commitWrites(storage, [
621
734
  {
622
735
  type: "document.change",
623
736
  id: firstId,
624
737
  content: { kind: "base", version: 2, value: { items: ["checkpoint"], nested: { count: 3 } } },
625
738
  },
626
739
  ], context);
627
- const replacedAt = await storage.commit([
740
+ const replacedAt = await commitWrites(storage, [
628
741
  {
629
742
  type: "document.change",
630
743
  id: firstId,
@@ -650,7 +763,7 @@ export function createStorageConformance(options) {
650
763
  });
651
764
  expect((await storage.document(firstId, "current", context))?.deltasSinceBase).toBe(1);
652
765
  const secondId = await storage.mintId();
653
- const retiredAt = await storage.commit([
766
+ const retiredAt = await commitWrites(storage, [
654
767
  {
655
768
  type: "document.create",
656
769
  record: { ...firstRecord, id: secondId },
@@ -688,14 +801,14 @@ export function createStorageConformance(options) {
688
801
  revision: 0,
689
802
  rows: Array.from({ length: 512 }, (_, value) => ({ value, stable: `row-${value}` })),
690
803
  };
691
- const createdAt = await storage.commit([{ type: "document.create", record, content: { kind: "base", version: 1, value: initial } }], context);
804
+ const createdAt = await commitWrites(storage, [{ type: "document.create", record, content: { kind: "base", version: 1, value: initial } }], context);
692
805
  const beforeReplacement = structuredClone(initial);
693
806
  let beforeReplacementAt = createdAt;
694
807
  for (let revision = 1; revision <= 24; revision++) {
695
808
  const index = (revision * 17) % beforeReplacement.rows.length;
696
809
  beforeReplacement.rows[index].value = -revision;
697
810
  beforeReplacement.revision = revision;
698
- beforeReplacementAt = await storage.commit([
811
+ beforeReplacementAt = await commitWrites(storage, [
699
812
  {
700
813
  type: "document.change",
701
814
  id,
@@ -715,7 +828,7 @@ export function createStorageConformance(options) {
715
828
  rows: Array.from({ length: 512 }, (_, value) => ({ value: 10_000 + value, stable: `new-${value}` })),
716
829
  };
717
830
  const replacementSnapshot = structuredClone(replacement);
718
- const replacementAt = await storage.commit([
831
+ const replacementAt = await commitWrites(storage, [
719
832
  {
720
833
  type: "document.change",
721
834
  id,
@@ -728,7 +841,7 @@ export function createStorageConformance(options) {
728
841
  const index = (revision * 19) % current.rows.length;
729
842
  current.rows[index].value = -revision;
730
843
  current.revision = revision;
731
- await storage.commit([
844
+ await commitWrites(storage, [
732
845
  {
733
846
  type: "document.change",
734
847
  id,
@@ -755,7 +868,7 @@ export function createStorageConformance(options) {
755
868
  const rootId = await createRoot(storage);
756
869
  const childId = await storage.mintId();
757
870
  const secondChildId = await storage.mintId();
758
- await storage.commit([
871
+ await commitWrites(storage, [
759
872
  { type: "conversation", value: { id: childId } },
760
873
  { type: "conversation", value: { id: secondChildId } },
761
874
  ], context);
@@ -767,14 +880,14 @@ export function createStorageConformance(options) {
767
880
  history: "rewindable",
768
881
  fork: "asOf",
769
882
  };
770
- const createdAt = await storage.commit([
883
+ const createdAt = await commitWrites(storage, [
771
884
  {
772
885
  type: "document.create",
773
886
  record: sourceRecord,
774
887
  content: { kind: "base", version: 2, value: { count: 1, rows: [{ value: "base" }] } },
775
888
  },
776
889
  ], context);
777
- await storage.commit([
890
+ await commitWrites(storage, [
778
891
  {
779
892
  type: "document.change",
780
893
  id: sourceId,
@@ -798,7 +911,7 @@ export function createStorageConformance(options) {
798
911
  history: "rewindable",
799
912
  fork: "asOf",
800
913
  });
801
- await storage.commit([
914
+ await commitWrites(storage, [
802
915
  {
803
916
  type: "document.copy",
804
917
  record: childRecord(historicalCopyId, childId),
@@ -825,7 +938,7 @@ export function createStorageConformance(options) {
825
938
  value: { count: 2, rows: [{ value: "base" }, { value: "current" }] },
826
939
  });
827
940
  expect(await storage.document(retiredCopyId, "current", context)).toBeUndefined();
828
- await storage.commit([
941
+ await commitWrites(storage, [
829
942
  {
830
943
  type: "document.change",
831
944
  id: sourceId,
@@ -846,14 +959,14 @@ export function createStorageConformance(options) {
846
959
  history: "latest",
847
960
  fork: "current",
848
961
  };
849
- await storage.commit([
962
+ await commitWrites(storage, [
850
963
  {
851
964
  type: "document.create",
852
965
  record: latestSource,
853
966
  content: { kind: "base", version: 4, value: { retained: "copy" } },
854
967
  },
855
968
  ], context);
856
- await storage.commit([
969
+ await commitWrites(storage, [
857
970
  {
858
971
  type: "document.copy",
859
972
  record: {
@@ -864,7 +977,7 @@ export function createStorageConformance(options) {
864
977
  source: { id: latestSourceId, at: "current" },
865
978
  },
866
979
  ], context);
867
- await storage.commit([
980
+ await commitWrites(storage, [
868
981
  {
869
982
  type: "document.change",
870
983
  id: latestSourceId,
@@ -879,7 +992,7 @@ export function createStorageConformance(options) {
879
992
  const conflictId = await storage.mintId();
880
993
  let conflictError;
881
994
  try {
882
- await storage.commit([
995
+ await commitWrites(storage, [
883
996
  {
884
997
  type: "document.copy",
885
998
  record: childRecord(conflictId, childId),
@@ -901,7 +1014,7 @@ export function createStorageConformance(options) {
901
1014
  const mismatchId = await storage.mintId();
902
1015
  let mismatchError;
903
1016
  try {
904
- await storage.commit([
1017
+ await commitWrites(storage, [
905
1018
  {
906
1019
  type: "document.copy",
907
1020
  record: { ...childRecord(mismatchId, childId), kind: "copy.mismatch" },
@@ -923,14 +1036,14 @@ export function createStorageConformance(options) {
923
1036
  kind: "session.settings",
924
1037
  scope: { kind: "session" },
925
1038
  };
926
- await storage.commit([{ type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } }], context);
927
- await storage.commit([{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 2]] } }], context);
928
- const migratedAt = await storage.commit([{ type: "document.change", id, content: { kind: "base", version: 2, value: { count: 3 } } }], context);
1039
+ await commitWrites(storage, [{ type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } }], context);
1040
+ await commitWrites(storage, [{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 2]] } }], context);
1041
+ const migratedAt = await commitWrites(storage, [{ type: "document.change", id, content: { kind: "base", version: 2, value: { count: 3 } } }], context);
929
1042
  expect(await storage.document(id, "current", context)).toMatchObject({ version: 2, value: { count: 3 } });
930
1043
  await expect(storage.document(id, migratedAt, context)).rejects.toThrow("does not retain historical content");
931
- await expect(storage.commit([{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 4]] } }], context)).rejects.toThrow("version transition requires a base");
1044
+ await expect(commitWrites(storage, [{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 4]] } }], context)).rejects.toThrow("version transition requires a base");
932
1045
  expect((await storage.document(id, "current", context))?.value).toEqual({ count: 3 });
933
- await storage.commit([{ type: "document.retire", id }], context);
1046
+ await commitWrites(storage, [{ type: "document.retire", id }], context);
934
1047
  expect(await storage.document(id, "current", context)).toBeUndefined();
935
1048
  }),
936
1049
  createCase(options, "indexes logical addresses and exact-scope scans independently", async (storage) => {
@@ -942,7 +1055,7 @@ export function createStorageConformance(options) {
942
1055
  const taskSingletonId = await storage.mintId();
943
1056
  const taskFamilyId = await storage.mintId();
944
1057
  const taskOtherKindId = await storage.mintId();
945
- const createdAt = await storage.commit([
1058
+ const createdAt = await commitWrites(storage, [
946
1059
  { type: "task", value: pendingTask(taskId, rootId) },
947
1060
  {
948
1061
  type: "document.create",
@@ -1026,8 +1139,8 @@ export function createStorageConformance(options) {
1026
1139
  kind: "singleton",
1027
1140
  scope: { kind: "session" },
1028
1141
  };
1029
- await storage.commit([{ type: "document.create", record, content: { kind: "base", version: 1, value: { value: 1 } } }], context);
1030
- await expect(storage.commit([
1142
+ await commitWrites(storage, [{ type: "document.create", record, content: { kind: "base", version: 1, value: { value: 1 } } }], context);
1143
+ await expect(commitWrites(storage, [
1031
1144
  {
1032
1145
  type: "document.create",
1033
1146
  record: { ...record, id: secondId },
@@ -1038,7 +1151,7 @@ export function createStorageConformance(options) {
1038
1151
  expect((await storage.document(firstId, "current", context))?.value).toEqual({ value: 1 });
1039
1152
  expect(await storage.document(secondId, "current", context)).toBeUndefined();
1040
1153
  const emptyId = await storage.mintId();
1041
- const emptyAt = await storage.commit([
1154
+ const emptyAt = await commitWrites(storage, [
1042
1155
  {
1043
1156
  type: "document.create",
1044
1157
  record: {
@@ -1061,6 +1174,25 @@ export function createStorageConformance(options) {
1061
1174
  key: "empty",
1062
1175
  }, emptyAt, context)).toBeUndefined();
1063
1176
  }),
1177
+ createCase(options, "refuses a creation and a change of one document in one commit, in either order", async (storage) => {
1178
+ await createRoot(storage);
1179
+ for (const order of ["create first", "change first"]) {
1180
+ const id = await storage.mintId();
1181
+ const create = {
1182
+ type: "document.create",
1183
+ record: { id, kind: "twice", key: order, scope: { kind: "session" } },
1184
+ content: { kind: "base", version: 1, value: { x: 1 } },
1185
+ };
1186
+ const change = {
1187
+ type: "document.change",
1188
+ id,
1189
+ content: { kind: "delta", version: 1, ops: [["s", ["x"], 2]] },
1190
+ };
1191
+ const writes = order === "create first" ? [create, change] : [change, create];
1192
+ await expect(commitWrites(storage, writes, context)).rejects.toThrow("more than one content command");
1193
+ expect(await storage.document(id, "current", context)).toBeUndefined();
1194
+ }
1195
+ }),
1064
1196
  createCase(options, "rolls back record tables and secondary indexes when a document command fails", async (storage) => {
1065
1197
  const rootId = await createRoot(storage);
1066
1198
  const taskId = await storage.mintId();
@@ -1079,14 +1211,14 @@ export function createStorageConformance(options) {
1079
1211
  kind: "atomic",
1080
1212
  scope: { kind: "session" },
1081
1213
  };
1082
- const baselineSeq = await storage.commit([
1214
+ const baselineSeq = await commitWrites(storage, [
1083
1215
  { type: "task", value: task },
1084
1216
  { type: "submission", value: submission },
1085
1217
  { type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } },
1086
1218
  ], context);
1087
1219
  const entryId = await storage.mintId();
1088
1220
  const conflictingDocumentId = await storage.mintId();
1089
- await expect(storage.commit([
1221
+ await expect(commitWrites(storage, [
1090
1222
  {
1091
1223
  type: "task",
1092
1224
  value: { ...task, state: { status: "running", checkpoint: { phase: "effect" } } },
@@ -1108,7 +1240,7 @@ export function createStorageConformance(options) {
1108
1240
  expect(await storage.entry(entryId, context)).toBeUndefined();
1109
1241
  expect(await storage.document(conflictingDocumentId, "current", context)).toBeUndefined();
1110
1242
  expect((await storage.findDocument({ kind: record.kind, scope: record.scope }, "current", context))?.id).toBe(documentId);
1111
- const afterRollbackSeq = await storage.commit([
1243
+ const afterRollbackSeq = await commitWrites(storage, [
1112
1244
  {
1113
1245
  type: "document.change",
1114
1246
  id: documentId,
@@ -1129,7 +1261,7 @@ export function createStorageConformance(options) {
1129
1261
  const secondKindDocumentId = await storage.mintId();
1130
1262
  const firstKeyDocumentId = await storage.mintId();
1131
1263
  const secondKeyDocumentId = await storage.mintId();
1132
- await storage.commit([
1264
+ await commitWrites(storage, [
1133
1265
  { type: "task", value: { ...pendingTask(firstTaskId, rootId), kind: first } },
1134
1266
  { type: "task", value: { ...pendingTask(secondTaskId, rootId), kind: second } },
1135
1267
  {
@@ -1194,18 +1326,188 @@ export function createStorageConformance(options) {
1194
1326
  createCase(options, "keeps one global record ID namespace and rejects exhausted ID minting", async (storage) => {
1195
1327
  const rootId = await createRoot(storage);
1196
1328
  const explicitEntryId = idFromNumber(100);
1197
- await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], context);
1329
+ await commitWrites(storage, [{ type: "entry", value: entry(explicitEntryId, rootId) }], context);
1198
1330
  expect(await storage.mintId()).toBe(101);
1199
- await expect(storage.commit([{ type: "task", value: pendingTask(idFromNumber(explicitEntryId), rootId) }], context)).rejects.toThrow(`ID ${explicitEntryId} already belongs to entry`);
1200
- await storage.commit([{ type: "entry", value: entry(idFromNumber(Number.MAX_SAFE_INTEGER), rootId, "last-id") }], context);
1331
+ await expect(commitWrites(storage, [{ type: "task", value: pendingTask(idFromNumber(explicitEntryId), rootId) }], context)).rejects.toThrow(`ID ${explicitEntryId} already belongs to entry`);
1332
+ await commitWrites(storage, [{ type: "entry", value: entry(idFromNumber(Number.MAX_SAFE_INTEGER), rootId, "last-id") }], context);
1201
1333
  await expect(storage.mintId()).rejects.toThrow("ID space is exhausted");
1202
1334
  await expect(storage.mintId()).rejects.toThrow("ID space is exhausted");
1203
1335
  }),
1336
+ createCase(options, "commits batches in order, each with its seq, checked against the earlier ones", async (storage) => {
1337
+ const rootId = await createRoot(storage);
1338
+ const firstId = await storage.mintId();
1339
+ const lastId = await storage.mintId();
1340
+ const documentId = await storage.mintId();
1341
+ const receipts = [];
1342
+ let read;
1343
+ const result = await storage.commit([
1344
+ [
1345
+ { type: "entry", value: entry(firstId, rootId) },
1346
+ {
1347
+ type: "document.create",
1348
+ record: {
1349
+ id: documentId,
1350
+ kind: "batch.doc",
1351
+ scope: { kind: "conversation", conversationId: rootId },
1352
+ history: "latest",
1353
+ fork: "initial",
1354
+ },
1355
+ content: { kind: "base", version: 1, value: { count: 1 } },
1356
+ },
1357
+ ],
1358
+ // Changes the document the batch before created.
1359
+ [
1360
+ {
1361
+ type: "document.change",
1362
+ id: documentId,
1363
+ content: { kind: "delta", version: 1, ops: [["s", ["count"], 2]] },
1364
+ },
1365
+ ],
1366
+ [{ type: "entry", value: entry(lastId, rootId) }],
1367
+ ], (index, seq) => {
1368
+ receipts.push([index, seq]);
1369
+ // Receipts follow the last durable batch: a read started at the first observes it.
1370
+ read ??= storage.entry(lastId, context);
1371
+ }, context);
1372
+ expect(result).toEqual({ stored: 3 });
1373
+ expect(receipts.map(([index]) => index)).toEqual([0, 1, 2]);
1374
+ const [[, first], [, second], [, last]] = receipts;
1375
+ expect(second).toBeGreaterThan(first);
1376
+ expect(last).toBeGreaterThan(second);
1377
+ expect((await read)?.commitSeq).toBe(last);
1378
+ expect((await storage.entry(firstId, context))?.commitSeq).toBe(first);
1379
+ expect((await storage.document(documentId, "current", context))?.value).toEqual({ count: 2 });
1380
+ const next = await commitWrites(storage, [{ type: "entry", value: entry(await storage.mintId(), rootId) }], context);
1381
+ expect(next).toBeGreaterThan(last);
1382
+ }),
1383
+ createCase(options, "stores the batches before the first one refused, and none after it", async (storage) => {
1384
+ const rootId = await createRoot(storage);
1385
+ const firstId = await storage.mintId();
1386
+ const afterId = await storage.mintId();
1387
+ const receipts = [];
1388
+ const result = await storage.commit([
1389
+ [{ type: "entry", value: entry(firstId, rootId) }],
1390
+ // Refused against the state the first batch left: the ID is taken now.
1391
+ [
1392
+ { type: "task", value: pendingTask(await storage.mintId(), rootId) },
1393
+ { type: "entry", value: entry(firstId, rootId) },
1394
+ ],
1395
+ [{ type: "entry", value: entry(afterId, rootId) }],
1396
+ ], (index) => receipts.push(index), context);
1397
+ expect(result.stored).toBe(1);
1398
+ expect(String(result.refused)).toBe(`Error: ID ${firstId} already belongs to entry`);
1399
+ expect(receipts).toEqual([0]);
1400
+ expect(await storage.entry(firstId, context)).toBeDefined();
1401
+ expect(await storage.entry(afterId, context)).toBeUndefined();
1402
+ expect((await storage.scanTasks({}, 10, undefined, context)).items.map((task) => task.id)).toEqual([]);
1403
+ // A refused first batch stores nothing; the storage goes on.
1404
+ const refused = await storage.commit([[{ type: "conversation", value: { id: rootId } }]], (index) => receipts.push(index), context);
1405
+ expect(refused.stored).toBe(0);
1406
+ expect(String(refused.refused)).toBe(`Error: ID ${rootId} already belongs to conversation`);
1407
+ expect(receipts).toEqual([0]);
1408
+ await commitWrites(storage, [{ type: "entry", value: entry(afterId, rootId) }], context);
1409
+ expect(await storage.entry(afterId, context)).toBeDefined();
1410
+ }),
1411
+ createCase(options, "refuses a later batch its own malformed actions fail, keeping the batches before it", async (storage) => {
1412
+ const rootId = await createRoot(storage);
1413
+ const documentId = await storage.mintId();
1414
+ await commitWrites(storage, [
1415
+ {
1416
+ type: "document.create",
1417
+ record: { id: documentId, kind: "staged", scope: { kind: "session" } },
1418
+ content: { kind: "base", version: 1, value: { n: 1 } },
1419
+ },
1420
+ ], context);
1421
+ const firstId = await storage.mintId();
1422
+ const secondId = await storage.mintId();
1423
+ const afterId = await storage.mintId();
1424
+ const receipts = [];
1425
+ const result = await storage.commit([
1426
+ [{ type: "entry", value: entry(firstId, rootId) }],
1427
+ // Malformed in itself, whatever is stored: one document retired twice.
1428
+ [
1429
+ { type: "entry", value: entry(secondId, rootId) },
1430
+ { type: "document.retire", id: documentId },
1431
+ { type: "document.retire", id: documentId },
1432
+ ],
1433
+ [{ type: "entry", value: entry(afterId, rootId) }],
1434
+ ], (index) => receipts.push(index), context);
1435
+ expect(result.stored).toBe(1);
1436
+ expect(result.refused).toBeDefined();
1437
+ expect(receipts).toEqual([0]);
1438
+ expect(await storage.entry(firstId, context)).toBeDefined();
1439
+ expect(await storage.entry(secondId, context)).toBeUndefined();
1440
+ expect(await storage.entry(afterId, context)).toBeUndefined();
1441
+ expect((await storage.document(documentId, "current", context))?.value).toEqual({ n: 1 });
1442
+ }),
1443
+ createCase(options, "resolves a batched copy against the state the earlier batches left", async (storage) => {
1444
+ const rootId = await createRoot(storage);
1445
+ const childId = await storage.mintId();
1446
+ const sourceId = await storage.mintId();
1447
+ const copyId = await storage.mintId();
1448
+ const record = (id, conversationId) => ({
1449
+ id,
1450
+ kind: "batch.copy",
1451
+ scope: { kind: "conversation", conversationId },
1452
+ history: "rewindable",
1453
+ fork: "asOf",
1454
+ });
1455
+ const seqs = [];
1456
+ const result = await storage.commit([
1457
+ [
1458
+ {
1459
+ type: "document.create",
1460
+ record: record(sourceId, rootId),
1461
+ content: { kind: "base", version: 1, value: { count: 1 } },
1462
+ },
1463
+ ],
1464
+ [
1465
+ {
1466
+ type: "document.change",
1467
+ id: sourceId,
1468
+ content: { kind: "delta", version: 1, ops: [["s", ["count"], 2]] },
1469
+ },
1470
+ ],
1471
+ [
1472
+ { type: "conversation", value: { id: childId } },
1473
+ {
1474
+ type: "document.copy",
1475
+ record: record(copyId, childId),
1476
+ source: { id: sourceId, at: "current" },
1477
+ },
1478
+ ],
1479
+ ], (_index, seq) => seqs.push(seq), context);
1480
+ expect(result).toEqual({ stored: 3 });
1481
+ expect((await storage.document(copyId, "current", context))?.value).toEqual({ count: 2 });
1482
+ expect((await storage.document(sourceId, seqs[0], context))?.value).toEqual({ count: 1 });
1483
+ // A copy whose source the same batch changes is refused before the batch writes anything.
1484
+ const laterId = await storage.mintId();
1485
+ const laterCopyId = await storage.mintId();
1486
+ const refused = await storage.commit([
1487
+ [
1488
+ { type: "conversation", value: { id: laterId } },
1489
+ {
1490
+ type: "document.copy",
1491
+ record: record(laterCopyId, laterId),
1492
+ source: { id: sourceId, at: "current" },
1493
+ },
1494
+ {
1495
+ type: "document.change",
1496
+ id: sourceId,
1497
+ content: { kind: "delta", version: 1, ops: [["s", ["count"], 3]] },
1498
+ },
1499
+ ],
1500
+ ], () => { }, context);
1501
+ expect(refused.stored).toBe(0);
1502
+ expect(refused.refused).toBeDefined();
1503
+ expect(await storage.conversation(laterId, context)).toBeUndefined();
1504
+ expect((await storage.document(sourceId, "current", context))?.value).toEqual({ count: 2 });
1505
+ }),
1204
1506
  createCase(options, "rejects every operation after close", async (storage) => {
1205
1507
  await createRoot(storage);
1206
1508
  await storage.close(context);
1207
1509
  await expect(storage.conversation(ROOT_CONVERSATION_ID, context)).rejects.toThrow("closed");
1208
- await expect(storage.commit([], context)).rejects.toThrow("closed");
1510
+ await expect(commitWrites(storage, [{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context)).rejects.toThrow("closed");
1209
1511
  await expect(storage.mintId()).rejects.toThrow("closed");
1210
1512
  }),
1211
1513
  ];