@earendil-works/pi-durable 1.4.0 → 1.5.1

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 (270) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +195 -85
  3. package/dist/codemode/codemode.d.ts +6 -5
  4. package/dist/codemode/codemode.d.ts.map +1 -1
  5. package/dist/codemode/codemode.js +10 -49
  6. package/dist/codemode/codemode.js.map +1 -1
  7. package/dist/codemode/models.d.ts +2 -1
  8. package/dist/codemode/models.d.ts.map +1 -1
  9. package/dist/codemode/models.js +4 -4
  10. package/dist/codemode/models.js.map +1 -1
  11. package/dist/documents.d.ts +1 -1
  12. package/dist/documents.d.ts.map +1 -1
  13. package/dist/documents.js +1 -1
  14. package/dist/documents.js.map +1 -1
  15. package/dist/entries.d.ts +1 -1
  16. package/dist/entries.d.ts.map +1 -1
  17. package/dist/entries.js.map +1 -1
  18. package/dist/env/index.d.ts +13 -11
  19. package/dist/env/index.d.ts.map +1 -1
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/node-watch.d.ts +4 -4
  22. package/dist/env/node-watch.d.ts.map +1 -1
  23. package/dist/env/node-watch.js +18 -16
  24. package/dist/env/node-watch.js.map +1 -1
  25. package/dist/env/node.d.ts +3 -3
  26. package/dist/env/node.d.ts.map +1 -1
  27. package/dist/env/node.js +1 -1
  28. package/dist/env/node.js.map +1 -1
  29. package/dist/errors.d.ts +18 -2
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js +26 -3
  32. package/dist/errors.js.map +1 -1
  33. package/dist/harness/agent.d.ts.map +1 -1
  34. package/dist/harness/agent.js +37 -3
  35. package/dist/harness/agent.js.map +1 -1
  36. package/dist/harness/compaction.d.ts +8 -3
  37. package/dist/harness/compaction.d.ts.map +1 -1
  38. package/dist/harness/compaction.js +40 -11
  39. package/dist/harness/compaction.js.map +1 -1
  40. package/dist/harness/context.d.ts +24 -8
  41. package/dist/harness/context.d.ts.map +1 -1
  42. package/dist/harness/context.js +71 -25
  43. package/dist/harness/context.js.map +1 -1
  44. package/dist/harness/define.d.ts +8 -4
  45. package/dist/harness/define.d.ts.map +1 -1
  46. package/dist/harness/define.js +4 -0
  47. package/dist/harness/define.js.map +1 -1
  48. package/dist/harness/generation.d.ts +39 -6
  49. package/dist/harness/generation.d.ts.map +1 -1
  50. package/dist/harness/generation.js +451 -100
  51. package/dist/harness/generation.js.map +1 -1
  52. package/dist/harness/harness.d.ts +3 -1
  53. package/dist/harness/harness.d.ts.map +1 -1
  54. package/dist/harness/harness.js +87 -62
  55. package/dist/harness/harness.js.map +1 -1
  56. package/dist/harness/inbox.d.ts +7 -2
  57. package/dist/harness/inbox.d.ts.map +1 -1
  58. package/dist/harness/inbox.js +5 -2
  59. package/dist/harness/inbox.js.map +1 -1
  60. package/dist/harness/json.d.ts +21 -5
  61. package/dist/harness/json.d.ts.map +1 -1
  62. package/dist/harness/json.js +23 -5
  63. package/dist/harness/json.js.map +1 -1
  64. package/dist/harness/live.d.ts +124 -17
  65. package/dist/harness/live.d.ts.map +1 -1
  66. package/dist/harness/live.js +93 -12
  67. package/dist/harness/live.js.map +1 -1
  68. package/dist/harness/partial-changes.d.ts +54 -0
  69. package/dist/harness/partial-changes.d.ts.map +1 -0
  70. package/dist/harness/partial-changes.js +163 -0
  71. package/dist/harness/partial-changes.js.map +1 -0
  72. package/dist/harness/prompt.d.ts +1 -1
  73. package/dist/harness/prompt.d.ts.map +1 -1
  74. package/dist/harness/prompt.js.map +1 -1
  75. package/dist/harness/provider.d.ts +1 -1
  76. package/dist/harness/provider.d.ts.map +1 -1
  77. package/dist/harness/provider.js.map +1 -1
  78. package/dist/harness/registry-reader.d.ts +11 -0
  79. package/dist/harness/registry-reader.d.ts.map +1 -0
  80. package/dist/harness/registry-reader.js +25 -0
  81. package/dist/harness/registry-reader.js.map +1 -0
  82. package/dist/harness/scheduler.d.ts +48 -20
  83. package/dist/harness/scheduler.d.ts.map +1 -1
  84. package/dist/harness/scheduler.js +1006 -380
  85. package/dist/harness/scheduler.js.map +1 -1
  86. package/dist/harness/submissions.d.ts +18 -11
  87. package/dist/harness/submissions.d.ts.map +1 -1
  88. package/dist/harness/submissions.js +63 -26
  89. package/dist/harness/submissions.js.map +1 -1
  90. package/dist/harness/task-graph.d.ts +14 -9
  91. package/dist/harness/task-graph.d.ts.map +1 -1
  92. package/dist/harness/task-graph.js +38 -28
  93. package/dist/harness/task-graph.js.map +1 -1
  94. package/dist/harness/telemetry.d.ts +11 -11
  95. package/dist/harness/telemetry.d.ts.map +1 -1
  96. package/dist/harness/telemetry.js +7 -7
  97. package/dist/harness/telemetry.js.map +1 -1
  98. package/dist/harness/tool.d.ts +3 -1
  99. package/dist/harness/tool.d.ts.map +1 -1
  100. package/dist/harness/tool.js +172 -56
  101. package/dist/harness/tool.js.map +1 -1
  102. package/dist/harness/transcript.d.ts +72 -0
  103. package/dist/harness/transcript.d.ts.map +1 -0
  104. package/dist/harness/transcript.js +264 -0
  105. package/dist/harness/transcript.js.map +1 -0
  106. package/dist/harness/types.d.ts +237 -61
  107. package/dist/harness/types.d.ts.map +1 -1
  108. package/dist/harness/types.js.map +1 -1
  109. package/dist/harness/usage.d.ts +2 -1
  110. package/dist/harness/usage.d.ts.map +1 -1
  111. package/dist/harness/usage.js +1 -1
  112. package/dist/harness/usage.js.map +1 -1
  113. package/dist/harness/util.d.ts +4 -2
  114. package/dist/harness/util.d.ts.map +1 -1
  115. package/dist/harness/util.js +9 -0
  116. package/dist/harness/util.js.map +1 -1
  117. package/dist/harness/view.d.ts +44 -22
  118. package/dist/harness/view.d.ts.map +1 -1
  119. package/dist/harness/view.js +130 -71
  120. package/dist/harness/view.js.map +1 -1
  121. package/dist/index.d.ts +9 -7
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +4 -3
  124. package/dist/index.js.map +1 -1
  125. package/dist/search.d.ts +19 -3
  126. package/dist/search.d.ts.map +1 -1
  127. package/dist/search.js +107 -44
  128. package/dist/search.js.map +1 -1
  129. package/dist/session/cached-storage.d.ts +72 -0
  130. package/dist/session/cached-storage.d.ts.map +1 -0
  131. package/dist/session/cached-storage.js +269 -0
  132. package/dist/session/cached-storage.js.map +1 -0
  133. package/dist/session/conversation-tails.d.ts +33 -0
  134. package/dist/session/conversation-tails.d.ts.map +1 -0
  135. package/dist/session/conversation-tails.js +101 -0
  136. package/dist/session/conversation-tails.js.map +1 -0
  137. package/dist/session/forks.d.ts +1 -1
  138. package/dist/session/forks.d.ts.map +1 -1
  139. package/dist/session/forks.js.map +1 -1
  140. package/dist/session/keys.d.ts +69 -0
  141. package/dist/session/keys.d.ts.map +1 -0
  142. package/dist/session/keys.js +161 -0
  143. package/dist/session/keys.js.map +1 -0
  144. package/dist/session/session.d.ts +169 -26
  145. package/dist/session/session.d.ts.map +1 -1
  146. package/dist/session/session.js +1029 -171
  147. package/dist/session/session.js.map +1 -1
  148. package/dist/session/transaction.d.ts +84 -5
  149. package/dist/session/transaction.d.ts.map +1 -1
  150. package/dist/session/transaction.js +227 -40
  151. package/dist/session/transaction.js.map +1 -1
  152. package/dist/session/writer.d.ts +74 -0
  153. package/dist/session/writer.d.ts.map +1 -0
  154. package/dist/session/writer.js +294 -0
  155. package/dist/session/writer.js.map +1 -0
  156. package/dist/storage/indexeddb.d.ts +12 -3
  157. package/dist/storage/indexeddb.d.ts.map +1 -1
  158. package/dist/storage/indexeddb.js +91 -41
  159. package/dist/storage/indexeddb.js.map +1 -1
  160. package/dist/storage/jsonl/index.d.ts +1 -1
  161. package/dist/storage/jsonl/index.d.ts.map +1 -1
  162. package/dist/storage/jsonl/index.js +1 -1
  163. package/dist/storage/jsonl/index.js.map +1 -1
  164. package/dist/storage/jsonl/node.d.ts +3 -3
  165. package/dist/storage/jsonl/node.d.ts.map +1 -1
  166. package/dist/storage/jsonl/node.js +2 -2
  167. package/dist/storage/jsonl/node.js.map +1 -1
  168. package/dist/storage/jsonl/storage.d.ts +46 -5
  169. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  170. package/dist/storage/jsonl/storage.js +158 -43
  171. package/dist/storage/jsonl/storage.js.map +1 -1
  172. package/dist/storage/memory.d.ts +9 -3
  173. package/dist/storage/memory.d.ts.map +1 -1
  174. package/dist/storage/memory.js +28 -2
  175. package/dist/storage/memory.js.map +1 -1
  176. package/dist/storage/sqlite/commit.d.ts +52 -0
  177. package/dist/storage/sqlite/commit.d.ts.map +1 -0
  178. package/dist/storage/sqlite/commit.js +371 -0
  179. package/dist/storage/sqlite/commit.js.map +1 -0
  180. package/dist/storage/sqlite/rows.d.ts +33 -0
  181. package/dist/storage/sqlite/rows.d.ts.map +1 -0
  182. package/dist/storage/sqlite/rows.js +53 -0
  183. package/dist/storage/sqlite/rows.js.map +1 -0
  184. package/dist/storage/sqlite/statements.d.ts +13 -0
  185. package/dist/storage/sqlite/statements.d.ts.map +1 -0
  186. package/dist/storage/sqlite/statements.js +88 -0
  187. package/dist/storage/sqlite/statements.js.map +1 -0
  188. package/dist/storage/sqlite/storage.d.ts +23 -10
  189. package/dist/storage/sqlite/storage.d.ts.map +1 -1
  190. package/dist/storage/sqlite/storage.js +120 -261
  191. package/dist/storage/sqlite/storage.js.map +1 -1
  192. package/dist/storage/store/commit.d.ts +33 -0
  193. package/dist/storage/store/commit.d.ts.map +1 -0
  194. package/dist/storage/store/commit.js +286 -0
  195. package/dist/storage/store/commit.js.map +1 -0
  196. package/dist/storage/store/documents.d.ts +43 -0
  197. package/dist/storage/store/documents.d.ts.map +1 -0
  198. package/dist/storage/store/documents.js +102 -0
  199. package/dist/storage/store/documents.js.map +1 -0
  200. package/dist/storage/store/index.d.ts +2 -0
  201. package/dist/storage/store/index.d.ts.map +1 -0
  202. package/dist/storage/store/index.js +2 -0
  203. package/dist/storage/store/index.js.map +1 -0
  204. package/dist/storage/store/queries.d.ts +49 -0
  205. package/dist/storage/store/queries.d.ts.map +1 -0
  206. package/dist/storage/store/queries.js +93 -0
  207. package/dist/storage/store/queries.js.map +1 -0
  208. package/dist/storage/store/storage.d.ts +60 -0
  209. package/dist/storage/store/storage.d.ts.map +1 -0
  210. package/dist/storage/store/storage.js +438 -0
  211. package/dist/storage/store/storage.js.map +1 -0
  212. package/dist/storage/store/tables.d.ts +54 -0
  213. package/dist/storage/store/tables.d.ts.map +1 -0
  214. package/dist/storage/store/tables.js +108 -0
  215. package/dist/storage/store/tables.js.map +1 -0
  216. package/dist/testing/commit-writes.d.ts +5 -0
  217. package/dist/testing/commit-writes.d.ts.map +1 -0
  218. package/dist/testing/commit-writes.js +12 -0
  219. package/dist/testing/commit-writes.js.map +1 -0
  220. package/dist/testing/env-conformance.js +7 -7
  221. package/dist/testing/env-conformance.js.map +1 -1
  222. package/dist/testing/index.d.ts +1 -0
  223. package/dist/testing/index.d.ts.map +1 -1
  224. package/dist/testing/index.js +1 -0
  225. package/dist/testing/index.js.map +1 -1
  226. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  227. package/dist/testing/storage-benchmark.js +18 -17
  228. package/dist/testing/storage-benchmark.js.map +1 -1
  229. package/dist/testing/storage-conformance.d.ts.map +1 -1
  230. package/dist/testing/storage-conformance.js +354 -69
  231. package/dist/testing/storage-conformance.js.map +1 -1
  232. package/dist/tool-search/tool-search.d.ts +8 -14
  233. package/dist/tool-search/tool-search.d.ts.map +1 -1
  234. package/dist/tool-search/tool-search.js +25 -13
  235. package/dist/tool-search/tool-search.js.map +1 -1
  236. package/dist/tools/bash.d.ts +13 -13
  237. package/dist/tools/bash.d.ts.map +1 -1
  238. package/dist/tools/bash.js +16 -15
  239. package/dist/tools/bash.js.map +1 -1
  240. package/dist/tools/edit.d.ts +7 -7
  241. package/dist/tools/edit.d.ts.map +1 -1
  242. package/dist/tools/edit.js +9 -9
  243. package/dist/tools/edit.js.map +1 -1
  244. package/dist/tools/file-mutation-queue.d.ts +1 -1
  245. package/dist/tools/file-mutation-queue.d.ts.map +1 -1
  246. package/dist/tools/file-mutation-queue.js.map +1 -1
  247. package/dist/tools/index.d.ts +32 -32
  248. package/dist/tools/path-utils.d.ts +1 -1
  249. package/dist/tools/path-utils.d.ts.map +1 -1
  250. package/dist/tools/path-utils.js.map +1 -1
  251. package/dist/tools/read.d.ts +6 -6
  252. package/dist/tools/read.d.ts.map +1 -1
  253. package/dist/tools/read.js +19 -8
  254. package/dist/tools/read.js.map +1 -1
  255. package/dist/tools/write.d.ts +4 -4
  256. package/dist/tools/write.d.ts.map +1 -1
  257. package/dist/tools/write.js +5 -5
  258. package/dist/tools/write.js.map +1 -1
  259. package/dist/types.d.ts +228 -78
  260. package/dist/types.d.ts.map +1 -1
  261. package/dist/types.js.map +1 -1
  262. package/package.json +19 -12
  263. package/dist/harness/events.d.ts +0 -186
  264. package/dist/harness/events.d.ts.map +0 -1
  265. package/dist/harness/events.js +0 -373
  266. package/dist/harness/events.js.map +0 -1
  267. package/dist/session/observation.d.ts +0 -42
  268. package/dist/session/observation.d.ts.map +0 -1
  269. package/dist/session/observation.js +0 -281
  270. 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,7 +524,7 @@ 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
  }),
