@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.
- package/dist/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +70 -0
- package/dist/app.js.map +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +115 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changeStreams.d.ts +17 -0
- package/dist/lib/changeStreams.d.ts.map +1 -0
- package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
- package/dist/lib/changeStreams.js.map +1 -0
- package/dist/lib/frameGate.d.ts +26 -0
- package/dist/lib/frameGate.d.ts.map +1 -0
- package/dist/lib/frameGate.js +44 -0
- package/dist/lib/frameGate.js.map +1 -0
- package/dist/lib/json.d.ts +10 -0
- package/dist/lib/json.d.ts.map +1 -0
- package/dist/lib/json.js +27 -0
- package/dist/lib/json.js.map +1 -0
- package/dist/lib/nonceStore.d.ts +50 -0
- package/dist/lib/nonceStore.d.ts.map +1 -0
- package/dist/lib/nonceStore.js +96 -0
- package/dist/lib/nonceStore.js.map +1 -0
- package/dist/lib/queryLimit.d.ts +4 -0
- package/dist/lib/queryLimit.d.ts.map +1 -0
- package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
- package/dist/lib/queryLimit.js.map +1 -0
- package/dist/lib/queryWorker/pool.d.ts +60 -0
- package/dist/lib/queryWorker/pool.d.ts.map +1 -0
- package/dist/lib/queryWorker/pool.js +278 -0
- package/dist/lib/queryWorker/pool.js.map +1 -0
- package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
- package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
- package/dist/lib/queryWorker/protocol.js +2 -0
- package/dist/lib/queryWorker/protocol.js.map +1 -0
- package/dist/lib/queryWorker/worker.d.ts +2 -0
- package/dist/lib/queryWorker/worker.d.ts.map +1 -0
- package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
- package/dist/lib/queryWorker/worker.js.map +1 -0
- package/dist/lib/resumeBuffer.d.ts +143 -0
- package/dist/lib/resumeBuffer.d.ts.map +1 -0
- package/dist/lib/resumeBuffer.js +216 -0
- package/dist/lib/resumeBuffer.js.map +1 -0
- package/dist/lib/resumeCursor.d.ts +16 -0
- package/dist/lib/resumeCursor.d.ts.map +1 -0
- package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
- package/dist/lib/resumeCursor.js.map +1 -0
- package/dist/lib/tokenSweep.d.ts +17 -0
- package/dist/lib/tokenSweep.d.ts.map +1 -0
- package/dist/lib/tokenSweep.js +40 -0
- package/dist/lib/tokenSweep.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/middleware/auth.d.ts +25 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +74 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/errors.d.ts +17 -0
- package/dist/middleware/errors.d.ts.map +1 -0
- package/dist/middleware/errors.js +64 -0
- package/dist/middleware/errors.js.map +1 -0
- package/dist/routes/attachments.d.ts +5 -0
- package/dist/routes/attachments.d.ts.map +1 -0
- package/dist/routes/attachments.js +108 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +110 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/changes.d.ts +22 -0
- package/dist/routes/changes.d.ts.map +1 -0
- package/dist/routes/changes.js +233 -0
- package/dist/routes/changes.js.map +1 -0
- package/dist/routes/entity.d.ts +5 -0
- package/dist/routes/entity.d.ts.map +1 -0
- package/dist/routes/entity.js +30 -0
- package/dist/routes/entity.js.map +1 -0
- package/dist/routes/health.d.ts +4 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +7 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/records.d.ts +5 -0
- package/dist/routes/records.d.ts.map +1 -0
- package/dist/routes/records.js +231 -0
- package/dist/routes/records.js.map +1 -0
- package/dist/routes/tokens.d.ts +5 -0
- package/dist/routes/tokens.d.ts.map +1 -0
- package/dist/routes/tokens.js +65 -0
- package/dist/routes/tokens.js.map +1 -0
- package/dist/routes/types.d.ts +5 -0
- package/dist/routes/types.d.ts.map +1 -0
- package/dist/routes/types.js +46 -0
- package/dist/routes/types.js.map +1 -0
- package/dist/routes/wellknown.d.ts +6 -0
- package/dist/routes/wellknown.d.ts.map +1 -0
- package/dist/routes/wellknown.js +45 -0
- package/dist/routes/wellknown.js.map +1 -0
- package/dist/shutdown.d.ts +23 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +32 -0
- package/dist/shutdown.js.map +1 -0
- package/dist/stack.d.ts +20 -0
- package/dist/stack.d.ts.map +1 -0
- package/dist/stack.js +50 -0
- package/dist/stack.js.map +1 -0
- package/dist/testing.d.ts +49 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +130 -0
- package/dist/testing.js.map +1 -0
- package/{src/types.ts → dist/types.d.ts} +5 -5
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
- package/dist/wireError.d.ts.map +1 -0
- package/dist/wireError.js +13 -0
- package/dist/wireError.js.map +1 -0
- package/package.json +42 -17
- package/.claude/settings.local.json +0 -22
- package/.dockerignore +0 -10
- package/.env.example +0 -90
- package/.github/pull_request_template.md +0 -23
- package/.github/workflows/ci.yml +0 -55
- package/.github/workflows/release.yml +0 -99
- package/.prettierignore +0 -3
- package/.prettierrc +0 -7
- package/AGENTS.md +0 -91
- package/CONTRIBUTING.md +0 -234
- package/Dockerfile +0 -30
- package/SECURITY.md +0 -54
- package/docs/api.md +0 -277
- package/docs/deployment.md +0 -212
- package/eslint.config.mjs +0 -18
- package/pnpm-workspace.yaml +0 -13
- package/src/app.ts +0 -86
- package/src/config.ts +0 -176
- package/src/index.ts +0 -54
- package/src/lib/frameGate.ts +0 -42
- package/src/lib/json.ts +0 -27
- package/src/lib/nonceStore.ts +0 -103
- package/src/lib/queryWorker/pool.ts +0 -324
- package/src/lib/resumeBuffer.ts +0 -268
- package/src/lib/tokenSweep.ts +0 -43
- package/src/middleware/auth.ts +0 -77
- package/src/middleware/errors.ts +0 -73
- package/src/routes/attachments.ts +0 -126
- package/src/routes/auth.ts +0 -137
- package/src/routes/changes.ts +0 -279
- package/src/routes/entity.ts +0 -33
- package/src/routes/health.ts +0 -8
- package/src/routes/records.ts +0 -259
- package/src/routes/tokens.ts +0 -85
- package/src/routes/types.ts +0 -57
- package/src/routes/wellknown.ts +0 -55
- package/src/shutdown.ts +0 -56
- package/src/stack.ts +0 -96
- package/tests/app.test.ts +0 -86
- package/tests/changeFeedClient.ts +0 -156
- package/tests/config.test.ts +0 -112
- package/tests/conformance.test.ts +0 -2322
- package/tests/fixtures/blockingWorker.ts +0 -27
- package/tests/fixtures/brokenWorker.ts +0 -7
- package/tests/lib/changeFeedClient.test.ts +0 -126
- package/tests/lib/frameGate.test.ts +0 -93
- package/tests/lib/queryLimit.test.ts +0 -33
- package/tests/lib/queryWorker.test.ts +0 -60
- package/tests/lib/queryWorkerPool.test.ts +0 -129
- package/tests/lib/resumeBuffer.test.ts +0 -341
- package/tests/lib/resumeCursor.test.ts +0 -46
- package/tests/middleware/errors.test.ts +0 -176
- package/tests/routes/associations.test.ts +0 -170
- package/tests/routes/attachments.test.ts +0 -621
- package/tests/routes/auth.test.ts +0 -448
- package/tests/routes/changes.test.ts +0 -567
- package/tests/routes/contentQuery.test.ts +0 -68
- package/tests/routes/entity.test.ts +0 -170
- package/tests/routes/groups.test.ts +0 -69
- package/tests/routes/records.test.ts +0 -1965
- package/tests/routes/relatedToQuery.test.ts +0 -182
- package/tests/routes/tokens.test.ts +0 -157
- package/tests/routes/tombstones.test.ts +0 -93
- package/tests/routes/types.test.ts +0 -129
- package/tests/routes/versions.test.ts +0 -177
- package/tests/routes/wellknown.test.ts +0 -71
- package/tests/setup.ts +0 -154
- package/tests/shutdown.test.ts +0 -121
- package/tests/stack.test.ts +0 -198
- package/tsconfig.build.json +0 -9
- package/tsconfig.json +0 -19
- 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
|
-
});
|