@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,198 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import type { Logger } from 'pino';
|
|
6
|
+
import { initStack } from '../src/stack.js';
|
|
7
|
+
import { testConfig, tempDbPath, logger, OTHER_ENTITY_ID } from './setup.js';
|
|
8
|
+
|
|
9
|
+
// A minimal stub, so the mock can't silently miss calls to unmocked
|
|
10
|
+
// methods that a full pino instance's prototype methods wouldn't survive a
|
|
11
|
+
// spread of. initStack() itself only calls logger.warn(), but the context
|
|
12
|
+
// it returns owns a QueryWorkerPool that logs errors on its own schedule —
|
|
13
|
+
// and an unstubbed method there surfaces as an uncaught TypeError from
|
|
14
|
+
// inside a worker event handler, not as a failed assertion.
|
|
15
|
+
function spyLogger(): Logger & { warn: ReturnType<typeof vi.fn> } {
|
|
16
|
+
return { warn: vi.fn(), error: vi.fn() } as unknown as Logger & {
|
|
17
|
+
warn: ReturnType<typeof vi.fn>;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A second server process against the same DB_PATH must fail loudly at
|
|
22
|
+
// startup rather than corrupt or silently share the database — the native
|
|
23
|
+
// SQLite adapter's own storage-ownership lock (record-adapter-sqlite) is
|
|
24
|
+
// what enforces this; this test pins that the failure surfaces cleanly
|
|
25
|
+
// through initStack() rather than getting swallowed.
|
|
26
|
+
describe('initStack double-open protection', () => {
|
|
27
|
+
let dbPath: string | undefined;
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
if (dbPath) await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('fails with an operator-legible message when the lock is held by another live process', async () => {
|
|
33
|
+
dbPath = tempDbPath();
|
|
34
|
+
const config = testConfig(dbPath);
|
|
35
|
+
|
|
36
|
+
const ctx = await initStack(config, logger);
|
|
37
|
+
await ctx.queryWorker.close();
|
|
38
|
+
await ctx.stack.close();
|
|
39
|
+
|
|
40
|
+
// Simulate another still-running process holding the lock: process.ppid
|
|
41
|
+
// is guaranteed alive (it's this test runner's own parent) and is never
|
|
42
|
+
// this process's own pid, so the lock check's same-pid bypass can't mask
|
|
43
|
+
// the scenario being tested.
|
|
44
|
+
writeFileSync(`${dbPath}.lock`, JSON.stringify({ pid: process.ppid }));
|
|
45
|
+
|
|
46
|
+
// Second call finds the db already exists (openOrInitialize's open path)
|
|
47
|
+
// and hits the same lock check LocalAdapter.open() always enforced.
|
|
48
|
+
await expect(initStack(config, logger)).rejects.toThrow(/in use by another process/);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('initStack bootstrap', () => {
|
|
53
|
+
let dbPath: string | undefined;
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
if (dbPath) await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('fails clearly when initializing a new database without ENTITY_ID', async () => {
|
|
59
|
+
dbPath = tempDbPath();
|
|
60
|
+
const config = { ...testConfig(dbPath), entityId: null };
|
|
61
|
+
|
|
62
|
+
await expect(initStack(config, logger)).rejects.toThrow(
|
|
63
|
+
/ENTITY_ID is required when initializing a new database/,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('opens an existing database fine without ENTITY_ID set', async () => {
|
|
68
|
+
dbPath = tempDbPath();
|
|
69
|
+
const config = testConfig(dbPath);
|
|
70
|
+
|
|
71
|
+
const ctx = await initStack(config, logger);
|
|
72
|
+
await ctx.queryWorker.close();
|
|
73
|
+
await ctx.stack.close();
|
|
74
|
+
await ctx.tokens.close();
|
|
75
|
+
ctx.nonces.close();
|
|
76
|
+
|
|
77
|
+
const reopened = await initStack({ ...config, entityId: null }, logger);
|
|
78
|
+
expect(reopened.stack.ownerEntityId).toBe(config.entityId);
|
|
79
|
+
await reopened.queryWorker.close();
|
|
80
|
+
await reopened.stack.close();
|
|
81
|
+
await reopened.tokens.close();
|
|
82
|
+
reopened.nonces.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('warns but does not fail when ENTITY_ID no longer matches the stored owner', async () => {
|
|
86
|
+
dbPath = tempDbPath();
|
|
87
|
+
const config = testConfig(dbPath);
|
|
88
|
+
|
|
89
|
+
const ctx = await initStack(config, logger);
|
|
90
|
+
await ctx.queryWorker.close();
|
|
91
|
+
await ctx.stack.close();
|
|
92
|
+
await ctx.tokens.close();
|
|
93
|
+
ctx.nonces.close();
|
|
94
|
+
|
|
95
|
+
const warn = spyLogger();
|
|
96
|
+
const mismatched = await initStack({ ...config, entityId: OTHER_ENTITY_ID }, warn);
|
|
97
|
+
expect(mismatched.stack.ownerEntityId).toBe(config.entityId);
|
|
98
|
+
expect(warn.warn).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
configuredEntityId: OTHER_ENTITY_ID,
|
|
101
|
+
storedOwnerEntityId: config.entityId,
|
|
102
|
+
}),
|
|
103
|
+
expect.stringMatching(/does not match/),
|
|
104
|
+
);
|
|
105
|
+
await mismatched.queryWorker.close();
|
|
106
|
+
await mismatched.stack.close();
|
|
107
|
+
await mismatched.tokens.close();
|
|
108
|
+
mismatched.nonces.close();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('does not register commons types when SEED_COMMONS_TYPES is unset', async () => {
|
|
112
|
+
dbPath = tempDbPath();
|
|
113
|
+
const config = testConfig(dbPath);
|
|
114
|
+
|
|
115
|
+
const ctx = await initStack(config, logger);
|
|
116
|
+
const types = await ctx.adapter.listTypes();
|
|
117
|
+
expect(types.some((t) => t.id.startsWith('org.haverstack/'))).toBe(false);
|
|
118
|
+
await ctx.queryWorker.close();
|
|
119
|
+
await ctx.stack.close();
|
|
120
|
+
await ctx.tokens.close();
|
|
121
|
+
ctx.nonces.close();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('registers all eight commons types when SEED_COMMONS_TYPES is set', async () => {
|
|
125
|
+
dbPath = tempDbPath();
|
|
126
|
+
const config = { ...testConfig(dbPath), seedCommonsTypes: true };
|
|
127
|
+
|
|
128
|
+
const ctx = await initStack(config, logger);
|
|
129
|
+
const types = await ctx.adapter.listTypes();
|
|
130
|
+
const commonsIds = types.filter((t) => t.id.startsWith('org.haverstack/')).map((t) => t.id);
|
|
131
|
+
expect(commonsIds.sort()).toEqual(
|
|
132
|
+
[
|
|
133
|
+
'org.haverstack/article@1',
|
|
134
|
+
'org.haverstack/bookmark@1',
|
|
135
|
+
'org.haverstack/contact@1',
|
|
136
|
+
'org.haverstack/note@1',
|
|
137
|
+
'org.haverstack/page@1',
|
|
138
|
+
'org.haverstack/photo@1',
|
|
139
|
+
'org.haverstack/place@1',
|
|
140
|
+
'org.haverstack/task@1',
|
|
141
|
+
].sort(),
|
|
142
|
+
);
|
|
143
|
+
await ctx.queryWorker.close();
|
|
144
|
+
await ctx.stack.close();
|
|
145
|
+
await ctx.tokens.close();
|
|
146
|
+
ctx.nonces.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('re-seeding commons types on a later boot causes no createdAt churn', async () => {
|
|
150
|
+
dbPath = tempDbPath();
|
|
151
|
+
const config = { ...testConfig(dbPath), seedCommonsTypes: true };
|
|
152
|
+
|
|
153
|
+
const first = await initStack(config, logger);
|
|
154
|
+
const firstType = await first.adapter.getType('org.haverstack/note@1');
|
|
155
|
+
await first.queryWorker.close();
|
|
156
|
+
await first.stack.close();
|
|
157
|
+
await first.tokens.close();
|
|
158
|
+
first.nonces.close();
|
|
159
|
+
|
|
160
|
+
const second = await initStack({ ...config, entityId: null }, logger);
|
|
161
|
+
const secondType = await second.adapter.getType('org.haverstack/note@1');
|
|
162
|
+
expect(secondType?.createdAt).toEqual(firstType?.createdAt);
|
|
163
|
+
await second.queryWorker.close();
|
|
164
|
+
await second.stack.close();
|
|
165
|
+
await second.tokens.close();
|
|
166
|
+
second.nonces.close();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('creates the owner _entity record when OWNER_NAME is configured', async () => {
|
|
170
|
+
dbPath = tempDbPath();
|
|
171
|
+
const config = { ...testConfig(dbPath), ownerName: 'Jane Owner', ownerHandle: '@jane' };
|
|
172
|
+
|
|
173
|
+
const ctx = await initStack(config, logger);
|
|
174
|
+
const { records } = await ctx.stack.query({
|
|
175
|
+
filter: { typeId: '_entity@1', content: { did: ctx.stack.ownerEntityId } },
|
|
176
|
+
});
|
|
177
|
+
expect(records[0]?.content).toMatchObject({ name: 'Jane Owner', handle: '@jane' });
|
|
178
|
+
await ctx.queryWorker.close();
|
|
179
|
+
await ctx.stack.close();
|
|
180
|
+
await ctx.tokens.close();
|
|
181
|
+
ctx.nonces.close();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('creates no owner _entity record when OWNER_NAME is unset', async () => {
|
|
185
|
+
dbPath = tempDbPath();
|
|
186
|
+
const config = testConfig(dbPath);
|
|
187
|
+
|
|
188
|
+
const ctx = await initStack(config, logger);
|
|
189
|
+
const { records } = await ctx.stack.query({
|
|
190
|
+
filter: { typeId: '_entity@1', content: { did: ctx.stack.ownerEntityId } },
|
|
191
|
+
});
|
|
192
|
+
expect(records).toHaveLength(0);
|
|
193
|
+
await ctx.queryWorker.close();
|
|
194
|
+
await ctx.stack.close();
|
|
195
|
+
await ctx.tokens.close();
|
|
196
|
+
ctx.nonces.close();
|
|
197
|
+
});
|
|
198
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"resolveJsonModule": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src", "tests"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|