@haverstack/server 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +22 -0
- package/.dockerignore +10 -0
- package/.env.example +90 -0
- package/.github/pull_request_template.md +23 -0
- package/.github/workflows/ci.yml +55 -0
- package/.github/workflows/release.yml +99 -0
- package/.prettierignore +3 -0
- package/.prettierrc +7 -0
- package/AGENTS.md +91 -0
- package/CONTRIBUTING.md +234 -0
- package/Dockerfile +30 -0
- package/LICENSE +121 -0
- package/README.md +107 -0
- package/SECURITY.md +54 -0
- package/docs/api.md +277 -0
- package/docs/deployment.md +212 -0
- package/eslint.config.mjs +18 -0
- package/package.json +42 -0
- package/pnpm-workspace.yaml +13 -0
- package/src/app.ts +86 -0
- package/src/config.ts +176 -0
- package/src/index.ts +54 -0
- package/src/lib/changeStreams.ts +23 -0
- package/src/lib/frameGate.ts +42 -0
- package/src/lib/json.ts +27 -0
- package/src/lib/nonceStore.ts +103 -0
- package/src/lib/queryLimit.ts +11 -0
- package/src/lib/queryWorker/pool.ts +324 -0
- package/src/lib/queryWorker/protocol.ts +39 -0
- package/src/lib/queryWorker/worker.ts +51 -0
- package/src/lib/resumeBuffer.ts +268 -0
- package/src/lib/resumeCursor.ts +48 -0
- package/src/lib/tokenSweep.ts +43 -0
- package/src/middleware/auth.ts +77 -0
- package/src/middleware/errors.ts +73 -0
- package/src/routes/attachments.ts +126 -0
- package/src/routes/auth.ts +137 -0
- package/src/routes/changes.ts +279 -0
- package/src/routes/entity.ts +33 -0
- package/src/routes/health.ts +8 -0
- package/src/routes/records.ts +259 -0
- package/src/routes/tokens.ts +85 -0
- package/src/routes/types.ts +57 -0
- package/src/routes/wellknown.ts +55 -0
- package/src/shutdown.ts +56 -0
- package/src/stack.ts +96 -0
- package/src/types.ts +9 -0
- package/src/wireError.ts +21 -0
- package/tests/app.test.ts +86 -0
- package/tests/changeFeedClient.ts +156 -0
- package/tests/config.test.ts +112 -0
- package/tests/conformance.test.ts +2322 -0
- package/tests/fixtures/blockingWorker.ts +27 -0
- package/tests/fixtures/brokenWorker.ts +7 -0
- package/tests/lib/changeFeedClient.test.ts +126 -0
- package/tests/lib/frameGate.test.ts +93 -0
- package/tests/lib/queryLimit.test.ts +33 -0
- package/tests/lib/queryWorker.test.ts +60 -0
- package/tests/lib/queryWorkerPool.test.ts +129 -0
- package/tests/lib/resumeBuffer.test.ts +341 -0
- package/tests/lib/resumeCursor.test.ts +46 -0
- package/tests/middleware/errors.test.ts +176 -0
- package/tests/routes/associations.test.ts +170 -0
- package/tests/routes/attachments.test.ts +621 -0
- package/tests/routes/auth.test.ts +448 -0
- package/tests/routes/changes.test.ts +567 -0
- package/tests/routes/contentQuery.test.ts +68 -0
- package/tests/routes/entity.test.ts +170 -0
- package/tests/routes/groups.test.ts +69 -0
- package/tests/routes/records.test.ts +1965 -0
- package/tests/routes/relatedToQuery.test.ts +182 -0
- package/tests/routes/tokens.test.ts +157 -0
- package/tests/routes/tombstones.test.ts +93 -0
- package/tests/routes/types.test.ts +129 -0
- package/tests/routes/versions.test.ts +177 -0
- package/tests/routes/wellknown.test.ts +71 -0
- package/tests/setup.ts +154 -0
- package/tests/shutdown.test.ts +121 -0
- package/tests/stack.test.ts +198 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { buildTestApp, req, TEST_TOKEN, type TestApp } from '../setup.js';
|
|
3
|
+
|
|
4
|
+
const TYPE_ID = 'com.example.test/post@1';
|
|
5
|
+
|
|
6
|
+
describe('Associations', () => {
|
|
7
|
+
let t: TestApp;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
t = await buildTestApp();
|
|
10
|
+
await t.ctx.stack.defineType(TYPE_ID, 'Post', {
|
|
11
|
+
text: { kind: 'text' as const, required: true as const },
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await t.cleanup();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function seedRecord() {
|
|
19
|
+
return t.ctx.stack.create(TYPE_ID, { text: 'Hello' });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('POST adds a tag association and answers with the updated record', async () => {
|
|
23
|
+
const record = await seedRecord();
|
|
24
|
+
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
25
|
+
token: TEST_TOKEN,
|
|
26
|
+
body: { kind: 'tag', label: 'starred' },
|
|
27
|
+
});
|
|
28
|
+
expect(status).toBe(200);
|
|
29
|
+
expect((data as Record<string, unknown>).associations).toEqual([
|
|
30
|
+
{ kind: 'tag', label: 'starred' },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('POST adds a relationship association with a record-scope target and answers with the updated record', async () => {
|
|
35
|
+
const record = await seedRecord();
|
|
36
|
+
const other = await seedRecord();
|
|
37
|
+
const target = { scope: 'record' as const, recordId: other.id };
|
|
38
|
+
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
39
|
+
token: TEST_TOKEN,
|
|
40
|
+
body: { kind: 'relationship', label: 'reply-to', target },
|
|
41
|
+
});
|
|
42
|
+
expect(status).toBe(200);
|
|
43
|
+
expect((data as Record<string, unknown>).associations).toEqual([
|
|
44
|
+
{ kind: 'relationship', label: 'reply-to', target },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it.each([
|
|
49
|
+
['entity', { scope: 'entity', entityId: 'did:key:z6MkAlice' }],
|
|
50
|
+
['external', { scope: 'external', ns: 'atproto', id: 'at://did:plc:abc/app.bsky.feed.post/1' }],
|
|
51
|
+
] as const)(
|
|
52
|
+
'POST adds a relationship association with a %s-scope target and answers with the updated record',
|
|
53
|
+
async (_scope, target) => {
|
|
54
|
+
const record = await seedRecord();
|
|
55
|
+
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
56
|
+
token: TEST_TOKEN,
|
|
57
|
+
body: { kind: 'relationship', label: 'reply-to', target },
|
|
58
|
+
});
|
|
59
|
+
expect(status).toBe(200);
|
|
60
|
+
expect((data as Record<string, unknown>).associations).toEqual([
|
|
61
|
+
{ kind: 'relationship', label: 'reply-to', target },
|
|
62
|
+
]);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
it('GET returns all associations', async () => {
|
|
67
|
+
const record = await seedRecord();
|
|
68
|
+
await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' });
|
|
69
|
+
await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'archived' });
|
|
70
|
+
const { status, data } = await req(t.app, 'GET', `/records/${record.id}/associations`, {
|
|
71
|
+
token: TEST_TOKEN,
|
|
72
|
+
});
|
|
73
|
+
expect(status).toBe(200);
|
|
74
|
+
expect((data as { associations: unknown[] }).associations).toHaveLength(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('GET ?kind=tag filters by kind', async () => {
|
|
78
|
+
const record = await seedRecord();
|
|
79
|
+
await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' });
|
|
80
|
+
const { status, data } = await req(
|
|
81
|
+
t.app,
|
|
82
|
+
'GET',
|
|
83
|
+
`/records/${record.id}/associations?kind=tag`,
|
|
84
|
+
{ token: TEST_TOKEN },
|
|
85
|
+
);
|
|
86
|
+
expect(status).toBe(200);
|
|
87
|
+
const assocs = (data as { associations: Array<{ kind: string }> }).associations;
|
|
88
|
+
expect(assocs.every((a) => a.kind === 'tag')).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('POST .../associations/delete removes an association and answers with the updated record', async () => {
|
|
92
|
+
const record = await seedRecord();
|
|
93
|
+
await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' });
|
|
94
|
+
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, {
|
|
95
|
+
token: TEST_TOKEN,
|
|
96
|
+
body: { kind: 'tag', label: 'starred' },
|
|
97
|
+
});
|
|
98
|
+
expect(status).toBe(200);
|
|
99
|
+
expect((data as Record<string, unknown>).associations).toBeUndefined();
|
|
100
|
+
const after = await t.ctx.adapter.getRecord(record.id);
|
|
101
|
+
expect(after?.associations?.some((a) => a.label === 'starred')).toBeFalsy();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('POST .../associations/delete removes a relationship association regardless of target scope', async () => {
|
|
105
|
+
const record = await seedRecord();
|
|
106
|
+
const target = { scope: 'entity' as const, entityId: 'did:key:z6MkAlice' };
|
|
107
|
+
await t.ctx.adapter.associate(record.id, { kind: 'relationship', label: 'author', target });
|
|
108
|
+
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, {
|
|
109
|
+
token: TEST_TOKEN,
|
|
110
|
+
body: { kind: 'relationship', label: 'author', target },
|
|
111
|
+
});
|
|
112
|
+
expect(status).toBe(200);
|
|
113
|
+
expect((data as Record<string, unknown>).associations).toBeUndefined();
|
|
114
|
+
const after = await t.ctx.adapter.getRecord(record.id);
|
|
115
|
+
expect(after?.associations?.some((a) => a.label === 'author')).toBeFalsy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('If-Match / optimistic concurrency', () => {
|
|
119
|
+
it('POST /associations succeeds when If-Match names the current version', async () => {
|
|
120
|
+
const record = await seedRecord();
|
|
121
|
+
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
122
|
+
token: TEST_TOKEN,
|
|
123
|
+
body: { kind: 'tag', label: 'starred' },
|
|
124
|
+
headers: { 'If-Match': `"${record.version}"` },
|
|
125
|
+
});
|
|
126
|
+
expect(status).toBe(200);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('POST /associations returns 412 version_conflict on an If-Match mismatch', async () => {
|
|
130
|
+
const record = await seedRecord();
|
|
131
|
+
const { status, data } = await req(t.app, 'POST', `/records/${record.id}/associations`, {
|
|
132
|
+
token: TEST_TOKEN,
|
|
133
|
+
body: { kind: 'tag', label: 'starred' },
|
|
134
|
+
headers: { 'If-Match': `"${record.version + 1}"` },
|
|
135
|
+
});
|
|
136
|
+
expect(status).toBe(412);
|
|
137
|
+
expect((data as { error: { code: string } }).error.code).toBe('version_conflict');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('POST /associations/delete succeeds when If-Match names the current version', async () => {
|
|
141
|
+
const record = await seedRecord();
|
|
142
|
+
await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' });
|
|
143
|
+
const current = await t.ctx.adapter.getRecord(record.id);
|
|
144
|
+
const { status } = await req(t.app, 'POST', `/records/${record.id}/associations/delete`, {
|
|
145
|
+
token: TEST_TOKEN,
|
|
146
|
+
body: { kind: 'tag', label: 'starred' },
|
|
147
|
+
headers: { 'If-Match': `"${current!.version}"` },
|
|
148
|
+
});
|
|
149
|
+
expect(status).toBe(200);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('POST /associations/delete returns 412 version_conflict on an If-Match mismatch', async () => {
|
|
153
|
+
const record = await seedRecord();
|
|
154
|
+
await t.ctx.adapter.associate(record.id, { kind: 'tag', label: 'starred' });
|
|
155
|
+
const current = await t.ctx.adapter.getRecord(record.id);
|
|
156
|
+
const { status, data } = await req(
|
|
157
|
+
t.app,
|
|
158
|
+
'POST',
|
|
159
|
+
`/records/${record.id}/associations/delete`,
|
|
160
|
+
{
|
|
161
|
+
token: TEST_TOKEN,
|
|
162
|
+
body: { kind: 'tag', label: 'starred' },
|
|
163
|
+
headers: { 'If-Match': `"${current!.version + 1}"` },
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
expect(status).toBe(412);
|
|
167
|
+
expect((data as { error: { code: string } }).error.code).toBe('version_conflict');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|