@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,567 +0,0 @@
1
- import { Hono } from 'hono';
2
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
- import {
4
- buildTestApp,
5
- req,
6
- createTestContext,
7
- testConfig,
8
- tempDbPath,
9
- TEST_TOKEN,
10
- logger,
11
- type TestApp,
12
- } from '../setup.js';
13
- import { changeRoutes, type ChangeRouteOptions } from '../../src/routes/changes.js';
14
- import { authMiddleware } from '../../src/middleware/auth.js';
15
- import { openChangeFeed } from '../changeFeedClient.js';
16
- import type { StackContext } from '../../src/stack.js';
17
- import type { Config } from '../../src/config.js';
18
- import type { AppEnv } from '../../src/types.js';
19
-
20
- const NOTE_TYPE = 'com.example/note@1';
21
- const CONTRIBUTOR_ID = 'entity-contributor-789';
22
-
23
- /**
24
- * changeRoutes() alone doesn't run authMiddleware — that's wired up by
25
- * createApp(), which these tests bypass so they can pass ChangeRouteOptions
26
- * (keepaliveMs, sessionCheckMs, maxPendingFrames) that createApp() has no
27
- * way to thread through. Without it, c.get('auth') would stay undefined
28
- * for every request here regardless of the header sent, silently falling
29
- * back to the anonymous view.
30
- */
31
- function testChangesApp(ctx: StackContext, config: Config, opts: ChangeRouteOptions = {}) {
32
- const app = new Hono<AppEnv>();
33
- app.use(authMiddleware(config.ownerToken, ctx));
34
- app.route('/', changeRoutes(ctx, config, logger, opts));
35
- return app;
36
- }
37
-
38
- describe('GET /changes', () => {
39
- let t: TestApp;
40
- beforeEach(async () => {
41
- t = await buildTestApp();
42
- await t.ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } });
43
- });
44
- afterEach(async () => {
45
- await t.cleanup();
46
- });
47
-
48
- describe('query param validation', () => {
49
- it('rejects an unrecognized kind before streaming starts', async () => {
50
- const { status, data } = await req(t.app, 'GET', '/changes?kind=bogus', {
51
- token: TEST_TOKEN,
52
- });
53
- expect(status).toBe(400);
54
- expect((data as { error: { code: string } }).error.code).toBeDefined();
55
- });
56
-
57
- it('rejects an unrecognized include value', async () => {
58
- const { status } = await req(t.app, 'GET', '/changes?include=everything', {
59
- token: TEST_TOKEN,
60
- });
61
- expect(status).toBe(400);
62
- });
63
-
64
- it('rejects a charset-invalid cursor locally, as a 400, rather than as a reset frame', async () => {
65
- // isValidSeq() only ever allows base64url — a space is never in that
66
- // alphabet. Refused before the SSE stream even opens, not treated as
67
- // a resumable-but-unrecognized cursor.
68
- const { status, data } = await req(t.app, 'GET', '/changes', {
69
- token: TEST_TOKEN,
70
- headers: { 'Last-Event-ID': 'not a valid cursor' },
71
- });
72
- expect(status).toBe(400);
73
- expect((data as { error: { code: string } }).error.code).toBeDefined();
74
- });
75
- });
76
-
77
- it('never honors a bearer token passed as a query parameter', async () => {
78
- // A private, owner-only record. A connection presenting the owner's
79
- // token via ?token= rather than Authorization must still be treated as
80
- // anonymous — it must not see this change.
81
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'private' });
82
- const conn = await openChangeFeed(t.app, `/changes?token=${TEST_TOKEN}`);
83
- try {
84
- await conn.waitForFrames(1); // ready
85
- await req(t.app, 'PATCH', `/records/${record.id}`, {
86
- token: TEST_TOKEN,
87
- body: { contentPatch: { title: 'still private' } },
88
- });
89
- // Prove the connection is otherwise live (so a missing frame above
90
- // isn't just a dead connection) by making a public edit land.
91
- const publicRecord = await t.ctx.stack.create(
92
- NOTE_TYPE,
93
- { title: 'public' },
94
- { permissions: [{ access: 'public' }] },
95
- );
96
- await req(t.app, 'PATCH', `/records/${publicRecord.id}`, {
97
- token: TEST_TOKEN,
98
- body: { contentPatch: { title: 'still public' } },
99
- });
100
- const [, frame] = await conn.waitForFrames(2);
101
- expect((frame.data as { recordId: string }).recordId).toBe(publicRecord.id);
102
- } finally {
103
- await conn.close();
104
- }
105
- });
106
-
107
- it('sends periodic keepalive comments on an otherwise-idle connection', async () => {
108
- const dbPath = tempDbPath();
109
- const ctx = await createTestContext(dbPath);
110
- await ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } });
111
- const config = testConfig(dbPath);
112
- const app = testChangesApp(ctx, config, { keepaliveMs: 20, sessionCheckMs: 60_000 });
113
- try {
114
- const res = await app.request('/', {
115
- headers: { Accept: 'text/event-stream', Authorization: `Bearer ${TEST_TOKEN}` },
116
- });
117
- expect(res.status).toBe(200);
118
- const reader = res.body!.getReader();
119
- const decoder = new TextDecoder();
120
- let text = '';
121
- const start = Date.now();
122
- while (!text.includes(': keepalive') && Date.now() - start < 2000) {
123
- const { done, value } = await reader.read();
124
- if (done) break;
125
- text += decoder.decode(value, { stream: true });
126
- }
127
- expect(text).toContain(': keepalive');
128
- await reader.cancel().catch(() => {});
129
- } finally {
130
- await ctx.queryWorker.close();
131
- await ctx.stack.close();
132
- await ctx.tokens.close();
133
- ctx.nonces.close();
134
- }
135
- });
136
-
137
- it('closes the connection once a revoked token is re-checked', async () => {
138
- const dbPath = tempDbPath();
139
- const ctx = await createTestContext(dbPath);
140
- await ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } });
141
- const config = testConfig(dbPath);
142
- const app = testChangesApp(ctx, config, { sessionCheckMs: 20, keepaliveMs: 60_000 });
143
- const { id, token } = await ctx.adapter.createToken(CONTRIBUTOR_ID);
144
- try {
145
- const res = await app.request('/', {
146
- headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}` },
147
- });
148
- expect(res.status).toBe(200);
149
- await ctx.tokens.revokeToken(id);
150
-
151
- const reader = res.body!.getReader();
152
- const drained = await Promise.race([
153
- (async () => {
154
- while (true) {
155
- const { done } = await reader.read();
156
- if (done) return true;
157
- }
158
- })(),
159
- new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)),
160
- ]);
161
- expect(drained).toBe(true);
162
- } finally {
163
- await ctx.queryWorker.close();
164
- await ctx.stack.close();
165
- await ctx.tokens.close();
166
- ctx.nonces.close();
167
- }
168
- });
169
-
170
- // Buffer-overflow behavior (close rather than queue indefinitely on a
171
- // slow/stalled client) is covered by tests/lib/frameGate.test.ts against
172
- // FrameGate directly, with deterministic control over when a "write"
173
- // settles. An HTTP-level version of that test isn't reliable here: this
174
- // test app's Response/ReadableStream plumbing appears to drain writes
175
- // internally regardless of whether the test itself ever reads the body,
176
- // so the backpressure an integration test would need to simulate a
177
- // stalled client never actually reaches the route.
178
-
179
- describe('resume (#84)', () => {
180
- // The changeFeedSequenceFixtures dispatch in tests/conformance.test.ts
181
- // covers the documented resume/reset contract itself (what a missed
182
- // change and an expired cursor look like on the wire). These cover
183
- // scenarios the shared fixtures don't reach: a grant revoked during the
184
- // gap, a purge in the gap (not re-checkable, by design), overflow, and
185
- // a cursor whose buffer doesn't match the reconnect's own filter.
186
-
187
- it('drops a backlog frame whose grant was revoked during the gap, but still replays an unrelated one', async () => {
188
- const recordA = await t.ctx.stack.create(
189
- NOTE_TYPE,
190
- { title: 'a' },
191
- { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
192
- );
193
- const recordB = await t.ctx.stack.create(
194
- NOTE_TYPE,
195
- { title: 'b' },
196
- { permissions: [{ access: 'entity', entityId: CONTRIBUTOR_ID, read: true, write: false }] },
197
- );
198
- const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
199
-
200
- const conn = await openChangeFeed(t.app, '/changes', { token });
201
- let cursor: string;
202
- try {
203
- const [ready] = await conn.waitForFrames(1);
204
- cursor = (ready.data as { seq: string }).seq;
205
- } finally {
206
- await conn.close();
207
- }
208
-
209
- // While disconnected: edit recordA (still readable at that moment,
210
- // so it's buffered), then revoke recordA's grant, then edit recordB
211
- // (still readable throughout).
212
- await t.ctx.stack.patchContent(recordA.id, { title: 'a, edited' });
213
- await t.ctx.stack.mutate(recordA.id, { permissions: [] });
214
- await t.ctx.stack.patchContent(recordB.id, { title: 'b, edited' });
215
-
216
- const resumed = await openChangeFeed(t.app, '/changes', {
217
- token,
218
- headers: { 'Last-Event-ID': cursor },
219
- });
220
- try {
221
- const [ready, frame] = await resumed.waitForFrames(2);
222
- expect(ready.event).toBe('ready');
223
- expect(frame.event).toBe('record');
224
- // recordA's edit is missing from the replay — only recordB's
225
- // arrives — proving it was dropped rather than merely reordered.
226
- expect((frame.data as { recordId: string }).recordId).toBe(recordB.id);
227
- await expect(resumed.waitForFrames(3, 300)).rejects.toThrow();
228
- } finally {
229
- await resumed.close();
230
- }
231
- });
232
-
233
- it('replays a purge from the gap unconditionally — not re-checkable, so never dropped', async () => {
234
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'to be purged' });
235
-
236
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
237
- let cursor: string;
238
- try {
239
- const [ready] = await conn.waitForFrames(1);
240
- cursor = (ready.data as { seq: string }).seq;
241
- } finally {
242
- await conn.close();
243
- }
244
-
245
- await req(t.app, 'DELETE', `/records/${record.id}?hard=true`, { token: TEST_TOKEN });
246
-
247
- const resumed = await openChangeFeed(t.app, '/changes', {
248
- token: TEST_TOKEN,
249
- headers: { 'Last-Event-ID': cursor },
250
- });
251
- try {
252
- const [, frame] = await resumed.waitForFrames(2);
253
- const data = frame.data as Record<string, unknown>;
254
- expect(data.kind).toBe('purged');
255
- expect(data.recordId).toBe(record.id);
256
- } finally {
257
- await resumed.close();
258
- }
259
- });
260
-
261
- it('answers overflow once depth eviction has dropped what a reconnect would need', async () => {
262
- const app = testChangesApp(t.ctx, testConfig(t.dbPath), { resumeBufferDepth: 2 });
263
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'original' });
264
-
265
- const conn = await openChangeFeed(app, '/', { token: TEST_TOKEN });
266
- let cursor: string;
267
- try {
268
- const [ready] = await conn.waitForFrames(1);
269
- cursor = (ready.data as { seq: string }).seq;
270
- } finally {
271
- await conn.close();
272
- }
273
-
274
- // Three edits while disconnected against a depth-2 buffer: the first
275
- // is evicted before the reconnect ever asks for it. The buffer's own
276
- // subscription delivers asynchronously (core serializes a scoped
277
- // delivery through its own permission-check chain), so give each
278
- // append a moment to land before relying on the eviction it causes.
279
- for (const title of ['v2', 'v3', 'v4']) {
280
- await t.ctx.stack.patchContent(record.id, { title });
281
- await new Promise((resolve) => setTimeout(resolve, 20));
282
- }
283
-
284
- const resumed = await openChangeFeed(app, '/', {
285
- token: TEST_TOKEN,
286
- headers: { 'Last-Event-ID': cursor },
287
- });
288
- try {
289
- const [, reset] = await resumed.waitForFrames(2);
290
- expect(reset.event).toBe('reset');
291
- expect((reset.data as { reason: string }).reason).toBe('overflow');
292
- } finally {
293
- await resumed.close();
294
- }
295
- });
296
-
297
- it('resets rather than resumes when a cursor names a buffer for a different filter', async () => {
298
- const conn = await openChangeFeed(t.app, `/changes?typeId=${encodeURIComponent(NOTE_TYPE)}`, {
299
- token: TEST_TOKEN,
300
- });
301
- let cursor: string;
302
- try {
303
- const [ready] = await conn.waitForFrames(1);
304
- cursor = (ready.data as { seq: string }).seq;
305
- } finally {
306
- await conn.close();
307
- }
308
-
309
- // Same session, same cursor value, but no typeId filter this time —
310
- // a different key, so a different buffer. The cursor's embedded
311
- // buffer id can't match it.
312
- const resumed = await openChangeFeed(t.app, '/changes', {
313
- token: TEST_TOKEN,
314
- headers: { 'Last-Event-ID': cursor },
315
- });
316
- try {
317
- const [, reset] = await resumed.waitForFrames(2);
318
- expect(reset.event).toBe('reset');
319
- expect((reset.data as { reason: string }).reason).toBe('cursor_expired');
320
- } finally {
321
- await resumed.close();
322
- }
323
- });
324
- });
325
-
326
- // Two connections sharing a resume buffer share its single subscription,
327
- // and so its single filter. `?parentId=null` ("root records only") and no
328
- // parentId filter at all mean different things and must never share one:
329
- // whichever connected first would otherwise decide what the other
330
- // receives — a silent gap in one direction, frames outside the filter in
331
- // the other, and no `reset` either way to announce it.
332
- describe('buffer keying across distinct filters', () => {
333
- async function childWrite(): Promise<string> {
334
- const parent = await t.ctx.stack.create(NOTE_TYPE, { title: 'parent' });
335
- const child = await t.ctx.stack.create(
336
- NOTE_TYPE,
337
- { title: 'child' },
338
- { parentId: parent.id },
339
- );
340
- return child.id;
341
- }
342
-
343
- it('delivers a child change to an unfiltered connection opened after a ?parentId=null one', async () => {
344
- const rootsOnly = await openChangeFeed(t.app, '/changes?parentId=null', {
345
- token: TEST_TOKEN,
346
- });
347
- await rootsOnly.waitForFrames(1);
348
- const unfiltered = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
349
- await unfiltered.waitForFrames(1);
350
-
351
- try {
352
- const childId = await childWrite();
353
- const frames = await unfiltered.waitForFrames(3);
354
- const child = frames.find(
355
- (f) => f.event === 'record' && (f.data as { recordId: string }).recordId === childId,
356
- );
357
- expect(child).toBeDefined();
358
- expect(unfiltered.frames.some((f) => f.event === 'reset')).toBe(false);
359
- } finally {
360
- await unfiltered.close();
361
- await rootsOnly.close();
362
- }
363
- });
364
-
365
- it('withholds a child change from a ?parentId=null connection opened after an unfiltered one', async () => {
366
- const unfiltered = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
367
- await unfiltered.waitForFrames(1);
368
- const rootsOnly = await openChangeFeed(t.app, '/changes?parentId=null', {
369
- token: TEST_TOKEN,
370
- });
371
- await rootsOnly.waitForFrames(1);
372
-
373
- try {
374
- const childId = await childWrite();
375
- // The parent is a root record, so it does arrive — waiting on it
376
- // proves the connection is live and that the child's absence below
377
- // is a filter decision rather than a race.
378
- await rootsOnly.waitForFrames(2);
379
- const leaked = rootsOnly.frames.filter(
380
- (f) => f.event === 'record' && (f.data as { recordId: string }).recordId === childId,
381
- );
382
- expect(leaked).toHaveLength(0);
383
- } finally {
384
- await rootsOnly.close();
385
- await unfiltered.close();
386
- }
387
- });
388
- });
389
-
390
- it('closes the connection when the token store cannot answer a session re-check', async () => {
391
- const dbPath = tempDbPath();
392
- const ctx = await createTestContext(dbPath);
393
- await ctx.stack.defineType(NOTE_TYPE, 'Note', { title: { kind: 'string' } });
394
- const config = testConfig(dbPath);
395
- const { token } = await ctx.adapter.createToken(CONTRIBUTOR_ID);
396
- const app = testChangesApp(ctx, config, { sessionCheckMs: 20, keepaliveMs: 60_000 });
397
-
398
- // The store is reachable at connect (auth succeeds) and unreachable by
399
- // the time the re-check runs. Closing is the honest answer: the client
400
- // reconnects through the path that already handles a 401. Left
401
- // unhandled, the rejection would take the process down instead.
402
- const lookupToken = ctx.tokens.lookupToken.bind(ctx.tokens);
403
- let authenticated = false;
404
- ctx.tokens.lookupToken = async (t: string) => {
405
- if (authenticated) throw new Error('token store unavailable');
406
- authenticated = true;
407
- return lookupToken(t);
408
- };
409
-
410
- try {
411
- const res = await app.request('/', {
412
- headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}` },
413
- });
414
- expect(res.status).toBe(200);
415
-
416
- const reader = res.body!.getReader();
417
- const drained = await Promise.race([
418
- (async () => {
419
- while (true) {
420
- const { done } = await reader.read();
421
- if (done) return true;
422
- }
423
- })(),
424
- new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 2000)),
425
- ]);
426
- expect(drained).toBe(true);
427
- } finally {
428
- ctx.tokens.lookupToken = lookupToken;
429
- await ctx.queryWorker.close();
430
- await ctx.stack.close();
431
- await ctx.tokens.close();
432
- ctx.nonces.close();
433
- }
434
- });
435
-
436
- describe('includeUnlisted', () => {
437
- it('refuses a non-owner with 403 before the SSE stream opens', async () => {
438
- const { token } = await t.ctx.adapter.createToken(CONTRIBUTOR_ID);
439
- const { status } = await req(t.app, 'GET', '/changes?includeUnlisted=true', { token });
440
- expect(status).toBe(403);
441
- });
442
-
443
- it('refuses an anonymous connection with 403', async () => {
444
- const { status } = await req(t.app, 'GET', '/changes?includeUnlisted=true');
445
- expect(status).toBe(403);
446
- });
447
-
448
- it('is honored for the owner acting alone', async () => {
449
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { unlisted: true });
450
- const conn = await openChangeFeed(t.app, '/changes?includeUnlisted=true', {
451
- token: TEST_TOKEN,
452
- });
453
- try {
454
- await conn.waitForFrames(1); // ready
455
- await t.ctx.stack.patchContent(record.id, { title: 'y' });
456
- const [, frame] = await conn.waitForFrames(2);
457
- expect((frame.data as { recordId: string }).recordId).toBe(record.id);
458
- } finally {
459
- await conn.close();
460
- }
461
- });
462
- });
463
-
464
- // docs/spec/events.md § The unlisted transition — pinned row by row. Each
465
- // "emits nothing" case proves the connection is still live by making an
466
- // unrelated public change land afterward (same technique as "never honors
467
- // a bearer token passed as a query parameter" above), so a missing frame
468
- // means the transition was excluded, not that the connection died.
469
- describe('the unlisted transition table (default subscriber, no includeUnlisted)', () => {
470
- it('created unlisted emits nothing', async () => {
471
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
472
- try {
473
- await conn.waitForFrames(1); // ready
474
- await t.ctx.stack.create(NOTE_TYPE, { title: 'secret' }, { unlisted: true });
475
- const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' });
476
- const [, frame] = await conn.waitForFrames(2);
477
- expect((frame.data as { recordId: string }).recordId).toBe(proof.id);
478
- } finally {
479
- await conn.close();
480
- }
481
- });
482
-
483
- it('POST /records with unlistedAt in the body creates unlisted and emits nothing', async () => {
484
- // Same transition as "created unlisted emits nothing" above, but
485
- // through the actual HTTP route with unlistedAt in the body rather
486
- // than stack.create({ unlisted: true }) directly — pins that the
487
- // route really forwards it end to end.
488
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
489
- try {
490
- await conn.waitForFrames(1); // ready
491
- await req(t.app, 'POST', '/records', {
492
- token: TEST_TOKEN,
493
- body: {
494
- typeId: NOTE_TYPE,
495
- content: { title: 'secret' },
496
- unlistedAt: new Date().toISOString(),
497
- },
498
- });
499
- const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' });
500
- const [, frame] = await conn.waitForFrames(2);
501
- expect((frame.data as { recordId: string }).recordId).toBe(proof.id);
502
- } finally {
503
- await conn.close();
504
- }
505
- });
506
-
507
- it('listed -> unlisted emits a deleted/unlist frame', async () => {
508
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' });
509
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
510
- try {
511
- await conn.waitForFrames(1); // ready
512
- await t.ctx.stack.mutate(record.id, { unlisted: true });
513
- const [, frame] = await conn.waitForFrames(2);
514
- const data = frame.data as { kind: string; ops: string[]; recordId: string };
515
- expect(data.recordId).toBe(record.id);
516
- expect(data.kind).toBe('deleted');
517
- expect(data.ops).toEqual(['unlist']);
518
- } finally {
519
- await conn.close();
520
- }
521
- });
522
-
523
- it('an ordinary edit while unlisted emits nothing', async () => {
524
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { unlisted: true });
525
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
526
- try {
527
- await conn.waitForFrames(1); // ready
528
- await t.ctx.stack.patchContent(record.id, { title: 'edited' });
529
- const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' });
530
- const [, frame] = await conn.waitForFrames(2);
531
- expect((frame.data as { recordId: string }).recordId).toBe(proof.id);
532
- } finally {
533
- await conn.close();
534
- }
535
- });
536
-
537
- it('unlisted -> listed emits a changed/list frame', async () => {
538
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { unlisted: true });
539
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
540
- try {
541
- await conn.waitForFrames(1); // ready
542
- await t.ctx.stack.mutate(record.id, { unlisted: false });
543
- const [, frame] = await conn.waitForFrames(2);
544
- const data = frame.data as { kind: string; ops: string[]; recordId: string };
545
- expect(data.recordId).toBe(record.id);
546
- expect(data.kind).toBe('changed');
547
- expect(data.ops).toEqual(['list']);
548
- } finally {
549
- await conn.close();
550
- }
551
- });
552
-
553
- it('hard delete while unlisted emits nothing', async () => {
554
- const record = await t.ctx.stack.create(NOTE_TYPE, { title: 'x' }, { unlisted: true });
555
- const conn = await openChangeFeed(t.app, '/changes', { token: TEST_TOKEN });
556
- try {
557
- await conn.waitForFrames(1); // ready
558
- await t.ctx.stack.delete(record.id, { hard: true });
559
- const proof = await t.ctx.stack.create(NOTE_TYPE, { title: 'public' });
560
- const [, frame] = await conn.waitForFrames(2);
561
- expect((frame.data as { recordId: string }).recordId).toBe(proof.id);
562
- } finally {
563
- await conn.close();
564
- }
565
- });
566
- });
567
- });
@@ -1,68 +0,0 @@
1
- /**
2
- * Content filters and search over this server's query routes: the sqlite
3
- * adapter's path-reach content filtering, and FTS5 full-text search,
4
- * pinned at the HTTP surface rather than assumed to arrive intact.
5
- */
6
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
- import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js';
8
-
9
- const NOTE_TYPE = 'com.example.test/note@1';
10
- const CONTACT_TYPE = 'com.example.test/contact@1';
11
-
12
- describe('nested content query', () => {
13
- let t: TestApp;
14
- beforeEach(async () => {
15
- t = await buildTestApp();
16
- await t.ctx.stack.defineType(CONTACT_TYPE, 'Contact', {
17
- profile: { kind: 'object', properties: { email: { kind: 'string' } } },
18
- });
19
- });
20
- afterEach(async () => {
21
- await t.cleanup();
22
- });
23
-
24
- it('a multi-segment content filter key matches a nested field', async () => {
25
- const matching = await t.ctx.stack.create(CONTACT_TYPE, {
26
- profile: { email: 'a@example.com' },
27
- });
28
- await t.ctx.stack.create(CONTACT_TYPE, { profile: { email: 'b@example.com' } });
29
- const { status, data } = await req(t.app, 'POST', '/records/query', {
30
- token: TEST_TOKEN,
31
- body: {
32
- filter: { typeId: CONTACT_TYPE, content: { 'profile.email': 'a@example.com' } },
33
- },
34
- });
35
- expect(status).toBe(200);
36
- const d = data as { records: Array<{ id: string }> };
37
- expect(d.records.map((r) => r.id)).toEqual([matching.id]);
38
- });
39
- });
40
-
41
- describe('full-text search', () => {
42
- let t: TestApp;
43
- beforeEach(async () => {
44
- t = await buildTestApp();
45
- await t.ctx.stack.defineType(NOTE_TYPE, 'Note', {
46
- title: { kind: 'string' },
47
- body: { kind: 'text' },
48
- });
49
- });
50
- afterEach(async () => {
51
- await t.cleanup();
52
- });
53
-
54
- it('filter.search finds the record containing the term and excludes others', async () => {
55
- const matching = await t.ctx.stack.create(NOTE_TYPE, {
56
- title: 'x',
57
- body: 'the quick brown fox jumps',
58
- });
59
- await t.ctx.stack.create(NOTE_TYPE, { title: 'y', body: 'lorem ipsum dolor sit amet' });
60
- const { status, data } = await req(t.app, 'POST', '/records/query', {
61
- token: TEST_TOKEN,
62
- body: { filter: { typeId: NOTE_TYPE, search: 'fox' } },
63
- });
64
- expect(status).toBe(200);
65
- const d = data as { records: Array<{ id: string }> };
66
- expect(d.records.map((r) => r.id)).toEqual([matching.id]);
67
- });
68
- });