diff --git a/.changeset/bump-core-permissions-as-associations.md b/.changeset/bump-core-permissions-as-associations.md new file mode 100644 index 0000000..e09debb --- /dev/null +++ b/.changeset/bump-core-permissions-as-associations.md @@ -0,0 +1,37 @@ +--- +'@haverstack/eleventy': minor +--- + +Bump `@haverstack/core` to `^0.37.0` and `@haverstack/commons` to `^0.31.0`. + +**Existing stacks must be recreated.** Core 0.35.0 stores record permissions as +associations and takes `parentId` off the version tier. The SQLite schema is +`CREATE TABLE IF NOT EXISTS` only and ships no migration, so a database written +by an older core opens and then fails on the first permission write: its +`associations` table keeps a `kind` constraint that refuses `permission` and +`anyone`, and has no `related_role` or `attachment_record_id` column. + +`Permission` is gone. A record's ACL is now `AuthorityAssociation[]` — a +`permission` association whose bit is its label and whose `grantee` carries a +required `role`, or an `anyone` association spelling world-read affirmatively. +`[{ access: 'public' }]` becomes `[{ kind: 'anyone', label: 'read' }]`. The +`associations` key narrows to `DataAssociation[]`, since authority kinds now +live in the same table behind a separate key. + +The plugin reads rather than writes both sets, so this is a dependency and +documentation change: no module needed a code change, and `StackRecord`'s two +projections keep an association loop from ever seeing an ACL entry. Attachment +candidates are now ordered by core's own total order (`createdAt`, then `id`) +so two uploads landing in the same millisecond stage the same way every build. + +`docs/design.md § Rebuilding on change` is corrected, and the correction is the +part worth reading before building rebuild avoidance. Since 0.33–0.35 the +journal-tier ops — `associate`, `dissociate`, `permissions`, `reparent`, +`unlist`, `list` — leave `version` **and** `updatedAt` exactly where they stand. +The previously sketched `updatedAt: { after }` gate would therefore skip a +rebuild for precisely the edits this plugin cares most about: adding a record to +a site, scoping one with `for-site`, embedding an attachment, moving a page, or +publishing a draft. The gate now runs after load and fingerprints the index +(associations and permissions included) rather than asking the query layer a +question `updatedAt` can no longer answer. A watching `--serve` correspondingly +reads `RecordChange.ops`, never `version`. diff --git a/docs/design.md b/docs/design.md index 1f8eb07..1da8941 100644 --- a/docs/design.md +++ b/docs/design.md @@ -326,33 +326,53 @@ scale that is sub-second and fine. Two ways to do better, in increasing order of ### A "did anything change?" gate (one-shot / CI) -`RecordFilter.updatedAt` takes a `{ after: Date }`, so a build can ask whether anything -relevant changed since last time and, if not, exit before emitting — the deploy step -then sees no diff. No server, works with `LocalAdapter`. - -Sketch: - -1. After a successful build, persist the **maximum `updatedAt` actually observed** - (not `Date.now()` — a write that landed mid-build must not be skipped next time), - alongside the set of permalinks emitted. -2. Next build, for each relevant type run - `query({ filter: { typeId, updatedAt: { after: last }, includeDeleted: true, includeUnlisted: true } })`, - looping the cursor as everywhere else. -3. Empty result → skip load/resolve/emit entirely. -4. Non-empty → full build, then rewrite the state file. - -Things that make this subtle: - -- **Query every type the build reads, not just members.** A `page-meta`, `menu`, - `site`, or `_attachment` edit moves _that_ record's `updatedAt`, not the described - record's. -- **Associations count** — `associate` / `dissociate` bump `version` / `updatedAt`, so a - new `site` membership or `embed` is caught. -- **Hard deletes (purges) are not caught** — nothing is left to query. Soft deletes show - with `includeDeleted: true`; for purges, diff the persisted permalink set against what - now resolves, or accept that a purge needs a manual full rebuild. -- A query result carries no count, so the gate query obeys the same - cursor-to-exhaustion rule as the load phase. +The obvious form of this — ask `RecordFilter.updatedAt` for `{ after: lastBuild }` and +exit before loading if nothing comes back — **is not sound for this plugin**, and the +reason is worth stating because the query still typechecks and still looks right. + +Since core 0.33–0.35 the journal-tier ops (`associate`, `dissociate`, `permissions`, +`reparent`, `unlist`, `list`) leave `version` and `updatedAt` exactly where they stand. +Only `patch` and the whole-record verbs bump. Every one of those no-bump ops changes +what this plugin emits: + +| No-bump op | What it moves in the output | +| ------------------------ | ---------------------------------------------------------- | +| `associate`/`dissociate` | `site` membership, `for-site` scoping, `embed` attachments | +| `reparent` | a page's ancestor slugs, so its permalink | +| `unlist`/`list` | whether a record is in listings, feeds and the sitemap | +| `permissions` | what a non-owner build can see at all | + +So an `updatedAt` gate would skip the build for exactly the edits a site owner is most +likely to make between builds — adding an article to a site, publishing a draft, moving +a page. A gate that is wrong in the common case is worse than no gate. + +The durable record of a no-bump write is the journal, and `getJournal()` is per-record: +it answers "what happened to this record", never "which records changed". There is no +cross-record journal read, so nothing short of the load sweep can see these changes +locally. + +**The gate therefore moves after load, not before it.** Load is one bounded set of +queries and is not the slow part; resolve, asset staging and emit are. Sketch: + +1. Run load as normal. +2. Fingerprint the index: for every record, its `id`, `version`, `updatedAt`, + `parentId`, `unlistedAt`, and its `associations` and `permissions` in a canonical + order — the no-bump aspects have to be in the hash precisely because `version` + does not cover them. +3. Compare against the fingerprint persisted by the last successful build. Equal → + skip resolve/emit/staging and leave the output tree alone, so the deploy step sees + no diff. Different → full build, then rewrite the state file. + +Things that stay subtle: + +- **Load every type the build reads, not just members.** A `page-meta`, `menu`, `site`, + or `_attachment` edit moves _that_ record, not the described record — the fingerprint + covers them because load already fetches them. +- **Hard deletes (purges) are caught by this shape**, unlike the `updatedAt` gate: a + purged record simply stops appearing in the sweep, which changes the fingerprint. + Soft deletes need `includeDeleted: true` only if the build wants to distinguish them. +- A query result carries no count, so the sweep obeys the same cursor-to-exhaustion + rule as the load phase. ### A watching `--serve` (needs a stack server) @@ -376,8 +396,18 @@ the right call. needed if the integration drives Eleventy programmatically instead. - The subscription's `ChangeFilter` only narrows by `typeId` / `parentId` / `entityId` / `kinds` — not content or associations — so expect changes the current site does not - care about and let the rebuild sort it out. Publishing a record (clearing - `unlistedAt`) arrives as an ordinary `changed` event, so no special case. + care about and let the rebuild sort it out. +- **Read `RecordChange.ops`, not `version`.** A change frame carries `ops: ChangeOp[]`, + and the journal-tier ops (`associate`, `dissociate`, `permissions`, `reparent`, + `unlist`, `list`) report the record's version and `updatedAt` unchanged. A watcher + that dedupes on `version` would drop precisely the membership, publish and move + events the site cares about. Publishing a record arrives as a `list` op under a + `changed` kind, so it needs no special case beyond not being filtered out by a + version comparison. +- `associate`/`dissociate` frames carry `associationsAdded`/`associationsRemoved`, so a + watcher can tell a `site` membership change from an unrelated tag without refetching + the record. Since the rebuild is full either way, this is only useful for deciding + whether to rebuild at all. ### Why not page-level incremental diff --git a/package.json b/package.json index aa599eb..a84a414 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "@11ty/eleventy": ">=3.0.0" }, "dependencies": { - "@haverstack/commons": "^0.25.0", - "@haverstack/core": "^0.31.0", + "@haverstack/commons": "^0.31.0", + "@haverstack/core": "^0.37.0", "markdown-it": "^15.0.1", "sanitize-html": "^2.17.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f0c611..ec941ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@haverstack/commons': - specifier: ^0.25.0 - version: 0.25.0 - '@haverstack/core': specifier: ^0.31.0 version: 0.31.0 + '@haverstack/core': + specifier: ^0.37.0 + version: 0.37.0 markdown-it: specifier: ^15.0.1 version: 15.0.1 @@ -221,11 +221,11 @@ packages: resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@haverstack/commons@0.25.0': - resolution: {integrity: sha512-1MwoTPcaEuOv75bm8dbYWg/PVHI4oABpmTVZ6XpervUGyYJLDuLDW5pRgz2/LsGJZM5NHRBNQyca52d99zgPcA==} + '@haverstack/commons@0.31.0': + resolution: {integrity: sha512-OhKy1IzXtZ9r6tFVwZ3fQb2Q+SbwBUu08FliU5BhwJXku/cFwVNBaOnZcrx1TQiOJ0i04ftz+WXq+BIDGblYxQ==} - '@haverstack/core@0.31.0': - resolution: {integrity: sha512-r/cCuTE+39oEJ7ib6gLc2yoMrK2+9QfVWDX2QhdlG77msxZDLjijcx6djNrDbaZRUo1heCIDpgJunSUhjOVG+Q==} + '@haverstack/core@0.37.0': + resolution: {integrity: sha512-tEFL/mVpVeOsQt0BDdtEJu8PQgy5DVoScCqEKID+DrAkvcspjcXrhUsGsCe87tWHbrYUWAMH8zW0UCSNw/ET0Q==} '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -1840,11 +1840,11 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@haverstack/commons@0.25.0': + '@haverstack/commons@0.31.0': dependencies: - '@haverstack/core': 0.31.0 + '@haverstack/core': 0.37.0 - '@haverstack/core@0.31.0': {} + '@haverstack/core@0.37.0': {} '@humanfs/core@0.19.2': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0af49a4..d91c200 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,5 +3,5 @@ allowBuilds: # @haverstack/* publish frequently enough to land inside a minimumReleaseAge # supply-chain policy window; excluded so a fresh release still installs. minimumReleaseAgeExclude: - - '@haverstack/commons@0.25.0' - - '@haverstack/core@0.31.0' + - '@haverstack/commons@0.31.0' + - '@haverstack/core@0.37.0' diff --git a/src/assets.ts b/src/assets.ts index 374900c..ac90a2f 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -129,7 +129,10 @@ export function collectAssets( if (!staged) continue; // One fileId can have several _attachment@1 records (one per upload), // each with its own filename. The body might reference any of them, - // so every known name points at the one staged path. + // so every known name points at the one staged path. This is why the + // association's `attachmentRecordId` is not consulted: content + // addressing gives a fileId exactly one staged path, so resolving + // which upload a reference came from would pick the same string. for (const meta of index.attachmentsByFileId.get(fileId) ?? []) { const filename = meta.content.filename; if (typeof filename === 'string' && filename) embedMap.set(filename, staged.assetPath); diff --git a/src/load.ts b/src/load.ts index 094fc05..ca45c1e 100644 --- a/src/load.ts +++ b/src/load.ts @@ -312,8 +312,14 @@ export async function load(stack: StackClient, opts: LoadOptions = {}): Promise< list.push(attachment); attachmentsByFileId.set(fileId, list); } + // Core's total order for "first recorded" (see firstRecordedAttachment in + // @haverstack/core/wire): createdAt, then id, so two uploads landing in the + // same millisecond still order the same way on every build. for (const list of attachmentsByFileId.values()) { - list.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + list.sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); } const byId = new Map(); diff --git a/tests/assets.test.ts b/tests/assets.test.ts index 1e57526..c20a6d7 100644 --- a/tests/assets.test.ts +++ b/tests/assets.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, PHOTO, SITE } from '@haverstack/commons'; import { @@ -16,7 +16,7 @@ import { stageAssets, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); const onSite = (id: string) => ({ kind: 'relationship' as const, diff --git a/tests/check.test.ts b/tests/check.test.ts index 465111e..fcaf232 100644 --- a/tests/check.test.ts +++ b/tests/check.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Association, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation, type DataAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, SITE } from '@haverstack/commons'; import { @@ -13,14 +13,14 @@ import { SITE_MEMBERSHIP_LABEL, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); -const onSite = (id: string): Association => ({ +const onSite = (id: string): DataAssociation => ({ kind: 'relationship', label: SITE_MEMBERSHIP_LABEL, target: { scope: 'record', recordId: id }, }); -const forSite = (id: string): Association => ({ +const forSite = (id: string): DataAssociation => ({ kind: 'relationship', label: FOR_SITE_LABEL, target: { scope: 'record', recordId: id }, diff --git a/tests/emit.test.ts b/tests/emit.test.ts index 23b4642..061ef46 100644 --- a/tests/emit.test.ts +++ b/tests/emit.test.ts @@ -2,7 +2,7 @@ import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, SITE } from '@haverstack/commons'; import { @@ -16,7 +16,7 @@ import { type EmitOptions, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); const onSite = (id: string) => ({ kind: 'relationship' as const, diff --git a/tests/feeds.test.ts b/tests/feeds.test.ts index cf50863..7f2a58a 100644 --- a/tests/feeds.test.ts +++ b/tests/feeds.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, SITE } from '@haverstack/commons'; import { @@ -13,7 +13,7 @@ import { type ResolvedSite, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); const onSite = (id: string) => ({ kind: 'relationship' as const, diff --git a/tests/load.test.ts b/tests/load.test.ts index d4884d6..221989c 100644 --- a/tests/load.test.ts +++ b/tests/load.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, SITE } from '@haverstack/commons'; import { @@ -12,7 +12,7 @@ import { SITE_MEMBERSHIP_LABEL, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); const onSite = (siteId: string) => ({ diff --git a/tests/publish.test.ts b/tests/publish.test.ts index 7b763de..3387c73 100644 --- a/tests/publish.test.ts +++ b/tests/publish.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Association, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation, type DataAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, PHOTO, POST, SITE } from '@haverstack/commons'; import { @@ -9,9 +9,9 @@ import { SITE_MEMBERSHIP_LABEL, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); -const onSite = (id: string): Association => ({ +const onSite = (id: string): DataAssociation => ({ kind: 'relationship', label: SITE_MEMBERSHIP_LABEL, target: { scope: 'record', recordId: id }, diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index e2f4a7b..8e50b65 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { Stack, type Association, type Permission } from '@haverstack/core'; +import { Stack, type AuthorityAssociation, type DataAssociation } from '@haverstack/core'; import { MemoryAdapter } from '@haverstack/core/testing'; import { ARTICLE, PAGE, POST, SITE } from '@haverstack/commons'; import { @@ -14,7 +14,7 @@ import { type ResolveConfig, } from '../src/index.js'; -const PUBLIC: Permission[] = [{ access: 'public' }]; +const PUBLIC: AuthorityAssociation[] = [{ kind: 'anyone', label: 'read' }]; const iso = (d: string) => new Date(d).toISOString(); const CONFIG: ResolveConfig = { slugStrategy: 'title' }; @@ -63,7 +63,7 @@ async function worked() { const meta = ( parentId: string, content: Record, - associations: Association[] = [], + associations: DataAssociation[] = [], ) => stack.create(PAGE_META.id, content, { parentId, permissions: PUBLIC, associations }); const article = ( content: Record,