diff --git a/README.md b/README.md index 7d791d6..32dceba 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ This is a monorepo. Packages are published to npm under the `@haverstack` scope. | [`@haverstack/record-adapter-sqlite`](./packages/record-adapter-sqlite) | Node native SQLite (`node:sqlite`) `StackRecordAdapter` — used by `adapter-local` | | [`@haverstack/blob-adapter-disk`](./packages/blob-adapter-disk) | Disk filesystem `StackBlobAdapter` | | [`@haverstack/adapter-api`](./packages/adapter-api) | HTTP adapter for remote stack servers | +| [`@haverstack/commons`](./packages/commons) | Canonical Schema Commons type definitions (`note`, `task`, `contact`, ...) | Planned: @@ -288,6 +289,10 @@ packages/ src/ index.ts # APIAdapter (StackAdapter) tests/ + commons/ # @haverstack/commons + src/ + index.ts # Canonical Schema Commons type constants + defineCommonsTypes() + tests/ ``` --- diff --git a/docs/commons/README.md b/docs/commons/README.md index 9082518..0f0b651 100644 --- a/docs/commons/README.md +++ b/docs/commons/README.md @@ -32,7 +32,7 @@ org.haverstack/contact@1 org.haverstack/article@1 org.haverstack/place@1 org.haverstack/page@1 -org.haverstack/photo@1 (staged — see below) +org.haverstack/photo@1 org.haverstack/message@1 (proposed — see below) org.haverstack/event@1 (proposed) org.haverstack/poll@1 (proposed) @@ -140,7 +140,7 @@ authority on **any** record, of any type: - **`embed`** — `{ kind: 'attachment', label: 'embed', fileId }` marks a file referenced from a record's body text (`note`, `article`, `page`, `message`). How the body refers to the embed is app territory in v1; a commons syntax is an expected - follow-up once `file-ref` fields (#63) land. + follow-up proposal. - **`series`** — `{ kind: 'relationship', label: 'series', recordId }` groups records that are occurrences of one recurring thing (materialized [`event`](./event.md) occurrences are the motivating case). Reserved now so recurrence proposals build on @@ -217,7 +217,7 @@ shape** — notes are kept, messages are sent, articles are published. | `org.haverstack/article@1` | [`article.md`](./article.md) | Draft | `{ title, text }` | | `org.haverstack/place@1` | [`place.md`](./place.md) | Draft | `{ latitude, longitude }` | | `org.haverstack/page@1` | [`page.md`](./page.md) | Draft | `{ slug, text }` | -| `org.haverstack/photo@1` | [`photo.md`](./photo.md) | Staged | `{ image }` (pending #63) | +| `org.haverstack/photo@1` | [`photo.md`](./photo.md) | Draft | `{ image }` | | `org.haverstack/message@1` | [`message.md`](./message.md) | Proposed | `{ text }` | | `org.haverstack/event@1` | [`event.md`](./event.md) | Proposed | `{ title, startsAt }` | | `org.haverstack/poll@1` | [`poll.md`](./poll.md) | Proposed | `{ question, options }` | @@ -226,24 +226,29 @@ shape** — notes are kept, messages are sent, articles are published. **Statuses.** _Draft_: settled enough to build against (still subject to in-place change until there's an install base). _Staged_: design recorded, registration blocked -on a named implementation issue (`photo` waits on #63 so its required image is -schema-enforced rather than convention-only). _Proposed_: design recorded to fix -intent, but per the governance rule it stays parked until a concrete intended writer -exists — the group cluster graduates when a group-tools app or demo is real. The group -cluster additionally depends on the grant/group reshape (#57/#58) landing as decided. +on a named implementation dependency, so a required field is schema-enforced rather +than convention-only from day one. No type currently holds this status. _Proposed_: +design recorded to fix intent, but per the governance rule it stays parked until a +concrete intended writer exists — the group cluster graduates when a group-tools app +or demo is real, building on the grant/group primitives (`_group`, type-level grants) +documented in the identity and access-control specs. Deliberately absent from the initial set: `post` (public social shapes are reconciled with the ATProto-compat RFC, #15 — `message` is the group-scoped shape, not the social one), recurrence rules (see `event`: occurrences are materialized in @1), -`file`/`document` (a first-class `file` type is expected to follow `photo`'s pattern -once #63 lands; until then a record plus attachment covers it), and `checkin` (subsumed -by the `location` cross-type convention plus any record). +`file`/`document` (a first-class `file` type is expected to follow `photo`'s pattern; +until a real writer needs it, a record plus attachment covers it), and `checkin` +(subsumed by the `location` cross-type convention plus any record). --- -## Planned tooling +## Tooling -A `@haverstack/commons` package that exports the canonical schemas as constants and a -`defineCommonsTypes(stack, [...])` helper, so "register the type exactly as written" -is a one-liner and drift is structurally impossible. Not started; the definitions in -this directory are authoritative until it exists. +[`@haverstack/commons`](../../packages/commons) exports the canonical schemas for +every Draft-status type as constants and a `defineCommonsTypes(stack, [...])` helper, +so registering a type exactly as written is a one-liner and the transcription drift +the governance process exists to prevent is structurally impossible. Apps should +depend on the package rather than transcribing a fenced code block from this +directory. These files are the design record: rationale, conventions, and read-compat +cores live here, and the package's constants are kept in lockstep with them. Proposed +types (not yet Draft) are docs-only and stay out of the package until they graduate. diff --git a/docs/commons/bookmark.md b/docs/commons/bookmark.md index 2d5652b..3ad5cd7 100644 --- a/docs/commons/bookmark.md +++ b/docs/commons/bookmark.md @@ -34,9 +34,9 @@ await stack.defineType('org.haverstack/bookmark@1', 'Bookmark', { - **Page snapshot**: an archived copy of the page's content is an attachment association with label `snapshot` (one per capture; multiple captures are multiple associations). The underlying file's `mimeType` — a property of the `_attachment@1` record, not the - association — follows the deterministic first-recorded rule (#65); apps should prefer + association — follows the deterministic first-recorded rule; apps should prefer archival-friendly types (PDF, WARC, single-file HTML stored as an attachment — served - under the #66 safe-list rules). + under the dangerous-type safe-list rules). - **Favicon / preview image**: attachment association with label `preview`. ## Read-compat core diff --git a/docs/commons/contact.md b/docs/commons/contact.md index 54b5c7d..11dd497 100644 --- a/docs/commons/contact.md +++ b/docs/commons/contact.md @@ -61,11 +61,10 @@ or via associations, not by address. `{ kind: 'relationship', label: 'entity', recordId: <_entity record id> }` on the contact. This keeps the directory entry (mutable, user-owned petname territory) apart from the identity record, in line with the properties-vs-perspectives principle and - the DID identity direction (#49): your name for someone is your perspective; their key - is a property. + the DID identity model: your name for someone is your perspective; their key is a + property. - **Grouping** (family, team, club): tag associations for casual grouping. A commons - position on linking contacts to `_group` records is deferred until the group reshape - (#58) settles. + position on linking contacts to `_group` records has not been proposed yet. - **Birthdays**: excluded from @1 — calendar-shaped data (partial dates, year-unknown birthdays, recurrence) belongs with the future `event` proposal. @@ -84,8 +83,8 @@ lead — can be listed by a contacts consumer. - Postal addresses — genuinely structured, rarely shared between apps; future proposal with vCard `ADR` as prior art. - `birthday` and dates — see conventions. -- Any identity/key material — that is `_entity`/#49 territory; a contact asserts - nothing cryptographic. +- Any identity/key material — that is `_entity` territory; a contact asserts nothing + cryptographic. ## Changelog diff --git a/docs/commons/event.md b/docs/commons/event.md index dda6dc1..f4f5d09 100644 --- a/docs/commons/event.md +++ b/docs/commons/event.md @@ -29,7 +29,7 @@ await stack.defineType('org.haverstack/event@1', 'Event', { | Field | Kind | Required | Meaning | | ------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `title` | `string` | yes | What it is ("Monthly meeting", "Spring cleanup day"). | -| `startsAt` | `date` | yes | When it begins — an absolute instant (ISO 8601, per #69). An event is meaningless without a time. | +| `startsAt` | `date` | yes | When it begins — an absolute instant (ISO 8601). An event is meaningless without a time. | | `endsAt` | `date` | no | When it ends. Absent means unspecified duration (a deadline, a "starts at 7pm" with no stated end). | | `allDay` | `boolean` | no | Absent means `false`. When `true`, times-of-day are ignored: the event occupies the calendar date(s) of `startsAt`(–`endsAt`) interpreted in the stack's configured timezone. | | `description` | `text` | no | Details. Markdown by convention. | diff --git a/docs/commons/folder.md b/docs/commons/folder.md index 4d714bb..a0bfd11 100644 --- a/docs/commons/folder.md +++ b/docs/commons/folder.md @@ -45,15 +45,14 @@ await stack.defineType('org.haverstack/folder@1', 'Folder', { - **A "file" in the drive** is, today, any record carrying an attachment association — typically a `note` with the file attached and the body as commentary. A first-class `file` type (a required `file-ref` plus name) is expected to follow - [`photo`](./photo.md)'s pattern once #63 lands; this file's conventions will be - amended then. + [`photo`](./photo.md)'s pattern; this file's conventions will be amended when a real + writer needs it. - **Folder-level permissions do not exist** — permissions are per-record and grants are per-type; a folder's `permissions` field governs the folder record itself, **not** its contents. Apps must not present a folder as an access boundary unless they also set permissions on every contained record. Recorded bluntly because every user will - assume otherwise; whether reference-implies-access (#51) or a future inherited model - changes this is an open question for the permissions work, not something this type - can promise. + assume otherwise: the permission model has no reference-implies-access or inherited + mechanism, so a folder's own access and its contents' access are always independent. - **Moving** is re-parenting — one field write, atomic, history preserved. ## Read-compat core diff --git a/docs/commons/message.md b/docs/commons/message.md index 015b04e..d8cd5cd 100644 --- a/docs/commons/message.md +++ b/docs/commons/message.md @@ -70,7 +70,8 @@ write, which is the whole reason this type needs no author field. - **Comments across trust boundaries**: a public blog's comment section is this same shape living in the author's personal stack — commenters are entities holding a `create` grant on `message@1`. Webmention-grade interop with real primitives - underneath; making that practical for strangers depends on the identity work (#49). + underneath, practical for strangers because entity identity is a self-certifying DID + (a public key, nothing else) rather than an account in some directory. ## Read-compat core @@ -85,7 +86,8 @@ discussion. - Social-post semantics — #15 territory (see above). - Reactions — a future micro-proposal (likely tag associations by non-authors — which - needs the association-permission rules from the #57/#58 reshape to settle first). + needs finer-grained association permissions than today's `update-own`/`update-any` + grant actions provide). - Chat features (presence, ephemerality, delivery/read receipts) — out of scope by design posture, not by omission. - `to`/`cc` addressing — presence in the group's stack is the addressing. diff --git a/docs/commons/note.md b/docs/commons/note.md index 8626fca..978edbd 100644 --- a/docs/commons/note.md +++ b/docs/commons/note.md @@ -39,7 +39,7 @@ await stack.defineType('org.haverstack/note@1', 'Note', { associations with label `embed` — see the cross-type conventions in the [README](./README.md). How the body refers to an embedded file (e.g. a markdown image reference) is app territory in @1; a commons convention here is an expected - follow-up proposal once `file-ref` fields (#63) land. + follow-up proposal. - **Cross-references**: a note that is _about_ another record (annotating a bookmark, a contact) uses `{ kind: 'relationship', label: 'about', recordId }`. @@ -51,8 +51,7 @@ await stack.defineType('org.haverstack/note@1', 'Note', { Consumers wanting maximum reach should accept any type compatible with this shape and treat `title`/`format` as optional enrichments. Note that `text` ⇄ `string` are mutually -readable under the strengthened `isCompatible` relation (#54), so short-string types -also qualify. +readable under `isCompatible()`, so short-string types also qualify. ## Deliberately excluded @@ -60,7 +59,7 @@ also qualify. - `tags: string[]` — tag associations exist. - `pinned`, `archived`, `color` — perspectives; app sidecar or tags. - Rich-text formats beyond markdown/plain (HTML in particular) — HTML notes are an - XSS-shaped liability the dangerous-type work (#66) exists to avoid; apps holding HTML + XSS-shaped liability the dangerous-type safeguards exist to avoid; apps holding HTML should convert on write. ## Changelog diff --git a/docs/commons/photo.md b/docs/commons/photo.md index 63136b7..13d7b5d 100644 --- a/docs/commons/photo.md +++ b/docs/commons/photo.md @@ -1,24 +1,19 @@ # `org.haverstack/photo@1` — Photo -> **Status:** Staged — blocked on `file-ref` fields (#63). The design is recorded now -> so intent is fixed; **do not register this type until #63 lands** and this file is -> promoted to Draft. +> **Status:** Draft. An image as a first-class object — the photo-library / Tumblr-photo-post shape — as opposed to an image illustrating some other record (which is just an attachment association on that record). A photo record is "this image is in my library": the binary plus caption, alt text, and capture time. -## Why staged - The essential field of a photo is the image itself, but type schemas validate `content` only: a "required attachment association" would be a convention the schema can't -enforce and `isCompatible()` can't see. Issue #63's `file-ref` field kind fixes exactly -this — making `photo` its first and motivating consumer. Shipping earlier with a -convention-only image, then making a `file-ref` field required later, would force a -version bump for no gain; waiting means `photo@1` is right from day one. +enforce and `isCompatible()` can't see. The `file-ref` field kind fixes exactly this, +making `photo` its first and motivating consumer — the required `image` field below is +schema-enforced, not convention-only. -## Schema (pending #63) +## Schema ```ts await stack.defineType('org.haverstack/photo@1', 'Photo', { @@ -38,7 +33,7 @@ await stack.defineType('org.haverstack/photo@1', 'Photo', { | `alt` | `string` | no | Accessibility description of the image content — a property of the image (describes the bytes), not a perspective; travels with it. | | `takenAt` | `date` | no | Capture time (typically from EXIF). Distinct from `createdAt`, which is when the record entered the stack (import time). | -## Conventions (to finalize at promotion) +## Conventions - **Geotag**: the cross-type `location` relationship to a [`place`](./place.md) record — not raw coordinates in content. @@ -52,7 +47,7 @@ await stack.defineType('org.haverstack/photo@1', 'Photo', { - **Video/audio**: sibling proposals, not a generalized `media` type — photo-specific prior art (EXIF, photo libraries) is too good to dilute. -## Read-compat core (pending #63) +## Read-compat core ```ts { image: { kind: 'file-ref', required: true } } @@ -60,4 +55,7 @@ await stack.defineType('org.haverstack/photo@1', 'Photo', { ## Changelog -- **Staged** — design recorded; registration blocked on #63 (`file-ref` field kind). +- **Draft** — graduated from Staged: the `file-ref` field kind now exists, so `image` + is schema-enforced as specified, unchanged from the staged design. +- **Staged** — design recorded; registration blocked on the `file-ref` field kind not + yet existing. diff --git a/docs/commons/task.md b/docs/commons/task.md index f419648..458b1e0 100644 --- a/docs/commons/task.md +++ b/docs/commons/task.md @@ -20,13 +20,13 @@ await stack.defineType('org.haverstack/task@1', 'Task', { ## Field semantics -| Field | Kind | Required | Meaning | -| ------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `title` | `string` | yes | What needs doing. Single line by convention. | -| `done` | `boolean` | yes | Whether it's complete. Required (not optional-absent-means-open) so that it is a top-level scalar every adapter can filter on: `content: { done: false }` is _the_ query of every task app. | -| `notes` | `text` | no | Longer description. Markdown by convention. | -| `due` | `date` | no | When it's due. A deadline, not a calendar block — tasks with rich scheduling belong to a future `event` proposal. | -| `completedAt` | `date` | no | When it was completed. Writers should set it when setting `done: true` and clear it (write `null`, per the #69 merge-patch semantics) when un-completing. If `done` and `completedAt` disagree, `done` wins. | +| Field | Kind | Required | Meaning | +| ------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | `string` | yes | What needs doing. Single line by convention. | +| `done` | `boolean` | yes | Whether it's complete. Required (not optional-absent-means-open) so that it is a top-level scalar every adapter can filter on: `content: { done: false }` is _the_ query of every task app. | +| `notes` | `text` | no | Longer description. Markdown by convention. | +| `due` | `date` | no | When it's due. A deadline, not a calendar block — tasks with rich scheduling belong to a future `event` proposal. | +| `completedAt` | `date` | no | When it was completed. Writers should set it when setting `done: true` and clear it (write `null`, per merge-patch semantics) when un-completing. If `done` and `completedAt` disagree, `done` wins. | ## Conventions @@ -57,8 +57,9 @@ await stack.defineType('org.haverstack/task@1', 'Task', { it in a sidecar and projects it to `done`. - Recurrence — recurrence rules are the hardest part of iCalendar for a reason; they go with the future `event` proposal, not here. -- `assignee` — `entityId` is the author; assignment semantics inside a shared stack - should be designed alongside the group/grant reshape (#57/#58), not guessed at now. +- `assignee` — `entityId` is the author; assignment semantics inside a shared stack are + a future proposal building on the existing grant/group model (`_group`, type-level + grants), not guessed at now. ## Changelog diff --git a/docs/commons/text-types.md b/docs/commons/text-types.md index 179eab0..c2d5e5f 100644 --- a/docs/commons/text-types.md +++ b/docs/commons/text-types.md @@ -121,25 +121,25 @@ four mechanical reasons, not just taxonomy: exists to express. Posts-as-messages would hand board apps threads whose parents they structurally cannot traverse. 3. **Different deletion physics.** Inside a stack, recoverability is real: "anything a - write-holder does, the owner can undo" (#59). Broadcast breaks it — the network has + write-holder does, the owner can undo." Broadcast breaks it — the network has copies; deletion is a request (ATProto's tombstones exist because of this). The type boundary keeps the commons honest about which text lives under which physics. 4. **Different authorship requirements.** In-stack, `entityId` means author because the stack is a trust domain. A broadcast utterance travels _without_ its stack, so - authorship must be self-certifying — the #49 DID work, surfaced in #15's revised - `externalIds` on `EntityContent`. `message` needs none of it; `post` can't exist - without it. + authorship must be self-certifying — the DID identity model used for `entityId` + generally, surfaced in #15's revised `externalIds` on `EntityContent`. `message` + needs none of it; `post` can't exist without it. The dependency chain is therefore: **#16** (cross-stack/cross-protocol reference fabric, plus its `relatedTo`/capability follow-up so external references are -queryable) → **#49** (self-certified identity) → **#15 as revised** (protocol-neutral -core hooks; ATProto-specific machinery in `adapter-atproto`) → a `post@1` proposal -here, as the _protocol-neutral_ broadcast utterance: the canonical copy lives in your -stack; bridges syndicate it (`adapter-atproto` maps it to `app.bsky.feed.post`, an -ActivityPub bridge to a `Note`) and replies come home as external-target -relationships. That is the IndieWeb's POSSE pattern — publish on your own site, -syndicate elsewhere — with real primitives underneath: Bluesky and Mastodon become -views of a record you own. +queryable) → **#15 as revised** (protocol-neutral core hooks, building on the +self-certifying DID identity model; ATProto-specific machinery in `adapter-atproto`) → +a `post@1` proposal here, as the _protocol-neutral_ broadcast +utterance: the canonical copy lives in your stack; bridges syndicate it +(`adapter-atproto` maps it to `app.bsky.feed.post`, an ActivityPub bridge to a `Note`) +and replies come home as external-target relationships. That is the IndieWeb's POSSE +pattern — publish on your own site, syndicate elsewhere — with real primitives +underneath: Bluesky and Mastodon become views of a record you own. One forward-compatibility note: `message`'s quote-reply convention uses today's flat relationship shape (`recordId`). Commons labels (`reply-to`, `about`, `location`, diff --git a/package.json b/package.json index d4ab2ec..0208bc4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "publish:adapter-local": "pnpm --filter @haverstack/adapter-local publish --access public", "publish:blob-adapter-disk": "pnpm --filter @haverstack/blob-adapter-disk publish --access public", "publish:api": "pnpm --filter @haverstack/adapter-api publish --access public", - "publish:wire-types": "pnpm --filter @haverstack/wire-types publish --access public" + "publish:wire-types": "pnpm --filter @haverstack/wire-types publish --access public", + "publish:commons": "pnpm --filter @haverstack/commons publish --access public" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/commons/README.md b/packages/commons/README.md new file mode 100644 index 0000000..6e22f4f --- /dev/null +++ b/packages/commons/README.md @@ -0,0 +1,59 @@ +# @haverstack/commons + +Canonical [Schema Commons](https://github.com/haverstack/core/tree/main/docs/commons) +type definitions for Haverstack. + +The commons namespace `org.haverstack` is reserved for a small set of well-known, +neutrally-governed types (`note`, `bookmark`, `task`, ...) so that apps sharing a stack +can interoperate on the data shapes almost every personal app touches. That only works +if every app registers a commons type **exactly as written** — a modified copy under +the same `typeId` is schema drift, and the drift guard will (correctly) reject it. This +package makes that structural rather than social: import the constant, don't +hand-transcribe a schema out of markdown. + +> **Status:** Early development. Exports only Draft-status types — see +> [`docs/commons/README.md`](https://github.com/haverstack/core/blob/main/docs/commons/README.md) +> for what Draft/Staged/Proposed mean. Proposed types have no intended writer yet and +> stay docs-only until they graduate. + +## Installation + +```sh +npm install @haverstack/commons +``` + +## Usage + +```ts +import { Stack } from '@haverstack/core'; +import { defineCommonsTypes, NOTE, TASK } from '@haverstack/commons'; + +// Register only the commons types your app actually writes. +await defineCommonsTypes(stack, [NOTE, TASK]); + +const note = await stack.create(NOTE.id, { text: 'Hello, Haverstack!' }); +``` + +Each export (`NOTE`, `BOOKMARK`, `TASK`, `CONTACT`, `ARTICLE`, `PLACE`, `PAGE`, `PHOTO`) +is a `{ id, name, schema }` triple mirroring its type's `stack.defineType(...)` block in +[`docs/commons/`](https://github.com/haverstack/core/tree/main/docs/commons) exactly. +`defineCommonsTypes()` is a thin loop over `stack.defineType()` — calling it again with +types already registered is the ordinary idempotent no-op path (see `defineType`'s own +docs), so it's safe to call on every app startup. + +## Governance + +The schemas here are not this package's to change. A type's shape, required core, and +conventions are decided by the process in +[`docs/commons/README.md` § Governance](https://github.com/haverstack/core/blob/main/docs/commons/README.md#governance) — +propose additions or changes there, as an issue on `haverstack/core` titled +`Commons: `. This package's constants are updated to match once a proposal +lands; it never carries a schema the docs don't also carry. + +## License + +[CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/) — public domain. + +## Monorepo + +Part of [haverstack/core](https://github.com/haverstack/core). diff --git a/packages/commons/package.json b/packages/commons/package.json new file mode 100644 index 0000000..5dc369c --- /dev/null +++ b/packages/commons/package.json @@ -0,0 +1,44 @@ +{ + "name": "@haverstack/commons", + "version": "0.1.0", + "description": "Canonical Schema Commons type definitions for Haverstack — register commons types exactly as written", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/haverstack/core", + "directory": "packages/commons" + }, + "license": "CC0-1.0", + "keywords": [ + "haverstack", + "personal data", + "schema commons", + "interop" + ], + "scripts": { + "prepublishOnly": "pnpm run build", + "build": "tsc -p tsconfig.build.json", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "eslint src tests" + }, + "dependencies": { + "@haverstack/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.5.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts new file mode 100644 index 0000000..49d019b --- /dev/null +++ b/packages/commons/src/index.ts @@ -0,0 +1,150 @@ +/** + * @haverstack/commons + * ------------------------------------------------------- + * Canonical Schema Commons type definitions — see docs/commons/README.md for + * the design rules and governance process these schemas are bound by. + * + * Each export mirrors the fenced `stack.defineType(...)` block in its type's + * doc file exactly. Apps register commons types by passing these constants + * to `defineCommonsTypes()` rather than hand-copying schemas out of + * markdown, which is the drift the Schema Commons' governance process exists + * to prevent. + * + * Only Draft-status types are exported here. Proposed types (not yet backed + * by a concrete intended writer) stay docs-only until they graduate. + */ + +import type { Stack, StackType, TypeId, TypeSchema } from '@haverstack/core'; + +export type CommonsType = { + readonly id: TypeId; + readonly name: string; + readonly schema: TypeSchema; +}; + +const labeledValue = { + kind: 'object', + properties: { + value: { kind: 'string', required: true }, + label: { kind: 'string' }, + }, +} as const; + +export const NOTE: CommonsType = { + id: 'org.haverstack/note@1', + name: 'Note', + schema: { + text: { kind: 'text', required: true }, + title: { kind: 'string' }, + format: { kind: 'string' }, + }, +}; + +export const BOOKMARK: CommonsType = { + id: 'org.haverstack/bookmark@1', + name: 'Bookmark', + schema: { + url: { kind: 'string', required: true }, + title: { kind: 'string' }, + description: { kind: 'text' }, + }, +}; + +export const TASK: CommonsType = { + id: 'org.haverstack/task@1', + name: 'Task', + schema: { + title: { kind: 'string', required: true }, + done: { kind: 'boolean', required: true }, + notes: { kind: 'text' }, + due: { kind: 'date' }, + completedAt: { kind: 'date' }, + }, +}; + +export const CONTACT: CommonsType = { + id: 'org.haverstack/contact@1', + name: 'Contact', + schema: { + name: { kind: 'string', required: true }, + emails: { kind: 'array', items: labeledValue }, + phones: { kind: 'array', items: labeledValue }, + urls: { kind: 'array', items: labeledValue }, + org: { kind: 'string' }, + note: { kind: 'text' }, + }, +}; + +export const ARTICLE: CommonsType = { + id: 'org.haverstack/article@1', + name: 'Article', + schema: { + title: { kind: 'string', required: true }, + text: { kind: 'text', required: true }, + format: { kind: 'string' }, + summary: { kind: 'text' }, + url: { kind: 'string' }, + author: { kind: 'string' }, + publishedAt: { kind: 'date' }, + }, +}; + +export const PLACE: CommonsType = { + id: 'org.haverstack/place@1', + name: 'Place', + schema: { + latitude: { kind: 'number', required: true }, + longitude: { kind: 'number', required: true }, + name: { kind: 'string' }, + address: { kind: 'string' }, + url: { kind: 'string' }, + }, +}; + +export const PAGE: CommonsType = { + id: 'org.haverstack/page@1', + name: 'Page', + schema: { + slug: { kind: 'string', required: true }, + text: { kind: 'text', required: true }, + title: { kind: 'string' }, + format: { kind: 'string' }, + publishedAt: { kind: 'date' }, + collection: { + kind: 'object', + properties: { + typeId: { kind: 'string', required: true }, + tag: { kind: 'string' }, + order: { kind: 'string' }, + }, + }, + }, +}; + +export const PHOTO: CommonsType = { + id: 'org.haverstack/photo@1', + name: 'Photo', + schema: { + image: { kind: 'file-ref', required: true }, + caption: { kind: 'text' }, + alt: { kind: 'string' }, + takenAt: { kind: 'date' }, + }, +}; + +/** + * Registers each given commons type on `stack` via `defineType()`, exactly + * as written here. Sequential, matching `Stack`'s own system-type seeding — + * each call is independent, but running in order keeps the returned array + * predictable. + */ +export const defineCommonsTypes = async ( + stack: Stack, + types: readonly CommonsType[], +): Promise => { + const defined: StackType[] = []; + for (const type of types) { + defined.push(await stack.defineType(type.id, type.name, type.schema)); + } + return defined; +}; diff --git a/packages/commons/tests/index.test.ts b/packages/commons/tests/index.test.ts new file mode 100644 index 0000000..f11e324 --- /dev/null +++ b/packages/commons/tests/index.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { Stack, isCompatible } from '@haverstack/core'; +import type { TypeSchema } from '@haverstack/core'; +import { MemoryAdapter } from '@haverstack/core/testing'; +import { + NOTE, + BOOKMARK, + TASK, + CONTACT, + ARTICLE, + PLACE, + PAGE, + PHOTO, + defineCommonsTypes, +} from '../src/index.js'; + +const ALL = [NOTE, BOOKMARK, TASK, CONTACT, ARTICLE, PLACE, PAGE, PHOTO]; + +let adapter: MemoryAdapter; +let stack: Stack; + +beforeEach(async () => { + adapter = new MemoryAdapter({ ownerEntityId: 'owner-123', timezone: 'UTC' }); + stack = await Stack.create(adapter); +}); + +describe('commons type ids', () => { + test('every type is namespaced under org.haverstack and at version 1', () => { + for (const type of ALL) { + expect(type.id).toMatch(/^org\.haverstack\/[a-z]+@1$/); + } + }); + + test('ids are unique', () => { + expect(new Set(ALL.map((t) => t.id)).size).toBe(ALL.length); + }); +}); + +describe('defineCommonsTypes', () => { + test('registers exactly the given types, unchanged on redefinition', async () => { + const defined = await defineCommonsTypes(stack, [NOTE, TASK]); + expect(defined.map((t) => t.id)).toEqual([NOTE.id, TASK.id]); + expect(await stack.getType(BOOKMARK.id)).toBeNull(); + + // Redefining is the idempotent no-op path — StackSchemaDriftError would + // throw here if the constant ever drifted from what was first written. + await expect(defineCommonsTypes(stack, [NOTE, TASK])).resolves.toBeDefined(); + }); + + test('registers every commons type without drift errors', async () => { + const defined = await defineCommonsTypes(stack, ALL); + expect(defined).toHaveLength(ALL.length); + }); +}); + +describe('read-compat cores', () => { + const cores: Record = { + [NOTE.id]: { text: { kind: 'text', required: true } }, + [BOOKMARK.id]: { url: { kind: 'string', required: true } }, + [TASK.id]: { + title: { kind: 'string', required: true }, + done: { kind: 'boolean', required: true }, + }, + [CONTACT.id]: { name: { kind: 'string', required: true } }, + [ARTICLE.id]: { + title: { kind: 'string', required: true }, + text: { kind: 'text', required: true }, + }, + [PLACE.id]: { + latitude: { kind: 'number', required: true }, + longitude: { kind: 'number', required: true }, + }, + [PAGE.id]: { + slug: { kind: 'string', required: true }, + text: { kind: 'text', required: true }, + }, + [PHOTO.id]: { image: { kind: 'file-ref', required: true } }, + }; + + test('every schema satisfies its documented read-compat core', () => { + for (const type of ALL) { + expect(isCompatible(type.schema, cores[type.id])).toBe(true); + } + }); +}); + +describe('PHOTO', () => { + test('image is a required file-ref field, schema-enforced', async () => { + const defined = await defineCommonsTypes(stack, [PHOTO]); + expect(defined[0].schema.image).toEqual({ kind: 'file-ref', required: true }); + }); +}); diff --git a/packages/commons/tsconfig.build.json b/packages/commons/tsconfig.build.json new file mode 100644 index 0000000..57d0596 --- /dev/null +++ b/packages/commons/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": false + }, + "exclude": ["tests/**/*.ts"] +} diff --git a/packages/commons/tsconfig.json b/packages/commons/tsconfig.json new file mode 100644 index 0000000..dbd41f4 --- /dev/null +++ b/packages/commons/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/commons/vitest.config.ts b/packages/commons/vitest.config.ts new file mode 100644 index 0000000..8f00571 --- /dev/null +++ b/packages/commons/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), + '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), + }, + }, + test: { + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a36643d..1fe902b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,22 @@ importers: specifier: ^2.0.0 version: 2.1.9(@types/node@22.19.17) + packages/commons: + dependencies: + '@haverstack/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.17 + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^2.0.0 + version: 2.1.9(@types/node@22.19.17) + packages/conformance-fixtures: dependencies: '@haverstack/wire-types':