Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ QUERY_WORKER_POOL_SIZE=2
# See docs/deployment.md#bounding-query-cost.
QUERY_QUEUE_LIMIT=64

# Register the Schema Commons types (org.haverstack/note, bookmark, task,
# contact, article, place, page, photo) from @haverstack/commons on startup
# (default: false). Off by default because the package is Draft status and
# registering types is not free — they show up in GET /types and in every
# app's type cache. See docs/deployment.md#schema-commons-seeding.
SEED_COMMONS_TYPES=false

# How long shutdown waits (in milliseconds) for open/keep-alive connections
# to drain on server.close() before forcing them closed and finishing the
# flush/close sequence anyway (default: 10000 = 10s).
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ All configuration is via environment variables. See `.env.example` for the full
| `QUERY_TIMEOUT_MS` | No | `10000` (10s) | Execution deadline for a `GET /records` or `POST /records/query` search, timed from when it reaches a worker. Exceeding it answers `503` (code `timeout`). See [Deployment: bounding query cost](./docs/deployment.md#bounding-query-cost). |
| `QUERY_WORKER_POOL_SIZE` | No | `2` | Number of worker threads a slow search can run on without blocking other requests (max 32). See [Deployment: bounding query cost](./docs/deployment.md#bounding-query-cost). |
| `QUERY_QUEUE_LIMIT` | No | `64` | Searches allowed to queue for a worker before the server sheds load with `503` (code `timeout`). See [Deployment: bounding query cost](./docs/deployment.md#bounding-query-cost). |
| `SEED_COMMONS_TYPES` | No | `false` | Registers the [Schema Commons](https://github.com/haverstack/core/blob/main/docs/commons/README.md) types from `@haverstack/commons` on startup. See [Deployment: Schema Commons seeding](./docs/deployment.md#schema-commons-seeding). |
| `SHUTDOWN_TIMEOUT_MS` | No | `10000` (10s) | How long shutdown waits for open connections to drain before forcing them closed and finishing cleanup anyway. |

---
Expand Down
6 changes: 6 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,3 +204,9 @@ Read access is unaffected either way: `GET /records`, `POST /records/query`, `GE
## Public endpoints

`GET /.well-known/stack` is intentionally public and unauthenticated. It exposes the owner entity ID, configured timezone, and capability list. This information is required by `@haverstack/adapter-api` to bootstrap a client connection. If your stack is private, ensure the endpoint is only reachable by intended clients (e.g. by network policy) rather than by auth-gating it.

## Schema Commons seeding

`SEED_COMMONS_TYPES=true` registers the [Schema Commons](https://github.com/haverstack/core/blob/main/docs/commons/README.md) types (`org.haverstack/note`, `bookmark`, `task`, `contact`, `article`, `place`, `page`, `photo`) from `@haverstack/commons` on every boot, via `defineType()`, which is idempotent by construction — safe to leave on permanently, and safe to flip on for an already-running stack.

It defaults to **off**. Registering a type is not free: it shows up in `GET /types` and gets cached by every app that talks to this stack, whether or not that app uses it. More importantly, `@haverstack/commons` is Draft status — the [governance doc](https://github.com/haverstack/core/blob/main/docs/commons/README.md) explicitly reserves the right to change a Draft type's definition in place, without a version bump, until there's an install base to break. Opting in is the honest posture for a package with that status; turning it on is a statement that you want this reference server to demonstrate commons interop, not a default every deployer should inherit silently.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@haverstack/adapter-local": "^0.10.0",
"@haverstack/commons": "^0.3.0",
"@haverstack/core": "^0.11.1",
"@haverstack/wire-types": "^0.9.0",
"@hono/node-server": "^2.1.1",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type Config = {
queryTimeoutMs: number;
queryWorkerPoolSize: number;
queryQueueLimit: number;
seedCommonsTypes: boolean;
shutdownTimeoutMs: number;
};

Expand Down Expand Up @@ -135,6 +136,14 @@ export function loadConfig(): Config {
throw new Error(`Invalid SHUTDOWN_TIMEOUT_MS: ${process.env['SHUTDOWN_TIMEOUT_MS']}`);
}

// Opt-in: @haverstack/commons is Draft status (docs/commons/README.md in
// haverstack/core reserves the right to change definitions in place until
// there's an install base), and registering types is not free — they show
// up in GET /types and every app's type cache. Off by default keeps that
// an explicit choice rather than something this reference server defaults
// on for every deployer.
const seedCommonsTypes = optional('SEED_COMMONS_TYPES', 'false') === 'true';

// Required, not auto-detected: the DID challenge-response handshake signs
// a payload scoped to this server's own public origin, and that origin
// must come from configuration rather than a client-controlled request
Expand Down Expand Up @@ -169,6 +178,7 @@ export function loadConfig(): Config {
queryTimeoutMs,
queryWorkerPoolSize,
queryQueueLimit,
seedCommonsTypes,
shutdownTimeoutMs,
};
}
19 changes: 19 additions & 0 deletions src/stack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local';
import {
ARTICLE,
BOOKMARK,
CONTACT,
NOTE,
PAGE,
PHOTO,
PLACE,
TASK,
defineCommonsTypes,
} from '@haverstack/commons';
import { Stack } from '@haverstack/core';
import type { StackTokenStore } from '@haverstack/core/wire';
import type { Logger } from 'pino';
Expand Down Expand Up @@ -62,6 +73,14 @@ export async function initStack(config: Config, logger: Logger): Promise<StackCo
: undefined,
);

// Opt-in (SEED_COMMONS_TYPES) — see config.ts. defineCommonsTypes() is a
// thin loop over stack.defineType(), which is idempotent by construction
// (an identical schema is a no-op preserving createdAt), so this is safe
// on every boot, same as the system-type seeding above.
if (config.seedCommonsTypes) {
await defineCommonsTypes(stack, [NOTE, BOOKMARK, TASK, CONTACT, ARTICLE, PLACE, PAGE, PHOTO]);
}

// Composed as a separate part rather than sniffed off the adapter: auth
// material lives in its own file beside stack.db (not inside the
// portable stack export) per docs/spec/wire-format.md § Authentication.
Expand Down
1 change: 1 addition & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function testConfig(dbPath: string, opts: TestContextOpts = { timezone: '
queryTimeoutMs: 10_000,
queryWorkerPoolSize: 1,
queryQueueLimit: 64,
seedCommonsTypes: false,
shutdownTimeoutMs: 10_000,
};
}
Expand Down
58 changes: 58 additions & 0 deletions tests/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,64 @@ describe('initStack bootstrap', () => {
mismatched.nonces.close();
});

it('does not register commons types when SEED_COMMONS_TYPES is unset', async () => {
dbPath = tempDbPath();
const config = testConfig(dbPath);

const ctx = await initStack(config, logger);
const types = await ctx.adapter.listTypes();
expect(types.some((t) => t.id.startsWith('org.haverstack/'))).toBe(false);
await ctx.queryWorker.close();
await ctx.stack.close();
await ctx.tokens.close();
ctx.nonces.close();
});

it('registers all eight commons types when SEED_COMMONS_TYPES is set', async () => {
dbPath = tempDbPath();
const config = { ...testConfig(dbPath), seedCommonsTypes: true };

const ctx = await initStack(config, logger);
const types = await ctx.adapter.listTypes();
const commonsIds = types.filter((t) => t.id.startsWith('org.haverstack/')).map((t) => t.id);
expect(commonsIds.sort()).toEqual(
[
'org.haverstack/article@1',
'org.haverstack/bookmark@1',
'org.haverstack/contact@1',
'org.haverstack/note@1',
'org.haverstack/page@1',
'org.haverstack/photo@1',
'org.haverstack/place@1',
'org.haverstack/task@1',
].sort(),
);
await ctx.queryWorker.close();
await ctx.stack.close();
await ctx.tokens.close();
ctx.nonces.close();
});

it('re-seeding commons types on a later boot causes no createdAt churn', async () => {
dbPath = tempDbPath();
const config = { ...testConfig(dbPath), seedCommonsTypes: true };

const first = await initStack(config, logger);
const firstType = await first.adapter.getType('org.haverstack/note@1');
await first.queryWorker.close();
await first.stack.close();
await first.tokens.close();
first.nonces.close();

const second = await initStack({ ...config, entityId: null }, logger);
const secondType = await second.adapter.getType('org.haverstack/note@1');
expect(secondType?.createdAt).toEqual(firstType?.createdAt);
await second.queryWorker.close();
await second.stack.close();
await second.tokens.close();
second.nonces.close();
});

it('creates the owner _entity record when OWNER_NAME is configured', async () => {
dbPath = tempDbPath();
const config = { ...testConfig(dbPath), ownerName: 'Jane Owner', ownerHandle: '@jane' };
Expand Down
Loading