@haverstack/server 0.8.0 → 0.9.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 (197) hide show
  1. package/dist/app.d.ts +8 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +70 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/config.d.ts +21 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +115 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/changeStreams.d.ts +17 -0
  14. package/dist/lib/changeStreams.d.ts.map +1 -0
  15. package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
  16. package/dist/lib/changeStreams.js.map +1 -0
  17. package/dist/lib/frameGate.d.ts +26 -0
  18. package/dist/lib/frameGate.d.ts.map +1 -0
  19. package/dist/lib/frameGate.js +44 -0
  20. package/dist/lib/frameGate.js.map +1 -0
  21. package/dist/lib/json.d.ts +10 -0
  22. package/dist/lib/json.d.ts.map +1 -0
  23. package/dist/lib/json.js +27 -0
  24. package/dist/lib/json.js.map +1 -0
  25. package/dist/lib/nonceStore.d.ts +50 -0
  26. package/dist/lib/nonceStore.d.ts.map +1 -0
  27. package/dist/lib/nonceStore.js +96 -0
  28. package/dist/lib/nonceStore.js.map +1 -0
  29. package/dist/lib/queryLimit.d.ts +4 -0
  30. package/dist/lib/queryLimit.d.ts.map +1 -0
  31. package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
  32. package/dist/lib/queryLimit.js.map +1 -0
  33. package/dist/lib/queryWorker/pool.d.ts +60 -0
  34. package/dist/lib/queryWorker/pool.d.ts.map +1 -0
  35. package/dist/lib/queryWorker/pool.js +278 -0
  36. package/dist/lib/queryWorker/pool.js.map +1 -0
  37. package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
  38. package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
  39. package/dist/lib/queryWorker/protocol.js +2 -0
  40. package/dist/lib/queryWorker/protocol.js.map +1 -0
  41. package/dist/lib/queryWorker/worker.d.ts +2 -0
  42. package/dist/lib/queryWorker/worker.d.ts.map +1 -0
  43. package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
  44. package/dist/lib/queryWorker/worker.js.map +1 -0
  45. package/dist/lib/resumeBuffer.d.ts +143 -0
  46. package/dist/lib/resumeBuffer.d.ts.map +1 -0
  47. package/dist/lib/resumeBuffer.js +216 -0
  48. package/dist/lib/resumeBuffer.js.map +1 -0
  49. package/dist/lib/resumeCursor.d.ts +16 -0
  50. package/dist/lib/resumeCursor.d.ts.map +1 -0
  51. package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
  52. package/dist/lib/resumeCursor.js.map +1 -0
  53. package/dist/lib/tokenSweep.d.ts +17 -0
  54. package/dist/lib/tokenSweep.d.ts.map +1 -0
  55. package/dist/lib/tokenSweep.js +40 -0
  56. package/dist/lib/tokenSweep.js.map +1 -0
  57. package/dist/main.d.ts +2 -0
  58. package/dist/main.d.ts.map +1 -0
  59. package/dist/main.js +46 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +25 -0
  62. package/dist/middleware/auth.d.ts.map +1 -0
  63. package/dist/middleware/auth.js +74 -0
  64. package/dist/middleware/auth.js.map +1 -0
  65. package/dist/middleware/errors.d.ts +17 -0
  66. package/dist/middleware/errors.d.ts.map +1 -0
  67. package/dist/middleware/errors.js +64 -0
  68. package/dist/middleware/errors.js.map +1 -0
  69. package/dist/routes/attachments.d.ts +5 -0
  70. package/dist/routes/attachments.d.ts.map +1 -0
  71. package/dist/routes/attachments.js +108 -0
  72. package/dist/routes/attachments.js.map +1 -0
  73. package/dist/routes/auth.d.ts +6 -0
  74. package/dist/routes/auth.d.ts.map +1 -0
  75. package/dist/routes/auth.js +110 -0
  76. package/dist/routes/auth.js.map +1 -0
  77. package/dist/routes/changes.d.ts +22 -0
  78. package/dist/routes/changes.d.ts.map +1 -0
  79. package/dist/routes/changes.js +233 -0
  80. package/dist/routes/changes.js.map +1 -0
  81. package/dist/routes/entity.d.ts +5 -0
  82. package/dist/routes/entity.d.ts.map +1 -0
  83. package/dist/routes/entity.js +30 -0
  84. package/dist/routes/entity.js.map +1 -0
  85. package/dist/routes/health.d.ts +4 -0
  86. package/dist/routes/health.d.ts.map +1 -0
  87. package/dist/routes/health.js +7 -0
  88. package/dist/routes/health.js.map +1 -0
  89. package/dist/routes/records.d.ts +5 -0
  90. package/dist/routes/records.d.ts.map +1 -0
  91. package/dist/routes/records.js +231 -0
  92. package/dist/routes/records.js.map +1 -0
  93. package/dist/routes/tokens.d.ts +5 -0
  94. package/dist/routes/tokens.d.ts.map +1 -0
  95. package/dist/routes/tokens.js +65 -0
  96. package/dist/routes/tokens.js.map +1 -0
  97. package/dist/routes/types.d.ts +5 -0
  98. package/dist/routes/types.d.ts.map +1 -0
  99. package/dist/routes/types.js +46 -0
  100. package/dist/routes/types.js.map +1 -0
  101. package/dist/routes/wellknown.d.ts +6 -0
  102. package/dist/routes/wellknown.d.ts.map +1 -0
  103. package/dist/routes/wellknown.js +45 -0
  104. package/dist/routes/wellknown.js.map +1 -0
  105. package/dist/shutdown.d.ts +23 -0
  106. package/dist/shutdown.d.ts.map +1 -0
  107. package/dist/shutdown.js +32 -0
  108. package/dist/shutdown.js.map +1 -0
  109. package/dist/stack.d.ts +20 -0
  110. package/dist/stack.d.ts.map +1 -0
  111. package/dist/stack.js +50 -0
  112. package/dist/stack.js.map +1 -0
  113. package/dist/testing.d.ts +49 -0
  114. package/dist/testing.d.ts.map +1 -0
  115. package/dist/testing.js +130 -0
  116. package/dist/testing.js.map +1 -0
  117. package/{src/types.ts → dist/types.d.ts} +5 -5
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
  122. package/dist/wireError.d.ts.map +1 -0
  123. package/dist/wireError.js +13 -0
  124. package/dist/wireError.js.map +1 -0
  125. package/package.json +42 -17
  126. package/.claude/settings.local.json +0 -22
  127. package/.dockerignore +0 -10
  128. package/.env.example +0 -90
  129. package/.github/pull_request_template.md +0 -23
  130. package/.github/workflows/ci.yml +0 -55
  131. package/.github/workflows/release.yml +0 -99
  132. package/.prettierignore +0 -3
  133. package/.prettierrc +0 -7
  134. package/AGENTS.md +0 -91
  135. package/CONTRIBUTING.md +0 -234
  136. package/Dockerfile +0 -30
  137. package/SECURITY.md +0 -54
  138. package/docs/api.md +0 -277
  139. package/docs/deployment.md +0 -212
  140. package/eslint.config.mjs +0 -18
  141. package/pnpm-workspace.yaml +0 -13
  142. package/src/app.ts +0 -86
  143. package/src/config.ts +0 -176
  144. package/src/index.ts +0 -54
  145. package/src/lib/frameGate.ts +0 -42
  146. package/src/lib/json.ts +0 -27
  147. package/src/lib/nonceStore.ts +0 -103
  148. package/src/lib/queryWorker/pool.ts +0 -324
  149. package/src/lib/resumeBuffer.ts +0 -268
  150. package/src/lib/tokenSweep.ts +0 -43
  151. package/src/middleware/auth.ts +0 -77
  152. package/src/middleware/errors.ts +0 -73
  153. package/src/routes/attachments.ts +0 -126
  154. package/src/routes/auth.ts +0 -137
  155. package/src/routes/changes.ts +0 -279
  156. package/src/routes/entity.ts +0 -33
  157. package/src/routes/health.ts +0 -8
  158. package/src/routes/records.ts +0 -259
  159. package/src/routes/tokens.ts +0 -85
  160. package/src/routes/types.ts +0 -57
  161. package/src/routes/wellknown.ts +0 -55
  162. package/src/shutdown.ts +0 -56
  163. package/src/stack.ts +0 -96
  164. package/tests/app.test.ts +0 -86
  165. package/tests/changeFeedClient.ts +0 -156
  166. package/tests/config.test.ts +0 -112
  167. package/tests/conformance.test.ts +0 -2322
  168. package/tests/fixtures/blockingWorker.ts +0 -27
  169. package/tests/fixtures/brokenWorker.ts +0 -7
  170. package/tests/lib/changeFeedClient.test.ts +0 -126
  171. package/tests/lib/frameGate.test.ts +0 -93
  172. package/tests/lib/queryLimit.test.ts +0 -33
  173. package/tests/lib/queryWorker.test.ts +0 -60
  174. package/tests/lib/queryWorkerPool.test.ts +0 -129
  175. package/tests/lib/resumeBuffer.test.ts +0 -341
  176. package/tests/lib/resumeCursor.test.ts +0 -46
  177. package/tests/middleware/errors.test.ts +0 -176
  178. package/tests/routes/associations.test.ts +0 -170
  179. package/tests/routes/attachments.test.ts +0 -621
  180. package/tests/routes/auth.test.ts +0 -448
  181. package/tests/routes/changes.test.ts +0 -567
  182. package/tests/routes/contentQuery.test.ts +0 -68
  183. package/tests/routes/entity.test.ts +0 -170
  184. package/tests/routes/groups.test.ts +0 -69
  185. package/tests/routes/records.test.ts +0 -1965
  186. package/tests/routes/relatedToQuery.test.ts +0 -182
  187. package/tests/routes/tokens.test.ts +0 -157
  188. package/tests/routes/tombstones.test.ts +0 -93
  189. package/tests/routes/types.test.ts +0 -129
  190. package/tests/routes/versions.test.ts +0 -177
  191. package/tests/routes/wellknown.test.ts +0 -71
  192. package/tests/setup.ts +0 -154
  193. package/tests/shutdown.test.ts +0 -121
  194. package/tests/stack.test.ts +0 -198
  195. package/tsconfig.build.json +0 -9
  196. package/tsconfig.json +0 -19
  197. package/vitest.config.ts +0 -12
