@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
package/src/routes/types.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { Hono } from 'hono';
|
|
2
|
-
import type { AppEnv } from '../types.js';
|
|
3
|
-
import type { StackContext } from '../stack.js';
|
|
4
|
-
import { requireOwner } from '../middleware/auth.js';
|
|
5
|
-
import { readJson } from '../lib/json.js';
|
|
6
|
-
import { serializeType } from '@haverstack/wire-types';
|
|
7
|
-
import {
|
|
8
|
-
hashSchema,
|
|
9
|
-
StackQueryError,
|
|
10
|
-
StackNotFoundError,
|
|
11
|
-
StackValidationError,
|
|
12
|
-
} from '@haverstack/core';
|
|
13
|
-
import type { TypeSchema } from '@haverstack/core';
|
|
14
|
-
|
|
15
|
-
export function typeRoutes(ctx: StackContext): Hono<AppEnv> {
|
|
16
|
-
const app = new Hono<AppEnv>();
|
|
17
|
-
const { adapter, stack } = ctx;
|
|
18
|
-
|
|
19
|
-
app.get('/', async (c) => {
|
|
20
|
-
const types = await adapter.listTypes();
|
|
21
|
-
return c.json(types.map(serializeType));
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
app.get('/:id', async (c) => {
|
|
25
|
-
const id = decodeURIComponent(c.req.param('id'));
|
|
26
|
-
const type = await adapter.getType(id);
|
|
27
|
-
if (!type) throw new StackNotFoundError('Type not found');
|
|
28
|
-
return c.json(serializeType(type));
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
app.post('/', requireOwner(stack.ownerEntityId), async (c) => {
|
|
32
|
-
const body = await readJson<Record<string, unknown>>(c);
|
|
33
|
-
if (!body.id || typeof body.id !== 'string') throw new StackQueryError('id is required');
|
|
34
|
-
if (!body.name || typeof body.name !== 'string') throw new StackQueryError('name is required');
|
|
35
|
-
if (!body.schema || typeof body.schema !== 'object')
|
|
36
|
-
throw new StackQueryError('schema is required');
|
|
37
|
-
if (!body.schemaHash || typeof body.schemaHash !== 'string')
|
|
38
|
-
throw new StackQueryError('schemaHash is required');
|
|
39
|
-
|
|
40
|
-
const computedHash = await hashSchema(body.schema as TypeSchema);
|
|
41
|
-
if (body.schemaHash !== computedHash)
|
|
42
|
-
throw new StackValidationError([
|
|
43
|
-
{ path: 'schemaHash', message: 'schemaHash does not match schema' },
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
// stack.defineType() (not adapter.saveType() directly) is what runs the
|
|
47
|
-
// schema-drift check: redefining an existing typeId with anything
|
|
48
|
-
// beyond additive evolution throws StackSchemaDriftError, which
|
|
49
|
-
// adapter.saveType() alone has no way to enforce — it's a raw write.
|
|
50
|
-
const type = await stack.defineType(body.id, body.name, body.schema as TypeSchema, {
|
|
51
|
-
...(body.migratesFrom ? { migratesFrom: body.migratesFrom as string } : {}),
|
|
52
|
-
});
|
|
53
|
-
return c.json(serializeType(type), 201);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
return app;
|
|
57
|
-
}
|
package/src/routes/wellknown.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { Hono } from 'hono';
|
|
2
|
-
import {
|
|
3
|
-
WIRE_PROTOCOL_VERSION,
|
|
4
|
-
AUTH_METHOD_DID_CHALLENGE,
|
|
5
|
-
CHANGE_TRANSPORT_SSE,
|
|
6
|
-
} from '@haverstack/wire-types';
|
|
7
|
-
import type { DiscoveryResponse } from '@haverstack/wire-types';
|
|
8
|
-
import type { AppEnv } from '../types.js';
|
|
9
|
-
import type { StackContext } from '../stack.js';
|
|
10
|
-
import type { Config } from '../config.js';
|
|
11
|
-
|
|
12
|
-
export function wellknownRoutes(ctx: StackContext, config: Config): Hono<AppEnv> {
|
|
13
|
-
const app = new Hono<AppEnv>();
|
|
14
|
-
|
|
15
|
-
app.get('/stack', (c) => {
|
|
16
|
-
const body: DiscoveryResponse = {
|
|
17
|
-
version: WIRE_PROTOCOL_VERSION,
|
|
18
|
-
entityId: ctx.stack.ownerEntityId,
|
|
19
|
-
timezone: ctx.stack.timezone,
|
|
20
|
-
// The server's own request-size ceilings are authoritative here,
|
|
21
|
-
// overriding the adapter's `null` — nothing at the storage layer
|
|
22
|
-
// imposes a limit, but this server enforces both. See
|
|
23
|
-
// docs/spec/wire-format.md § Discovery.
|
|
24
|
-
capabilities: {
|
|
25
|
-
...ctx.stack.features,
|
|
26
|
-
limits: {
|
|
27
|
-
attachmentBytes: config.maxAttachmentBytes,
|
|
28
|
-
contentBytes: config.maxContentBytes,
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
// The DID challenge-response handshake is always implemented by this
|
|
32
|
-
// server, so it's always advertised — a client holding a DID
|
|
33
|
-
// credential learns at open() that there's a handshake to perform,
|
|
34
|
-
// rather than discovering it as a 404 partway through one.
|
|
35
|
-
auth: { methods: [AUTH_METHOD_DID_CHALLENGE] },
|
|
36
|
-
// Top-level rather than inside `capabilities`, which carries only
|
|
37
|
-
// what the `...ctx.stack.features` spread brings. An object rather
|
|
38
|
-
// than a boolean for the same reason `auth` is: the surface grows
|
|
39
|
-
// entries — another transport, batched frames — not more booleans.
|
|
40
|
-
//
|
|
41
|
-
// Both flags are literals with no override: GET /changes does resume
|
|
42
|
-
// and does honor `?include=record`, and a client acts on this answer
|
|
43
|
-
// without asking again. An override could only ever make discovery
|
|
44
|
-
// lie about the route beside it.
|
|
45
|
-
changes: {
|
|
46
|
-
transports: [CHANGE_TRANSPORT_SSE],
|
|
47
|
-
resume: true,
|
|
48
|
-
records: true,
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
return c.json(body);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
return app;
|
|
55
|
-
}
|
package/src/shutdown.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import type { Logger } from 'pino';
|
|
2
|
-
import type { StackContext } from './stack.js';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The subset of node-server's return value shutdown() needs. `close()`
|
|
6
|
-
* mirrors Node's http.Server: it stops accepting new connections and waits
|
|
7
|
-
* for open ones to end before invoking the callback. `closeAllConnections()`
|
|
8
|
-
* (Node >=18.2) destroys every open connection immediately, which is what
|
|
9
|
-
* makes a pending close() callback fire once the grace period expires.
|
|
10
|
-
* Optional because node-server's `ServerType` also covers Http2Server, whose
|
|
11
|
-
* @types/node surface omits it even though the app only ever serves HTTP/1.
|
|
12
|
-
*/
|
|
13
|
-
export type ShutdownServer = {
|
|
14
|
-
close(callback: () => void): void;
|
|
15
|
-
closeAllConnections?(): void;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Builds the shutdown sequence: stop the server (bounded by `timeoutMs` so a
|
|
20
|
-
* client holding a keep-alive connection open can't block it indefinitely),
|
|
21
|
-
* then release the stack's resources. Does not call process.exit — callers
|
|
22
|
-
* decide the exit code so this stays unit-testable.
|
|
23
|
-
*/
|
|
24
|
-
export function createShutdownHandler(
|
|
25
|
-
server: ShutdownServer,
|
|
26
|
-
ctx: StackContext,
|
|
27
|
-
logger: Logger,
|
|
28
|
-
timeoutMs: number,
|
|
29
|
-
): (signal: string) => Promise<void> {
|
|
30
|
-
return async (signal: string) => {
|
|
31
|
-
logger.info({ signal }, 'Shutting down');
|
|
32
|
-
|
|
33
|
-
// A change-feed stream never drains on its own, so server.close() below
|
|
34
|
-
// would wait out the full grace period every time. See
|
|
35
|
-
// src/lib/changeStreams.ts.
|
|
36
|
-
ctx.changeStreams.closeAll();
|
|
37
|
-
|
|
38
|
-
await new Promise<void>((resolve) => {
|
|
39
|
-
const timer = setTimeout(() => {
|
|
40
|
-
logger.warn({ timeoutMs }, 'Shutdown grace period exceeded; forcing connections closed');
|
|
41
|
-
server.closeAllConnections?.();
|
|
42
|
-
}, timeoutMs);
|
|
43
|
-
server.close(() => {
|
|
44
|
-
clearTimeout(timer);
|
|
45
|
-
resolve();
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
await ctx.queryWorker.close();
|
|
50
|
-
await ctx.stack.flush();
|
|
51
|
-
await ctx.stack.close();
|
|
52
|
-
await ctx.tokens.close();
|
|
53
|
-
ctx.nonces.close();
|
|
54
|
-
logger.info('Clean shutdown complete');
|
|
55
|
-
};
|
|
56
|
-
}
|
package/src/stack.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local';
|
|
2
|
-
import {
|
|
3
|
-
ARTICLE,
|
|
4
|
-
BOOKMARK,
|
|
5
|
-
CONTACT,
|
|
6
|
-
NOTE,
|
|
7
|
-
PAGE,
|
|
8
|
-
PHOTO,
|
|
9
|
-
PLACE,
|
|
10
|
-
TASK,
|
|
11
|
-
defineCommonsTypes,
|
|
12
|
-
} from '@haverstack/commons';
|
|
13
|
-
import { Stack } from '@haverstack/core';
|
|
14
|
-
import type { StackTokenStore } from '@haverstack/core/wire';
|
|
15
|
-
import type { Logger } from 'pino';
|
|
16
|
-
import type { Config } from './config.js';
|
|
17
|
-
import { AuthNonceStore, defaultNonceStorePath } from './lib/nonceStore.js';
|
|
18
|
-
import { QueryWorkerPool } from './lib/queryWorker/pool.js';
|
|
19
|
-
import { ChangeStreamRegistry } from './lib/changeStreams.js';
|
|
20
|
-
|
|
21
|
-
export type StackContext = {
|
|
22
|
-
adapter: LocalAdapter;
|
|
23
|
-
stack: Stack;
|
|
24
|
-
// Typed as the interface, not NativeTokenStore, so routes and middleware
|
|
25
|
-
// bind to the contract. close() is the one lifecycle hook every
|
|
26
|
-
// implementation needs and the core interface doesn't declare.
|
|
27
|
-
tokens: StackTokenStore & { close(): Promise<void> };
|
|
28
|
-
// DID challenge-response nonces — see AuthNonceStore. Also kept outside
|
|
29
|
-
// the portable stack file, in its own sibling store beside the tokens.
|
|
30
|
-
nonces: AuthNonceStore;
|
|
31
|
-
// Runs ScopedStack.query() off the request thread with a per-request
|
|
32
|
-
// deadline. See docs/spec/wire-format.md § Bounding query cost and
|
|
33
|
-
// src/lib/queryWorker/pool.ts.
|
|
34
|
-
queryWorker: QueryWorkerPool;
|
|
35
|
-
// Open GET /changes SSE connections, so shutdown can end them promptly
|
|
36
|
-
// rather than waiting out the drain timeout. See src/lib/changeStreams.ts.
|
|
37
|
-
changeStreams: ChangeStreamRegistry;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export async function initStack(config: Config, logger: Logger): Promise<StackContext> {
|
|
41
|
-
// openOrInitialize() decides between open and create without a TOCTOU
|
|
42
|
-
// gap. entityId goes in as a lazy provider so openOrInitialize()'s own
|
|
43
|
-
// owner-mismatch check, which throws, never runs on the open path —
|
|
44
|
-
// ENTITY_ID divergence is a warning below, not a failure — and so a
|
|
45
|
-
// missing ENTITY_ID is an error only when creating a database.
|
|
46
|
-
const adapter = await LocalAdapter.openOrInitialize({
|
|
47
|
-
path: config.dbPath,
|
|
48
|
-
entityId: () => {
|
|
49
|
-
if (!config.entityId) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
'ENTITY_ID is required when initializing a new database (DB_PATH does not exist yet)',
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
return config.entityId;
|
|
55
|
-
},
|
|
56
|
-
timezone: config.timezone,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
if (config.entityId && config.entityId !== adapter.ownerEntityId) {
|
|
60
|
-
logger.warn(
|
|
61
|
-
{ configuredEntityId: config.entityId, storedOwnerEntityId: adapter.ownerEntityId },
|
|
62
|
-
"ENTITY_ID does not match the stack's stored owner; the configured value is ignored",
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const stack = await Stack.create(
|
|
67
|
-
adapter,
|
|
68
|
-
config.ownerName
|
|
69
|
-
? { ownerProfile: { name: config.ownerName, handle: config.ownerHandle ?? undefined } }
|
|
70
|
-
: undefined,
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
// Opt-in (SEED_COMMONS_TYPES). defineType() is idempotent — an identical
|
|
74
|
-
// schema is a no-op preserving createdAt — so this is safe every boot.
|
|
75
|
-
if (config.seedCommonsTypes) {
|
|
76
|
-
await defineCommonsTypes(stack, [NOTE, BOOKMARK, TASK, CONTACT, ARTICLE, PLACE, PAGE, PHOTO]);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Composed as a separate part rather than sniffed off the adapter: auth
|
|
80
|
-
// material lives in its own file beside stack.db (not inside the
|
|
81
|
-
// portable stack export) per docs/spec/wire-format.md § Authentication.
|
|
82
|
-
const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(config.dbPath) });
|
|
83
|
-
const nonces = AuthNonceStore.open(defaultNonceStorePath(config.dbPath));
|
|
84
|
-
|
|
85
|
-
// Spawned only once the adapter has opened and seeded stack.db: each
|
|
86
|
-
// worker opens its own connection to that same file, and idempotent
|
|
87
|
-
// seeding makes the race survivable rather than worth running.
|
|
88
|
-
const queryWorker = new QueryWorkerPool({
|
|
89
|
-
init: { dbPath: config.dbPath },
|
|
90
|
-
poolSize: config.queryWorkerPoolSize,
|
|
91
|
-
queueLimit: config.queryQueueLimit,
|
|
92
|
-
logger,
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() };
|
|
96
|
-
}
|
package/tests/app.test.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { createApp } from '../src/app.js';
|
|
3
|
-
import { buildTestApp, testConfig, logger, req, TEST_TOKEN, type TestApp } from './setup.js';
|
|
4
|
-
|
|
5
|
-
const NOTE_TYPE_ID = 'com.example.test/note@1';
|
|
6
|
-
|
|
7
|
-
describe('request body size limit', () => {
|
|
8
|
-
let t: TestApp;
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
t = await buildTestApp();
|
|
11
|
-
await t.ctx.stack.defineType(NOTE_TYPE_ID, 'Note', {
|
|
12
|
-
body: { kind: 'text' as const, required: false as const },
|
|
13
|
-
});
|
|
14
|
-
});
|
|
15
|
-
afterEach(async () => {
|
|
16
|
-
await t.cleanup();
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('returns a conforming 413 for an oversized POST /records body', async () => {
|
|
20
|
-
const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 100 }, logger);
|
|
21
|
-
const { status, data } = await req(smallApp, 'POST', '/records', {
|
|
22
|
-
token: TEST_TOKEN,
|
|
23
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'x'.repeat(500) } },
|
|
24
|
-
});
|
|
25
|
-
expect(status).toBe(413);
|
|
26
|
-
expect((data as { error: { code: string } }).error.code).toBe('payload_too_large');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('returns a conforming 413 for an oversized PATCH body', async () => {
|
|
30
|
-
const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 5000 }, logger);
|
|
31
|
-
const created = await req(smallApp, 'POST', '/records', {
|
|
32
|
-
token: TEST_TOKEN,
|
|
33
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'short' } },
|
|
34
|
-
});
|
|
35
|
-
const id = (created.data as { id: string }).id;
|
|
36
|
-
|
|
37
|
-
const { status, data } = await req(smallApp, 'PATCH', `/records/${id}`, {
|
|
38
|
-
token: TEST_TOKEN,
|
|
39
|
-
body: { contentPatch: { body: 'x'.repeat(6000) } },
|
|
40
|
-
});
|
|
41
|
-
expect(status).toBe(413);
|
|
42
|
-
expect((data as { error: { code: string } }).error.code).toBe('payload_too_large');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('succeeds for a body just under the limit', async () => {
|
|
46
|
-
const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 500 }, logger);
|
|
47
|
-
const { status } = await req(smallApp, 'POST', '/records', {
|
|
48
|
-
token: TEST_TOKEN,
|
|
49
|
-
body: { typeId: NOTE_TYPE_ID, content: { body: 'well under the limit' } },
|
|
50
|
-
});
|
|
51
|
-
expect(status).toBe(200);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('fires before JSON parsing, not after', async () => {
|
|
55
|
-
const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 10 }, logger);
|
|
56
|
-
const res = await smallApp.request('/records', {
|
|
57
|
-
method: 'POST',
|
|
58
|
-
headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'application/json' },
|
|
59
|
-
// Not valid JSON at all — if the limit ran after parsing, this would
|
|
60
|
-
// surface as a parse/bad_request failure instead of a clean 413.
|
|
61
|
-
body: 'not valid json, and also over the 10-byte limit',
|
|
62
|
-
});
|
|
63
|
-
expect(res.status).toBe(413);
|
|
64
|
-
const body = (await res.json()) as { error: { code: string } };
|
|
65
|
-
expect(body.error.code).toBe('payload_too_large');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('applies to routes outside the original per-prefix list (e.g. POST /attachments/gc)', async () => {
|
|
69
|
-
const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 10 }, logger);
|
|
70
|
-
const { status } = await req(smallApp, 'POST', '/attachments/gc', {
|
|
71
|
-
token: TEST_TOKEN,
|
|
72
|
-
body: { graceMs: 999999, dryRun: true },
|
|
73
|
-
});
|
|
74
|
-
expect(status).toBe(413);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('excludes POST /attachments so its own, larger ceiling still applies', async () => {
|
|
78
|
-
const smallApp = createApp(t.ctx, { ...testConfig(t.dbPath), maxContentBytes: 10 }, logger);
|
|
79
|
-
const res = await smallApp.request('/attachments', {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: { Authorization: `Bearer ${TEST_TOKEN}`, 'Content-Type': 'text/plain' },
|
|
82
|
-
body: 'x'.repeat(1000), // well over the 10-byte content limit, under maxAttachmentBytes
|
|
83
|
-
});
|
|
84
|
-
expect(res.status).toBe(200);
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A client-side reader for the change feed's SSE wire format
|
|
3
|
-
* (docs/spec/wire-format.md § Change feed). Hono's own `hono/streaming`
|
|
4
|
-
* ships an SSE *writer* (`streamSSE`) but nothing to consume one — that
|
|
5
|
-
* half is what this module provides, so tests/conformance.test.ts can
|
|
6
|
-
* drive changeFeedFixtures: open a connection, decode frames as they
|
|
7
|
-
* arrive, dispatch the fixture's `activity` mutations while it is still
|
|
8
|
-
* open, and collect what the connection produces.
|
|
9
|
-
*/
|
|
10
|
-
import type { Hono } from 'hono';
|
|
11
|
-
import type { AppEnv } from '../src/app.js';
|
|
12
|
-
|
|
13
|
-
export type DecodedFrame = {
|
|
14
|
-
/**
|
|
15
|
-
* SSE `id:`, when the wire line was present on *this* frame — not the
|
|
16
|
-
* persisted "last event ID" a spec-compliant EventSource client carries
|
|
17
|
-
* forward across events with no id of their own. changeFeedFixtures pins
|
|
18
|
-
* the literal per-frame wire content (a `reset` frame carries no id even
|
|
19
|
-
* immediately after a `record` frame that did), so this decoder resets
|
|
20
|
-
* per frame rather than inheriting.
|
|
21
|
-
*/
|
|
22
|
-
id?: string;
|
|
23
|
-
event: string;
|
|
24
|
-
data: unknown;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** Incremental SSE decoder: feed it raw chunks of the stream's text as they arrive. */
|
|
28
|
-
export class SSEDecoder {
|
|
29
|
-
private buffer = '';
|
|
30
|
-
private eventName: string | undefined;
|
|
31
|
-
private id: string | undefined;
|
|
32
|
-
private dataLines: string[] = [];
|
|
33
|
-
private sawField = false;
|
|
34
|
-
|
|
35
|
-
/** Decode one chunk, returning any frames it completed. */
|
|
36
|
-
push(text: string): DecodedFrame[] {
|
|
37
|
-
this.buffer += text;
|
|
38
|
-
const frames: DecodedFrame[] = [];
|
|
39
|
-
let newlineIndex: number;
|
|
40
|
-
while ((newlineIndex = this.buffer.indexOf('\n')) !== -1) {
|
|
41
|
-
const rawLine = this.buffer.slice(0, newlineIndex);
|
|
42
|
-
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
43
|
-
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
|
44
|
-
if (line === '') {
|
|
45
|
-
const frame = this.dispatch();
|
|
46
|
-
if (frame) frames.push(frame);
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (line.startsWith(':')) continue; // comment line — a keepalive
|
|
50
|
-
const colonIndex = line.indexOf(':');
|
|
51
|
-
const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
|
|
52
|
-
let value = colonIndex === -1 ? '' : line.slice(colonIndex + 1);
|
|
53
|
-
if (value.startsWith(' ')) value = value.slice(1);
|
|
54
|
-
this.sawField = true;
|
|
55
|
-
if (field === 'id') this.id = value;
|
|
56
|
-
else if (field === 'event') this.eventName = value;
|
|
57
|
-
else if (field === 'data') this.dataLines.push(value);
|
|
58
|
-
// 'retry' and any other field: not needed here, ignored.
|
|
59
|
-
}
|
|
60
|
-
return frames;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
private dispatch(): DecodedFrame | undefined {
|
|
64
|
-
if (!this.sawField) return undefined; // a blank line with nothing since the last dispatch
|
|
65
|
-
const event = this.eventName ?? 'message';
|
|
66
|
-
const id = this.id;
|
|
67
|
-
const dataText = this.dataLines.join('\n');
|
|
68
|
-
this.eventName = undefined;
|
|
69
|
-
this.id = undefined;
|
|
70
|
-
this.dataLines = [];
|
|
71
|
-
this.sawField = false;
|
|
72
|
-
let data: unknown = dataText;
|
|
73
|
-
if (dataText) {
|
|
74
|
-
try {
|
|
75
|
-
data = JSON.parse(dataText);
|
|
76
|
-
} catch {
|
|
77
|
-
// Leave as raw text — a fixture asserting on parsed JSON fails loudly.
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return { ...(id !== undefined && { id }), event, data };
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export type ChangeFeedConnection = {
|
|
85
|
-
status: number;
|
|
86
|
-
/** Frames decoded so far, in arrival order. Mutated in place as more arrive. */
|
|
87
|
-
frames: DecodedFrame[];
|
|
88
|
-
/** Resolves with the first `count` frames once they've arrived, or rejects after timeoutMs. */
|
|
89
|
-
waitForFrames(count: number, timeoutMs?: number): Promise<DecodedFrame[]>;
|
|
90
|
-
/** Cancels the underlying reader and stops decoding. */
|
|
91
|
-
close(): Promise<void>;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
export type OpenChangeFeedOpts = {
|
|
95
|
-
token?: string;
|
|
96
|
-
/** Additional headers, e.g. Last-Event-ID. */
|
|
97
|
-
headers?: Record<string, string>;
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Opens an SSE connection against a Hono test app. `app.request()` resolves
|
|
102
|
-
* as soon as the response's headers are sent — the body stays open and
|
|
103
|
-
* readable while the server keeps writing — so a mutation dispatched via
|
|
104
|
-
* the ordinary `req()` helper while a connection from this function is
|
|
105
|
-
* still open reaches the same in-process stack and can produce frames on
|
|
106
|
-
* it, exactly as changeFeedFixtures' `activity` expects.
|
|
107
|
-
*/
|
|
108
|
-
export async function openChangeFeed(
|
|
109
|
-
app: Hono<AppEnv>,
|
|
110
|
-
path: string,
|
|
111
|
-
opts: OpenChangeFeedOpts = {},
|
|
112
|
-
): Promise<ChangeFeedConnection> {
|
|
113
|
-
const headers: Record<string, string> = { Accept: 'text/event-stream' };
|
|
114
|
-
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`;
|
|
115
|
-
Object.assign(headers, opts.headers);
|
|
116
|
-
|
|
117
|
-
const res = await app.request(path, { headers });
|
|
118
|
-
const reader = res.body?.getReader();
|
|
119
|
-
const decoder = new SSEDecoder();
|
|
120
|
-
const textDecoder = new TextDecoder();
|
|
121
|
-
const frames: DecodedFrame[] = [];
|
|
122
|
-
let closed = false;
|
|
123
|
-
|
|
124
|
-
const pump = (async () => {
|
|
125
|
-
if (!reader) return;
|
|
126
|
-
while (!closed) {
|
|
127
|
-
const { done, value } = await reader.read();
|
|
128
|
-
if (done) return;
|
|
129
|
-
frames.push(...decoder.push(textDecoder.decode(value, { stream: true })));
|
|
130
|
-
}
|
|
131
|
-
})();
|
|
132
|
-
// A rejected pump would otherwise surface as an unhandled rejection the
|
|
133
|
-
// moment close() cancels the reader mid-read.
|
|
134
|
-
pump.catch(() => {});
|
|
135
|
-
|
|
136
|
-
async function waitForFrames(count: number, timeoutMs = 2000): Promise<DecodedFrame[]> {
|
|
137
|
-
const start = Date.now();
|
|
138
|
-
while (frames.length < count) {
|
|
139
|
-
if (Date.now() - start > timeoutMs) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`Timed out after ${timeoutMs}ms waiting for ${count} frame(s); got ${frames.length}: ${JSON.stringify(frames)}`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
145
|
-
}
|
|
146
|
-
return frames.slice(0, count);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function close(): Promise<void> {
|
|
150
|
-
closed = true;
|
|
151
|
-
if (reader) await reader.cancel().catch(() => {});
|
|
152
|
-
await pump;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { status: res.status, frames, waitForFrames, close };
|
|
156
|
-
}
|
package/tests/config.test.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { loadConfig } from '../src/config.js';
|
|
3
|
-
|
|
4
|
-
const REQUIRED_ENV = {
|
|
5
|
-
DB_PATH: './stack.db',
|
|
6
|
-
OWNER_TOKEN: 'test-owner-token',
|
|
7
|
-
BASE_URL: 'https://stack.example.com',
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
describe('loadConfig', () => {
|
|
11
|
-
const savedEnv = { ...process.env };
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
15
|
-
Object.assign(process.env, REQUIRED_ENV);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
for (const key of Object.keys(process.env)) delete process.env[key];
|
|
20
|
-
Object.assign(process.env, savedEnv);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('accepts a well-formed DID as ENTITY_ID', () => {
|
|
24
|
-
process.env['ENTITY_ID'] = 'did:key:z6MkfooBar';
|
|
25
|
-
const config = loadConfig();
|
|
26
|
-
expect(config.entityId).toBe('did:key:z6MkfooBar');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('rejects an ENTITY_ID that is not a DID', () => {
|
|
30
|
-
process.env['ENTITY_ID'] = 'not-a-did';
|
|
31
|
-
expect(() => loadConfig()).toThrow(/must be a DID/);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('allows ENTITY_ID to be unset', () => {
|
|
35
|
-
const config = loadConfig();
|
|
36
|
-
expect(config.entityId).toBeNull();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('reads OWNER_NAME and OWNER_HANDLE when set', () => {
|
|
40
|
-
process.env['OWNER_NAME'] = 'Jane';
|
|
41
|
-
process.env['OWNER_HANDLE'] = '@jane';
|
|
42
|
-
const config = loadConfig();
|
|
43
|
-
expect(config.ownerName).toBe('Jane');
|
|
44
|
-
expect(config.ownerHandle).toBe('@jane');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('defaults OWNER_NAME and OWNER_HANDLE to null', () => {
|
|
48
|
-
const config = loadConfig();
|
|
49
|
-
expect(config.ownerName).toBeNull();
|
|
50
|
-
expect(config.ownerHandle).toBeNull();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('leaves timezone undefined when TIMEZONE is unset', () => {
|
|
54
|
-
const config = loadConfig();
|
|
55
|
-
expect(config.timezone).toBeUndefined();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('reads TIMEZONE when set', () => {
|
|
59
|
-
process.env['TIMEZONE'] = 'America/New_York';
|
|
60
|
-
const config = loadConfig();
|
|
61
|
-
expect(config.timezone).toBe('America/New_York');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('defaults maxContentBytes to 1 MB', () => {
|
|
65
|
-
const config = loadConfig();
|
|
66
|
-
expect(config.maxContentBytes).toBe(1 * 1024 * 1024);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('reads MAX_CONTENT_BYTES when set', () => {
|
|
70
|
-
process.env['MAX_CONTENT_BYTES'] = '2048';
|
|
71
|
-
const config = loadConfig();
|
|
72
|
-
expect(config.maxContentBytes).toBe(2048);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('rejects an invalid MAX_CONTENT_BYTES', () => {
|
|
76
|
-
process.env['MAX_CONTENT_BYTES'] = 'not-a-number';
|
|
77
|
-
expect(() => loadConfig()).toThrow(/Invalid MAX_CONTENT_BYTES/);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('refuses to start when BASE_URL is unset', () => {
|
|
81
|
-
delete process.env['BASE_URL'];
|
|
82
|
-
expect(() => loadConfig()).toThrow(/Missing required environment variable: BASE_URL/);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('rejects a BASE_URL with no origin to bind a signature to', () => {
|
|
86
|
-
process.env['BASE_URL'] = 'not-a-url';
|
|
87
|
-
expect(() => loadConfig()).toThrow(/BASE_URL must be an absolute URL/);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('derives authOrigin from BASE_URL', () => {
|
|
91
|
-
process.env['BASE_URL'] = 'https://stack.example.com/some/path';
|
|
92
|
-
const config = loadConfig();
|
|
93
|
-
expect(config.baseUrl).toBe('https://stack.example.com/some/path');
|
|
94
|
-
expect(config.authOrigin).toBe('https://stack.example.com');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('defaults shutdownTimeoutMs to 10s', () => {
|
|
98
|
-
const config = loadConfig();
|
|
99
|
-
expect(config.shutdownTimeoutMs).toBe(10_000);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('reads SHUTDOWN_TIMEOUT_MS when set', () => {
|
|
103
|
-
process.env['SHUTDOWN_TIMEOUT_MS'] = '5000';
|
|
104
|
-
const config = loadConfig();
|
|
105
|
-
expect(config.shutdownTimeoutMs).toBe(5000);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('rejects an invalid SHUTDOWN_TIMEOUT_MS', () => {
|
|
109
|
-
process.env['SHUTDOWN_TIMEOUT_MS'] = 'not-a-number';
|
|
110
|
-
expect(() => loadConfig()).toThrow(/Invalid SHUTDOWN_TIMEOUT_MS/);
|
|
111
|
-
});
|
|
112
|
-
});
|