diff --git a/.env.example b/.env.example index 8dcfcc6..0864699 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/README.md b/README.md index 4c628f4..a68c300 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). | | `SHUTDOWN_TIMEOUT_MS` | No | `10000` (10s) | How long shutdown waits for open connections to drain before forcing them closed and finishing cleanup anyway. | --- diff --git a/docs/deployment.md b/docs/deployment.md index bf2671c..a219c64 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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. diff --git a/package.json b/package.json index 4e6e3a1..88f80e0 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 5ef93cd..c6468b2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,6 +39,7 @@ export type Config = { queryTimeoutMs: number; queryWorkerPoolSize: number; queryQueueLimit: number; + seedCommonsTypes: boolean; shutdownTimeoutMs: number; }; @@ -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 @@ -169,6 +178,7 @@ export function loadConfig(): Config { queryTimeoutMs, queryWorkerPoolSize, queryQueueLimit, + seedCommonsTypes, shutdownTimeoutMs, }; } 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' };