From 528d6aff624c4a6c909dcdaf6fd4dd228498fdec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 19:22:11 +0000 Subject: [PATCH] Seed Schema Commons types behind SEED_COMMONS_TYPES flag Registers the eight Draft-status org.haverstack types from @haverstack/commons on startup when opted in, defaulting off since the package is Draft status and registering a type is not free (GET /types, every app's type cache). defineType() is idempotent by construction, so this is safe on every boot. Closes #59 --- .env.example | 7 ++++++ README.md | 1 + docs/deployment.md | 6 +++++ package.json | 1 + pnpm-lock.yaml | 10 ++++++++ src/config.ts | 10 ++++++++ src/stack.ts | 19 +++++++++++++++ tests/setup.ts | 1 + tests/stack.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 113 insertions(+) diff --git a/.env.example b/.env.example index 337e499..4636fea 100644 --- a/.env.example +++ b/.env.example @@ -74,5 +74,12 @@ 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 + # See docs/deployment.md for production deployment guidance (TLS, CORS, # rate limiting, and public endpoint exposure). diff --git a/README.md b/README.md index 2a3cde1..16b9f0f 100644 --- a/README.md +++ b/README.md @@ -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). | --- diff --git a/docs/deployment.md b/docs/deployment.md index 39cb935..511f520 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -204,3 +204,9 @@ Read access is unaffected either way: `GET /records`, `GET /records/:id` and `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. diff --git a/package.json b/package.json index afb8c3d..e44bcd6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8d9c19..32865e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@haverstack/adapter-local': specifier: ^0.10.0 version: 0.10.0 + '@haverstack/commons': + specifier: ^0.3.0 + version: 0.3.0 '@haverstack/core': specifier: ^0.11.1 version: 0.11.1 @@ -268,6 +271,9 @@ packages: '@haverstack/blob-adapter-disk@0.9.0': resolution: {integrity: sha512-/TLyuqzG6yfuAfSRGoMq0FxTryAE2DzYKId6pc4eqggYTeru6huHxHjmOwterVy2mt/CInpBmhF5W3j2pBMEdw==} + '@haverstack/commons@0.3.0': + resolution: {integrity: sha512-tqjbJJw9DvfgSxMWANrPXTLj22Skqq1SU+P8vFVJgtWqJvZLSJjpoPamyd7qacDnj4eYtv3ejpEMDJFv2dI3xg==} + '@haverstack/conformance-fixtures@0.3.0': resolution: {integrity: sha512-CVuLTol0+gaN2xJRvcErbyHWvUXO4PR3+ViGIxQVu6tHthMWMv+XSiVcksdn10Fbir+fFmjYqMLxeCcr4c5fqQ==} @@ -1265,6 +1271,10 @@ snapshots: dependencies: '@haverstack/core': 0.11.1 + '@haverstack/commons@0.3.0': + dependencies: + '@haverstack/core': 0.11.1 + '@haverstack/conformance-fixtures@0.3.0': dependencies: '@haverstack/wire-types': 0.9.0 diff --git a/src/config.ts b/src/config.ts index da7a01c..b7ffc6f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,6 +38,7 @@ export type Config = { queryTimeoutMs: number; queryWorkerPoolSize: number; queryQueueLimit: number; + seedCommonsTypes: boolean; }; export function loadConfig(): Config { @@ -127,6 +128,14 @@ export function loadConfig(): Config { // header (Host, X-Forwarded-Host) — deriving it from a header would let a // client choose which origin it signs for, reopening the relay attack the // binding exists to prevent. See docs/spec/wire-format.md § Authentication. + // 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'; + const baseUrl = required('BASE_URL'); let authOrigin: string; try { @@ -155,5 +164,6 @@ export function loadConfig(): Config { queryTimeoutMs, queryWorkerPoolSize, queryQueueLimit, + seedCommonsTypes, }; } diff --git a/src/stack.ts b/src/stack.ts index fb1f11d..0167cb3 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -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'; @@ -62,6 +73,14 @@ export async function initStack(config: Config, logger: Logger): Promise { 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' };