@@ -1,341 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import type { RecordChange, Unsubscribe } from '@haverstack/core';
3
- import { decodeCursor } from '../../src/lib/resumeCursor.js';
4
- import { ResumeBuffer, ResumeBufferRegistry, resumeBufferKey } from '../../src/lib/resumeBuffer.js';
5
-
6
- let counter = 0;
7
- function change(overrides: Partial<RecordChange> = {}): RecordChange {
8
- counter += 1;
9
- return {
10
- kind: 'changed',
11
- ops: ['patch'],
12
- recordId: `record-${counter}`,
13
- typeId: 'com.example/note@1',
14
- version: counter,
15
- updatedAt: new Date('2024-01-01T00:00:00.000Z'),
16
- ...overrides,
17
- };
18
- }
19
-
20
- describe('ResumeBuffer', () => {
21
- it('mints the "nothing yet" head cursor at position 0', () => {
22
- const buffer = new ResumeBuffer(10);
23
- const decoded = decodeCursor(buffer.headCursor())!;
24
- expect(decoded.bufferId).toBe(buffer.id);
25
- expect(decoded.n).toBe(0);
26
- });
27
-
28
- it("advances the head cursor and attaches it as each entry's seq", () => {
29
- const buffer = new ResumeBuffer(10);
30
- const entry = buffer.append(change());
31
- expect(entry.n).toBe(1);
32
- expect(entry.frame.seq).toBe(buffer.headCursor());
33
- expect(decodeCursor(entry.frame.seq!)!.n).toBe(1);
34
- });
35
-
36
- it('notifies every attached live listener of each append, and stops once detached', () => {
37
- const buffer = new ResumeBuffer(10);
38
- const seen: string[] = [];
39
- const detach = buffer.subscribeLive((entry) => seen.push(entry.recordId));
40
-
41
- const c1 = change({ recordId: 'r1' });
42
- buffer.append(c1);
43
- detach();
44
- buffer.append(change({ recordId: 'r2' }));
45
-
46
- expect(seen).toEqual(['r1']);
47
- });
48
-
49
- it('replays everything after a given position, in order', () => {
50
- const buffer = new ResumeBuffer(10);
51
- buffer.append(change({ recordId: 'r1' }));
52
- const e2 = buffer.append(change({ recordId: 'r2' }));
53
- const e3 = buffer.append(change({ recordId: 'r3' }));
54
-
55
- const result = buffer.entriesAfter(1);
56
- expect(result).toEqual({ status: 'ok', entries: [e2, e3] });
57
- });
58
-
59
- it('reports nothing missing when asked to replay from its own current head', () => {
60
- const buffer = new ResumeBuffer(10);
61
- buffer.append(change());
62
- const result = buffer.entriesAfter(1);
63
- expect(result).toEqual({ status: 'ok', entries: [] });
64
- });
65
-
66
- it('reports cursor_expired for a position beyond anything it has ever minted', () => {
67
- const buffer = new ResumeBuffer(10);
68
- buffer.append(change());
69
- expect(buffer.entriesAfter(99)).toEqual({ status: 'cursor_expired' });
70
- });
71
-
72
- it('reports overflow once depth eviction has dropped what would be needed', () => {
73
- const buffer = new ResumeBuffer(2);
74
- buffer.append(change()); // n=1, evicted once n=4 lands
75
- buffer.append(change()); // n=2
76
- buffer.append(change()); // n=3, evicts n=1
77
- buffer.append(change()); // n=4, evicts n=2
78
-
79
- // n=1 and n=2 are both gone; only entries after n=2 are retained.
80
- expect(buffer.entriesAfter(1)).toEqual({ status: 'overflow' });
81
- expect(buffer.entriesAfter(2).status).toBe('ok');
82
- });
83
- });
84
-
85
- describe('resumeBufferKey', () => {
86
- it('is stable across a differently-ordered but equivalent filter', () => {
87
- const a = resumeBufferKey({
88
- principalId: 'p1',
89
- subjectId: 's1',
90
- filter: { typeId: ['b', 'a'], kinds: ['deleted', 'created'] },
91
- includeRecords: true,
92
- includeUnlisted: false,
93
- });
94
- const b = resumeBufferKey({
95
- principalId: 'p1',
96
- subjectId: 's1',
97
- filter: { typeId: ['a', 'b'], kinds: ['created', 'deleted'] },
98
- includeRecords: true,
99
- includeUnlisted: false,
100
- });
101
- expect(a).toBe(b);
102
- });
103
-
104
- it('differs across sessions, filters, the includeRecords choice, or the includeUnlisted choice', () => {
105
- const base = { filter: {}, includeRecords: false, includeUnlisted: false };
106
- const anon = resumeBufferKey({ principalId: null, subjectId: null, ...base });
107
- const authed = resumeBufferKey({ principalId: 'p1', subjectId: 's1', ...base });
108
- const filtered = resumeBufferKey({
109
- principalId: null,
110
- subjectId: null,
111
- filter: { entityId: 'e1' },
112
- includeRecords: false,
113
- includeUnlisted: false,
114
- });
115
- const withRecords = resumeBufferKey({
116
- principalId: null,
117
- subjectId: null,
118
- ...base,
119
- includeRecords: true,
120
- });
121
- const withUnlisted = resumeBufferKey({
122
- principalId: null,
123
- subjectId: null,
124
- ...base,
125
- includeUnlisted: true,
126
- });
127
-
128
- const keys = new Set([anon, authed, filtered, withRecords, withUnlisted]);
129
- expect(keys.size).toBe(5);
130
- });
131
-
132
- // A buffer opens one subscription carrying the filter of whichever
133
- // connection minted it, so a collision here hands one of the two the
134
- // other's filter — a silent gap in one direction, frames outside the
135
- // filter in the other. `parentId` is the case a positional
136
- // JSON.stringify got wrong: `undefined` renders as `null` inside an
137
- // array, so "no parentId filter" and `parentId: null` collapsed onto
138
- // one key.
139
- it('distinguishes an absent parentId filter from parentId: null', () => {
140
- const base = {
141
- principalId: null,
142
- subjectId: null,
143
- includeRecords: false,
144
- includeUnlisted: false,
145
- };
146
- const unfiltered = resumeBufferKey({ ...base, filter: {} });
147
- const rootsOnly = resumeBufferKey({ ...base, filter: { parentId: null } });
148
- const oneParent = resumeBufferKey({ ...base, filter: { parentId: 'rec-1' } });
149
-
150
- expect(new Set([unfiltered, rootsOnly, oneParent]).size).toBe(3);
151
- });
152
-
153
- it('distinguishes an absent filter field from every value that field can hold', () => {
154
- const base = {
155
- principalId: null,
156
- subjectId: null,
157
- includeRecords: false,
158
- includeUnlisted: false,
159
- };
160
- expect(resumeBufferKey({ ...base, filter: {} })).not.toBe(
161
- resumeBufferKey({ ...base, filter: { entityId: 'e1' } }),
162
- );
163
- expect(resumeBufferKey({ ...base, filter: {} })).not.toBe(
164
- resumeBufferKey({ ...base, filter: { typeId: [] } }),
165
- );
166
- expect(resumeBufferKey({ ...base, filter: {} })).not.toBe(
167
- resumeBufferKey({ ...base, filter: { kinds: [] } }),
168
- );
169
- });
170
-
171
- it("does not let one field's value imitate another's", () => {
172
- const base = {
173
- principalId: null,
174
- subjectId: null,
175
- includeRecords: false,
176
- includeUnlisted: false,
177
- };
178
- expect(resumeBufferKey({ ...base, filter: { parentId: 'x' } })).not.toBe(
179
- resumeBufferKey({ ...base, filter: { entityId: 'x' } }),
180
- );
181
- });
182
- });
183
-
184
- describe('ResumeBufferRegistry', () => {
185
- function subscribeStub(unsubscribe: Unsubscribe = () => {}) {
186
- return vi.fn(async (_onChange: (c: RecordChange) => void) => unsubscribe);
187
- }
188
-
189
- it('opens a subscription once per key and reuses the same buffer on a second acquire', async () => {
190
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
191
- const subscribe = subscribeStub();
192
-
193
- const first = await registry.acquire('key', subscribe);
194
- const second = await registry.acquire('key', subscribe);
195
-
196
- expect(second).toBe(first);
197
- expect(subscribe).toHaveBeenCalledTimes(1);
198
- });
199
-
200
- it('feeds the buffer from the subscription handler', async () => {
201
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
202
- let handler!: (c: RecordChange) => void;
203
- const buffer = await registry.acquire('key', async (onChange) => {
204
- handler = onChange;
205
- return () => {};
206
- });
207
-
208
- handler(change({ recordId: 'live' }));
209
- expect(buffer.entriesAfter(0)).toEqual({
210
- status: 'ok',
211
- entries: [expect.objectContaining({ recordId: 'live' })],
212
- });
213
- });
214
-
215
- it('keeps the buffer retained across a release/reacquire within the retention window', async () => {
216
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
217
- const subscribe = subscribeStub();
218
- const first = await registry.acquire('key', subscribe);
219
- registry.release('key');
220
- const second = await registry.acquire('key', subscribe);
221
-
222
- expect(second).toBe(first);
223
- expect(subscribe).toHaveBeenCalledTimes(1);
224
- });
225
-
226
- it('evicts and unsubscribes a buffer once its retention window elapses with no live connections', async () => {
227
- vi.useFakeTimers();
228
- try {
229
- const unsubscribe = vi.fn();
230
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 1000 });
231
- const subscribe = subscribeStub(unsubscribe);
232
-
233
- const first = await registry.acquire('key', subscribe);
234
- registry.release('key');
235
- await vi.advanceTimersByTimeAsync(1001);
236
-
237
- expect(unsubscribe).toHaveBeenCalledTimes(1);
238
-
239
- const second = await registry.acquire('key', subscribe);
240
- expect(second).not.toBe(first);
241
- expect(subscribe).toHaveBeenCalledTimes(2);
242
- } finally {
243
- vi.useRealTimers();
244
- }
245
- });
246
-
247
- it('never evicts while a live connection still holds the buffer', async () => {
248
- vi.useFakeTimers();
249
- try {
250
- const unsubscribe = vi.fn();
251
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 1000 });
252
- const subscribe = subscribeStub(unsubscribe);
253
-
254
- await registry.acquire('key', subscribe); // liveCount 1
255
- await registry.acquire('key', subscribe); // liveCount 2
256
- registry.release('key'); // liveCount 1 — still live
257
- await vi.advanceTimersByTimeAsync(2000);
258
-
259
- expect(unsubscribe).not.toHaveBeenCalled();
260
- } finally {
261
- vi.useRealTimers();
262
- }
263
- });
264
-
265
- it('closeAll tears down every retained buffer immediately', async () => {
266
- const unsubscribeA = vi.fn();
267
- const unsubscribeB = vi.fn();
268
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
269
- await registry.acquire('a', subscribeStub(unsubscribeA));
270
- await registry.acquire('b', subscribeStub(unsubscribeB));
271
-
272
- registry.closeAll();
273
-
274
- expect(unsubscribeA).toHaveBeenCalledTimes(1);
275
- expect(unsubscribeB).toHaveBeenCalledTimes(1);
276
- });
277
-
278
- // Registering a buffer nothing is feeding would answer every later
279
- // connection on that key with `ready` and then permanent silence — a gap
280
- // with no `reset` to announce it.
281
- it('registers nothing when the subscription fails, and retries on the next acquire', async () => {
282
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
283
- const failing = vi.fn(async () => {
284
- throw new Error('adapter unavailable');
285
- });
286
-
287
- await expect(registry.acquire('k', failing)).rejects.toThrow('adapter unavailable');
288
-
289
- const unsubscribe = vi.fn();
290
- const healthy = subscribeStub(unsubscribe);
291
- const buffer = await registry.acquire('k', healthy);
292
-
293
- expect(healthy).toHaveBeenCalledTimes(1);
294
- expect(buffer.liveCount).toBe(1);
295
-
296
- // And the retried buffer is genuinely fed and genuinely releasable.
297
- registry.release('k');
298
- expect(buffer.liveCount).toBe(0);
299
- });
300
-
301
- it('opens one subscription for two acquires that race on the same key', async () => {
302
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
303
- let resolveSubscribe!: (u: Unsubscribe) => void;
304
- const subscribe = vi.fn(
305
- () =>
306
- new Promise<Unsubscribe>((resolve) => {
307
- resolveSubscribe = resolve;
308
- }),
309
- );
310
-
311
- const first = registry.acquire('k', subscribe);
312
- const second = registry.acquire('k', subscribe);
313
- resolveSubscribe(() => {});
314
- const [a, b] = await Promise.all([first, second]);
315
-
316
- expect(subscribe).toHaveBeenCalledTimes(1);
317
- expect(a).toBe(b);
318
- expect(a.liveCount).toBe(2);
319
- });
320
-
321
- it('fails both racing acquires when the shared subscription fails, then retries', async () => {
322
- const registry = new ResumeBufferRegistry({ depth: 10, retentionMs: 10_000 });
323
- let rejectSubscribe!: (err: Error) => void;
324
- const failing = vi.fn(
325
- () =>
326
- new Promise<Unsubscribe>((_resolve, reject) => {
327
- rejectSubscribe = reject;
328
- }),
329
- );
330
-
331
- const first = registry.acquire('k', failing);
332
- const second = registry.acquire('k', failing);
333
- rejectSubscribe(new Error('adapter unavailable'));
334
- await expect(first).rejects.toThrow('adapter unavailable');
335
- await expect(second).rejects.toThrow('adapter unavailable');
336
-
337
- const healthy = subscribeStub();
338
- await registry.acquire('k', healthy);
339
- expect(healthy).toHaveBeenCalledTimes(1);
340
- });
341
- });
@@ -1,46 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { isValidSeq } from '@haverstack/wire-types';
3
- import { encodeCursor, decodeCursor } from '../../src/lib/resumeCursor.js';
4
-
5
- describe('resume cursor codec', () => {
6
- it('round-trips a buffer id and position', () => {
7
- const seq = encodeCursor('a-buffer-id', 7);
8
- expect(decodeCursor(seq)).toEqual({ bufferId: 'a-buffer-id', n: 7 });
9
- });
10
-
11
- it('mints only base64url-charset cursors, whatever the buffer id contains', () => {
12
- const seq = encodeCursor('weird:id/with+chars=', 0);
13
- expect(isValidSeq(seq)).toBe(true);
14
- });
15
-
16
- it('distinguishes buffer ids and positions that would collide as plain strings', () => {
17
- // Without a delimiter distinct from digits, "buf1" + n=23 could be
18
- // confused with "buf12" + n=3 — real base64url encoding of the whole
19
- // "bufferId:n" string is what keeps them apart.
20
- const a = encodeCursor('buf1', 23);
21
- const b = encodeCursor('buf12', 3);
22
- expect(a).not.toBe(b);
23
- expect(decodeCursor(a)).toEqual({ bufferId: 'buf1', n: 23 });
24
- expect(decodeCursor(b)).toEqual({ bufferId: 'buf12', n: 3 });
25
- });
26
-
27
- it('returns null for a charset-valid cursor naming garbage', () => {
28
- // base64url of "not-a-cursor-at-all", which has no bufferId:n shape.
29
- expect(decodeCursor('bm90LWEtY3Vyc29yLWF0LWFsbA')).toBeNull();
30
- });
31
-
32
- it('returns null for a cursor whose position is not a plain non-negative integer', () => {
33
- // Directly construct "buf:-1" and "buf:1.5" — encodeCursor itself
34
- // never produces these, but decode must still refuse them rather than
35
- // parsing a negative or fractional position.
36
- const negative = Buffer.from('buf:-1').toString('base64url');
37
- const fractional = Buffer.from('buf:1.5').toString('base64url');
38
- expect(decodeCursor(negative)).toBeNull();
39
- expect(decodeCursor(fractional)).toBeNull();
40
- });
41
-
42
- it('returns null for an empty or non-base64url string', () => {
43
- expect(decodeCursor('')).toBeNull();
44
- expect(decodeCursor('not base64url!!')).toBeNull();
45
- });
46
- });
@@ -1,176 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import type { Logger } from 'pino';
3
- import { createApp } from '../../src/app.js';
4
- import {
5
- createTestContext,
6
- tempDbPath,
7
- testConfig,
8
- req,
9
- TEST_TOKEN,
10
- OTHER_ENTITY_ID,
11
- } from '../setup.js';
12
- import type { StackContext } from '../../src/stack.js';
13
- import { rm } from 'node:fs/promises';
14
- import { dirname } from 'node:path';
15
-
16
- function spyLogger(): Logger & {
17
- warn: ReturnType<typeof vi.fn>;
18
- debug: ReturnType<typeof vi.fn>;
19
- } {
20
- return {
21
- warn: vi.fn(),
22
- error: vi.fn(),
23
- info: vi.fn(),
24
- debug: vi.fn(),
25
- } as unknown as Logger & { warn: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
26
- }
27
-
28
- describe('errorMiddleware — denied-but-verified logging', () => {
29
- let dbPath: string;
30
- let ctx: StackContext;
31
-
32
- beforeEach(async () => {
33
- dbPath = tempDbPath();
34
- ctx = await createTestContext(dbPath);
35
- });
36
-
37
- afterEach(async () => {
38
- await ctx.queryWorker.close();
39
- await ctx.stack.close();
40
- await ctx.tokens.close();
41
- await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
42
- });
43
-
44
- it('logs the requester DID when a verified token is denied by permission', async () => {
45
- const { token } = await ctx.adapter.createToken(OTHER_ENTITY_ID);
46
- const warn = spyLogger();
47
- const app = createApp(ctx, testConfig(dbPath), warn);
48
-
49
- const { status } = await req(app, 'GET', '/tokens', { token });
50
- expect(status).toBe(403);
51
- expect(warn.warn).toHaveBeenCalledWith(
52
- expect.objectContaining({ principalId: OTHER_ENTITY_ID, subjectId: OTHER_ENTITY_ID }),
53
- expect.stringMatching(/denied/i),
54
- );
55
- });
56
-
57
- it('does not log a requester DID for an anonymous 401', async () => {
58
- const warn = spyLogger();
59
- const app = createApp(ctx, testConfig(dbPath), warn);
60
-
61
- const { status } = await req(app, 'GET', '/tokens');
62
- expect(status).toBe(401);
63
- expect(warn.warn).not.toHaveBeenCalled();
64
- });
65
-
66
- it('does not log for the owner token itself', async () => {
67
- const warn = spyLogger();
68
- const app = createApp(ctx, testConfig(dbPath), warn);
69
-
70
- const { status } = await req(app, 'GET', '/tokens', { token: TEST_TOKEN });
71
- expect(status).toBe(200);
72
- expect(warn.warn).not.toHaveBeenCalled();
73
- });
74
- });
75
-
76
- const NOTE_TYPE_ID = 'com.example.test/note@1';
77
-
78
- describe('errorMiddleware — refusal logging (StackNotFoundError)', () => {
79
- let dbPath: string;
80
- let ctx: StackContext;
81
-
82
- beforeEach(async () => {
83
- dbPath = tempDbPath();
84
- ctx = await createTestContext(dbPath);
85
- await ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
86
- body: { kind: 'text' as const, required: true as const },
87
- });
88
- });
89
-
90
- afterEach(async () => {
91
- await ctx.queryWorker.close();
92
- await ctx.stack.close();
93
- await ctx.tokens.close();
94
- await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
95
- });
96
-
97
- it('logs existed: true for a verified requester denied read on an existing record', async () => {
98
- const record = await ctx.stack.create(NOTE_TYPE_ID, { body: 'private' });
99
- const { token } = await ctx.adapter.createToken(OTHER_ENTITY_ID);
100
- const debug = spyLogger();
101
- const app = createApp(ctx, testConfig(dbPath), debug);
102
-
103
- const { status } = await req(app, 'GET', `/records/${record.id}`, { token });
104
- expect(status).toBe(404);
105
- expect(debug.debug).toHaveBeenCalledWith(
106
- expect.objectContaining({
107
- principalId: OTHER_ENTITY_ID,
108
- subjectId: OTHER_ENTITY_ID,
109
- recordId: record.id,
110
- existed: true,
111
- check: 'read',
112
- }),
113
- expect.stringMatching(/refus/i),
114
- );
115
- });
116
-
117
- it('logs existed: false for a verified requester on a genuinely missing record', async () => {
118
- const { token } = await ctx.adapter.createToken(OTHER_ENTITY_ID);
119
- const debug = spyLogger();
120
- const app = createApp(ctx, testConfig(dbPath), debug);
121
-
122
- const { status } = await req(app, 'GET', '/records/nonexistent', { token });
123
- expect(status).toBe(404);
124
- expect(debug.debug).toHaveBeenCalledWith(
125
- expect.objectContaining({
126
- principalId: OTHER_ENTITY_ID,
127
- subjectId: OTHER_ENTITY_ID,
128
- recordId: 'nonexistent',
129
- existed: false,
130
- check: 'read',
131
- }),
132
- expect.stringMatching(/refus/i),
133
- );
134
- });
135
-
136
- it('does not log for an anonymous 404 on a private record', async () => {
137
- const record = await ctx.stack.create(NOTE_TYPE_ID, { body: 'private' });
138
- const debug = spyLogger();
139
- const app = createApp(ctx, testConfig(dbPath), debug);
140
-
141
- const { status } = await req(app, 'GET', `/records/${record.id}`);
142
- expect(status).toBe(404);
143
- expect(debug.debug).not.toHaveBeenCalled();
144
- });
145
- });
146
-
147
- describe('errorMiddleware — malformed JSON bodies', () => {
148
- let dbPath: string;
149
- let ctx: StackContext;
150
-
151
- beforeEach(async () => {
152
- dbPath = tempDbPath();
153
- ctx = await createTestContext(dbPath);
154
- });
155
-
156
- afterEach(async () => {
157
- await ctx.queryWorker.close();
158
- await ctx.stack.close();
159
- await ctx.tokens.close();
160
- await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
161
- });
162
-
163
- it('returns 400 bad_request rather than a bare 500', async () => {
164
- const app = createApp(ctx, testConfig(dbPath), spyLogger());
165
-
166
- const res = await app.request('/records', {
167
- method: 'POST',
168
- headers: { 'content-type': 'application/json', authorization: `Bearer ${TEST_TOKEN}` },
169
- body: '{ this is not json',
170
- });
171
-
172
- expect(res.status).toBe(400);
173
- const body = (await res.json()) as { error: { code: string } };
174
- expect(body.error.code).toBe('bad_request');
175
- });
176
- });