@@ -478,19 +536,19 @@ export function createStorageConformance(options) {
478
536
  state: { status: "sleeping", checkpoint: { phase: "next" }, until: 61_000 },
479
537
  memos: { kept: true },
480
538
  };
481
- await storage.commit([{ type: "task", value: sleeping }], context);
539
+ await commitWrites(storage, [{ type: "task", value: sleeping }], context);
482
540
  expect(await storage.task(sleepingId, context)).toEqual(sleeping);
483
541
  const scan = async (status) => (await storage.scanTasks({ status }, 10, undefined, context)).items;
484
542
  expect(await scan("sleeping")).toEqual([sleeping]);
485
543
  const running = { ...sleeping, state: { status: "running", checkpoint: { phase: "next" } } };
486
- await storage.commit([{ type: "task", value: running }], context);
544
+ await commitWrites(storage, [{ type: "task", value: running }], context);
487
545
  expect(await scan("sleeping")).toEqual([]);
488
546
  expect(await scan("running")).toEqual([running]);
489
547
  }),
490
548
  createCase(options, "indexes request IDs per conversation and replaces complete submission records", async (storage) => {
491
549
  const rootId = await createRoot(storage);
492
550
  const secondConversationId = await storage.mintId();
493
- await storage.commit([{ type: "conversation", value: { id: secondConversationId } }], context);
551
+ await commitWrites(storage, [{ type: "conversation", value: { id: secondConversationId } }], context);
494
552
  const firstId = await storage.mintId();
495
553
  const secondId = await storage.mintId();
496
554
  const otherConversationId = await storage.mintId();
@@ -515,7 +573,7 @@ export function createStorageConformance(options) {
515
573
  type: "input",
516
574
  status: "queued",
517
575
  };
518
- await storage.commit([
576
+ await commitWrites(storage, [
519
577
  { type: "submission", value: first },
520
578
  { type: "submission", value: second },
521
579
  { type: "submission", value: otherConversation },
@@ -527,7 +585,7 @@ export function createStorageConformance(options) {
527
585
  status: "placed",
528
586
  entry: await storage.mintId(),
529
587
  };
530
- await storage.commit([{ type: "submission", value: placedSecond }], context);
588
+ await commitWrites(storage, [{ type: "submission", value: placedSecond }], context);
531
589
  expect(await storage.submission(secondId, context)).toEqual(placedSecond);
532
590
  expect(await storage.submissionByRequest(rootId, "other", context)).toEqual(placedSecond);
533
591
  const ids = async (query) => {
@@ -571,7 +629,7 @@ export function createStorageConformance(options) {
571
629
  type: "write",
572
630
  status: "queued",
573
631
  };
574
- await storage.commit([
632
+ await commitWrites(storage, [
575
633
  { type: "submission", value: queuedDone },
576
634
  { type: "submission", value: queuedFailed },
577
635
  ], context);
@@ -586,7 +644,7 @@ export function createStorageConformance(options) {
586
644
  reason: "closed",
587
645
  detail: { retryable: false },
588
646
  };
589
- await storage.commit([
647
+ await commitWrites(storage, [
590
648
  { type: "submission", value: done },
591
649
  { type: "submission", value: unanswered },
592
650
  ], context);
@@ -595,6 +653,44 @@ export function createStorageConformance(options) {
595
653
  expect(await storage.submission(failedId, context)).toEqual(unanswered);
596
654
  expect(await storage.submissionByRequest(rootId, "passive-failed", context)).toEqual(unanswered);
597
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
+ }),
598
694
  createCase(options, "reconstructs rewindable documents and preserves half-open incarnations", async (storage) => {
599
695
  const rootId = await createRoot(storage);
600
696
  const firstId = await storage.mintId();
@@ -606,7 +702,7 @@ export function createStorageConformance(options) {
606
702
  fork: "asOf",
607
703
  };
608
704
  const initial = { items: ["a"], nested: { count: 1 } };
609
- const createdAt = await storage.commit([
705
+ const createdAt = await commitWrites(storage, [
610
706
  {
611
707
  type: "document.create",
612
708
  record: firstRecord,
@@ -618,7 +714,7 @@ export function createStorageConformance(options) {
618
714
  ["p", ["items"], 1, 0, appended],
619
715
  ["s", ["nested", "count"], 2],
620
716
  ];
621
- 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);
622
718
  initial.items.push("caller mutation");
623
719
  appended.push("caller mutation");
624
720
  expect(await storage.document(firstId, createdAt, context)).toMatchObject({
@@ -634,14 +730,14 @@ export function createStorageConformance(options) {
634
730
  items: ["a", "b"],
635
731
  nested: { count: 2 },
636
732
  });
637
- const checkpointAt = await storage.commit([
733
+ const checkpointAt = await commitWrites(storage, [
638
734
  {
639
735
  type: "document.change",
640
736
  id: firstId,
641
737
  content: { kind: "base", version: 2, value: { items: ["checkpoint"], nested: { count: 3 } } },
642
738
  },
643
739
  ], context);
644
- const replacedAt = await storage.commit([
740
+ const replacedAt = await commitWrites(storage, [
645
741
  {
646
742
  type: "document.change",
647
743
  id: firstId,
@@ -667,7 +763,7 @@ export function createStorageConformance(options) {
667
763
  });
668
764
  expect((await storage.document(firstId, "current", context))?.deltasSinceBase).toBe(1);
669
765
  const secondId = await storage.mintId();
670
- const retiredAt = await storage.commit([
766
+ const retiredAt = await commitWrites(storage, [
671
767
  {
672
768
  type: "document.create",
673
769
  record: { ...firstRecord, id: secondId },
@@ -705,14 +801,14 @@ export function createStorageConformance(options) {
705
801
  revision: 0,
706
802
  rows: Array.from({ length: 512 }, (_, value) => ({ value, stable: `row-${value}` })),
707
803
  };
708
- 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);
709
805
  const beforeReplacement = structuredClone(initial);
710
806
  let beforeReplacementAt = createdAt;
711
807
  for (let revision = 1; revision <= 24; revision++) {
712
808
  const index = (revision * 17) % beforeReplacement.rows.length;
713
809
  beforeReplacement.rows[index].value = -revision;
714
810
  beforeReplacement.revision = revision;
715
- beforeReplacementAt = await storage.commit([
811
+ beforeReplacementAt = await commitWrites(storage, [
716
812
  {
717
813
  type: "document.change",
718
814
  id,
@@ -732,7 +828,7 @@ export function createStorageConformance(options) {
732
828
  rows: Array.from({ length: 512 }, (_, value) => ({ value: 10_000 + value, stable: `new-${value}` })),
733
829
  };
734
830
  const replacementSnapshot = structuredClone(replacement);
735
- const replacementAt = await storage.commit([
831
+ const replacementAt = await commitWrites(storage, [
736
832
  {
737
833
  type: "document.change",
738
834
  id,
@@ -745,7 +841,7 @@ export function createStorageConformance(options) {
745
841
  const index = (revision * 19) % current.rows.length;
746
842
  current.rows[index].value = -revision;
747
843
  current.revision = revision;
748
- await storage.commit([
844
+ await commitWrites(storage, [
749
845
  {
750
846
  type: "document.change",
751
847
  id,
@@ -772,7 +868,7 @@ export function createStorageConformance(options) {
772
868
  const rootId = await createRoot(storage);
773
869
  const childId = await storage.mintId();
774
870
  const secondChildId = await storage.mintId();
775
- await storage.commit([
871
+ await commitWrites(storage, [
776
872
  { type: "conversation", value: { id: childId } },
777
873
  { type: "conversation", value: { id: secondChildId } },
778
874
  ], context);
@@ -784,14 +880,14 @@ export function createStorageConformance(options) {
784
880
  history: "rewindable",
785
881
  fork: "asOf",
786
882
  };
787
- const createdAt = await storage.commit([
883
+ const createdAt = await commitWrites(storage, [
788
884
  {
789
885
  type: "document.create",
790
886
  record: sourceRecord,
791
887
  content: { kind: "base", version: 2, value: { count: 1, rows: [{ value: "base" }] } },
792
888
  },
793
889
  ], context);
794
- await storage.commit([
890
+ await commitWrites(storage, [
795
891
  {
796
892
  type: "document.change",
797
893
  id: sourceId,
@@ -815,7 +911,7 @@ export function createStorageConformance(options) {
815
911
  history: "rewindable",
816
912
  fork: "asOf",
817
913
  });
818
- await storage.commit([
914
+ await commitWrites(storage, [
819
915
  {
820
916
  type: "document.copy",
821
917
  record: childRecord(historicalCopyId, childId),
@@ -842,7 +938,7 @@ export function createStorageConformance(options) {
842
938
  value: { count: 2, rows: [{ value: "base" }, { value: "current" }] },
843
939
  });
844
940
  expect(await storage.document(retiredCopyId, "current", context)).toBeUndefined();
845
- await storage.commit([
941
+ await commitWrites(storage, [
846
942
  {
847
943
  type: "document.change",
848
944
  id: sourceId,
@@ -863,14 +959,14 @@ export function createStorageConformance(options) {
863
959
  history: "latest",
864
960
  fork: "current",
865
961
  };
866
- await storage.commit([
962
+ await commitWrites(storage, [
867
963
  {
868
964
  type: "document.create",
869
965
  record: latestSource,
870
966
  content: { kind: "base", version: 4, value: { retained: "copy" } },
871
967
  },
872
968
  ], context);
873
- await storage.commit([
969
+ await commitWrites(storage, [
874
970
  {
875
971
  type: "document.copy",
876
972
  record: {
@@ -881,7 +977,7 @@ export function createStorageConformance(options) {
881
977
  source: { id: latestSourceId, at: "current" },
882
978
  },
883
979
  ], context);
884
- await storage.commit([
980
+ await commitWrites(storage, [
885
981
  {
886
982
  type: "document.change",
887
983
  id: latestSourceId,
@@ -896,7 +992,7 @@ export function createStorageConformance(options) {
896
992
  const conflictId = await storage.mintId();
897
993
  let conflictError;
898
994
  try {
899
- await storage.commit([
995
+ await commitWrites(storage, [
900
996
  {
901
997
  type: "document.copy",
902
998
  record: childRecord(conflictId, childId),
@@ -918,7 +1014,7 @@ export function createStorageConformance(options) {
918
1014
  const mismatchId = await storage.mintId();
919
1015
  let mismatchError;
920
1016
  try {
921
- await storage.commit([
1017
+ await commitWrites(storage, [
922
1018
  {
923
1019
  type: "document.copy",
924
1020
  record: { ...childRecord(mismatchId, childId), kind: "copy.mismatch" },
@@ -940,14 +1036,14 @@ export function createStorageConformance(options) {
940
1036
  kind: "session.settings",
941
1037
  scope: { kind: "session" },
942
1038
  };
943
- await storage.commit([{ type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } }], context);
944
- await storage.commit([{ type: "document.change", id, content: { kind: "delta", version: 1, ops: [["s", ["count"], 2]] } }], context);
945
- 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);
946
1042
  expect(await storage.document(id, "current", context)).toMatchObject({ version: 2, value: { count: 3 } });
947
1043
  await expect(storage.document(id, migratedAt, context)).rejects.toThrow("does not retain historical content");
948
- 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");
949
1045
  expect((await storage.document(id, "current", context))?.value).toEqual({ count: 3 });
950
- await storage.commit([{ type: "document.retire", id }], context);
1046
+ await commitWrites(storage, [{ type: "document.retire", id }], context);
951
1047
  expect(await storage.document(id, "current", context)).toBeUndefined();
952
1048
  }),
953
1049
  createCase(options, "indexes logical addresses and exact-scope scans independently", async (storage) => {
@@ -959,7 +1055,7 @@ export function createStorageConformance(options) {
959
1055
  const taskSingletonId = await storage.mintId();
960
1056
  const taskFamilyId = await storage.mintId();
961
1057
  const taskOtherKindId = await storage.mintId();
962
- const createdAt = await storage.commit([
1058
+ const createdAt = await commitWrites(storage, [
963
1059
  { type: "task", value: pendingTask(taskId, rootId) },
964
1060
  {
965
1061
  type: "document.create",
@@ -1043,8 +1139,8 @@ export function createStorageConformance(options) {
1043
1139
  kind: "singleton",
1044
1140
  scope: { kind: "session" },
1045
1141
  };
1046
- await storage.commit([{ type: "document.create", record, content: { kind: "base", version: 1, value: { value: 1 } } }], context);
1047
- 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, [
1048
1144
  {
1049
1145
  type: "document.create",
1050
1146
  record: { ...record, id: secondId },
@@ -1055,7 +1151,7 @@ export function createStorageConformance(options) {
1055
1151
  expect((await storage.document(firstId, "current", context))?.value).toEqual({ value: 1 });
1056
1152
  expect(await storage.document(secondId, "current", context)).toBeUndefined();
1057
1153
  const emptyId = await storage.mintId();
1058
- const emptyAt = await storage.commit([
1154
+ const emptyAt = await commitWrites(storage, [
1059
1155
  {
1060
1156
  type: "document.create",
1061
1157
  record: {
@@ -1078,6 +1174,25 @@ export function createStorageConformance(options) {
1078
1174
  key: "empty",
1079
1175
  }, emptyAt, context)).toBeUndefined();
1080
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
+ }),
1081
1196
  createCase(options, "rolls back record tables and secondary indexes when a document command fails", async (storage) => {
1082
1197
  const rootId = await createRoot(storage);
1083
1198
  const taskId = await storage.mintId();
@@ -1096,14 +1211,14 @@ export function createStorageConformance(options) {
1096
1211
  kind: "atomic",
1097
1212
  scope: { kind: "session" },
1098
1213
  };
1099
- const baselineSeq = await storage.commit([
1214
+ const baselineSeq = await commitWrites(storage, [
1100
1215
  { type: "task", value: task },
1101
1216
  { type: "submission", value: submission },
1102
1217
  { type: "document.create", record, content: { kind: "base", version: 1, value: { count: 1 } } },
1103
1218
  ], context);
1104
1219
  const entryId = await storage.mintId();
1105
1220
  const conflictingDocumentId = await storage.mintId();
1106
- await expect(storage.commit([
1221
+ await expect(commitWrites(storage, [
1107
1222
  {
1108
1223
  type: "task",
1109
1224
  value: { ...task, state: { status: "running", checkpoint: { phase: "effect" } } },
@@ -1125,7 +1240,7 @@ export function createStorageConformance(options) {
1125
1240
  expect(await storage.entry(entryId, context)).toBeUndefined();
1126
1241
  expect(await storage.document(conflictingDocumentId, "current", context)).toBeUndefined();
1127
1242
  expect((await storage.findDocument({ kind: record.kind, scope: record.scope }, "current", context))?.id).toBe(documentId);
1128
- const afterRollbackSeq = await storage.commit([
1243
+ const afterRollbackSeq = await commitWrites(storage, [
1129
1244
  {
1130
1245
  type: "document.change",
1131
1246
  id: documentId,
@@ -1146,7 +1261,7 @@ export function createStorageConformance(options) {
1146
1261
  const secondKindDocumentId = await storage.mintId();
1147
1262
  const firstKeyDocumentId = await storage.mintId();
1148
1263
  const secondKeyDocumentId = await storage.mintId();
1149
- await storage.commit([
1264
+ await commitWrites(storage, [
1150
1265
  { type: "task", value: { ...pendingTask(firstTaskId, rootId), kind: first } },
1151
1266
  { type: "task", value: { ...pendingTask(secondTaskId, rootId), kind: second } },
1152
1267
  {
@@ -1211,18 +1326,188 @@ export function createStorageConformance(options) {
1211
1326
  createCase(options, "keeps one global record ID namespace and rejects exhausted ID minting", async (storage) => {
1212
1327
  const rootId = await createRoot(storage);
1213
1328
  const explicitEntryId = idFromNumber(100);
1214
- await storage.commit([{ type: "entry", value: entry(explicitEntryId, rootId) }], context);
1329
+ await commitWrites(storage, [{ type: "entry", value: entry(explicitEntryId, rootId) }], context);
1215
1330
  expect(await storage.mintId()).toBe(101);
1216
- await expect(storage.commit([{ type: "task", value: pendingTask(idFromNumber(explicitEntryId), rootId) }], context)).rejects.toThrow(`ID ${explicitEntryId} already belongs to entry`);
1217
- 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);
1218
1333
  await expect(storage.mintId()).rejects.toThrow("ID space is exhausted");
1219
1334
  await expect(storage.mintId()).rejects.toThrow("ID space is exhausted");
1220
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
+ }),
1221
1506
  createCase(options, "rejects every operation after close", async (storage) => {
1222
1507
  await createRoot(storage);
1223
1508
  await storage.close(context);
1224
1509
  await expect(storage.conversation(ROOT_CONVERSATION_ID, context)).rejects.toThrow("closed");
1225
- await expect(storage.commit([], context)).rejects.toThrow("closed");
1510
+ await expect(commitWrites(storage, [{ type: "conversation", value: { id: ROOT_CONVERSATION_ID } }], context)).rejects.toThrow("closed");
1226
1511
  await expect(storage.mintId()).rejects.toThrow("closed");
1227
1512
  }),
1228
1513
  ];