Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/export-association-partition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@haverstack/core': minor
---

Export `isAuthorityAssociation` from `@haverstack/core`, for apps that split a journal entry's association changes between `amendAssociations()` and `amendAccess()`, and `partitionAssociations` and `AUTHORITY_ASSOCIATION_KINDS` from `@haverstack/core/adapter`, for storage engines that keep both halves in one table.
6 changes: 6 additions & 0 deletions .changeset/fts5-not-in-phrase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@haverstack/record-adapter-sqlite': patch
'@haverstack/record-adapter-do-sqlite': patch
---

`filter.search` no longer drops a `NOT` written inside a quoted phrase: `"cats (not dogs"` searches for the phrase as typed.
6 changes: 6 additions & 0 deletions .changeset/mutate-fresh-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@haverstack/record-adapter-sqlite': patch
'@haverstack/record-adapter-do-sqlite': patch
---

Concurrent writes to one record no longer lose updates: `mutateRecord`, `restoreVersion` and `commitMigration` read and check the record inside the transaction that writes it, so a content patch applies to the content as it stands rather than to an earlier read.
6 changes: 6 additions & 0 deletions .changeset/sqlite-shared-dedupe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@haverstack/record-adapter-sqlite': patch
'@haverstack/record-adapter-do-sqlite': patch
---

Remove redundant code from the bundled SQLite record logic: unused barrel exports, local aliases of core's native sort fields, and repeated not-found/version-read/row-hydration code.
6 changes: 6 additions & 0 deletions .changeset/sqlite-shared-indexes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@haverstack/record-adapter-sqlite': patch
'@haverstack/record-adapter-do-sqlite': patch
---

Internal restructure of the bundled SQLite record logic: the full-text and content indexes move into their own module. No behavior change.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ pnpm changeset
- **`patch`** for anything a consumer cannot observe; **`minor`** for anything they can. Every package is `0.x`, so minor _is_ the breaking slot.
- **Never `major`** — that cuts a 1.0 release, which is not a PR-level decision.
- **No changeset** for tests, CI, or repo tooling.
- `@haverstack/sqlite-shared` is `private` and has no version to publish. Record a change to it against `@haverstack/record-adapter-sqlite`, which bundles it — a changeset naming only private packages is rejected. If a template hands you a list of every package (changeset-bot's does), trim it to what you changed.
- `@haverstack/sqlite-shared` is `private` and has no version to publish. Record a change to it against both packages that bundle it, `@haverstack/record-adapter-sqlite` and `@haverstack/record-adapter-do-sqlite` — each ships its own copy, so each changelog owes the entry. A changeset naming only private packages is rejected. If a template hands you a list of every package (changeset-bot's does), trim it to what you changed.

A `minor` on a package minor-bumps everything that depends on it, transitively; a `patch` bumps nothing else. Preview with `node scripts/expand-changesets.mjs --dry-run`. Never hand-edit a `version` field or a `CHANGELOG.md` — CI owns both. See [CONTRIBUTING.md § Releasing](./CONTRIBUTING.md#releasing).

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Every package is on `0.x`, where semver puts breaking changes in the **minor** s

**Don't select `major`.** It cuts a 1.0 release, which is a decision about the install base (see [No backward compatibility yet](#no-backward-compatibility-yet)), not something a single PR makes.

`@haverstack/sqlite-shared` is `private` and has no version to publish. Record a change to it against `@haverstack/record-adapter-sqlite`, which bundles it. Naming a private package beside a public one makes Changesets refuse the whole release plan, so `scripts/expand-changesets.mjs` drops the line first — changeset-bot's template is safe to commit unedited. A changeset naming _only_ private packages fails: it would release nowhere.
`@haverstack/sqlite-shared` is `private` and has no version to publish. Record a change to it against both packages that bundle it, `@haverstack/record-adapter-sqlite` and `@haverstack/record-adapter-do-sqlite`: each inlines its own copy at build time, so a change to sqlite-shared ships in both and belongs in both changelogs. Naming a private package beside a public one makes Changesets refuse the whole release plan, so `scripts/expand-changesets.mjs` drops the line first — changeset-bot's template is safe to commit unedited. A changeset naming _only_ private packages fails: it would release nowhere.

### The ripple rule

Expand Down
2 changes: 2 additions & 0 deletions docs/spec/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ An adapter that holds a stack's identity has exactly one entry point, an async s

The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents.

## SQLite-backed adapters

**`@haverstack/sqlite-shared`** is an internal, non-public package holding everything a SQLite-backed record adapter needs that isn't specific to one binding — schema DDL and the function that applies it, `WHERE`/`ORDER` building, the cursor codec, row mappers, the FTS5 sanitizer and indexing strategy, the storage-ownership lock, the `_config` record's reader and writer, the capability declaration every engine running this query builder makes, and (via a small `SqlExecutor` interface normalizing a binding's call convention) the actual CRUD/query/version/type/association/token logic itself. `SharedSqlRecordAdapter` is where the last of those meet: an abstract base implementing all of `StackRecordAdapter` by forwarding to that logic, so an adapter supplies only what's genuinely engine-specific — constructing the database handle, transaction semantics, and its own lifecycle (`flush`/`close`, the two members the base leaves abstract). `record-adapter-sqlite` and `record-adapter-do-sqlite` both consume it; the split exists so a second (and third) SQLite engine inherits the behavior rather than reimplementing it, and so a cursor minted by one is decodable by another.

**It exposes two entry points.** The full barrel (`.`) includes the token-store logic (`SharedTokenLogic`, `TOKENS_SCHEMA_SQL`) and the file-lock helpers, both Node-specific (`node:crypto`, `node:fs`) — fine for `record-adapter-sqlite`, but a bare `import` of either survives tree-shaking as a dead-but-still-imported module in a bundle, which throws at load time in a Workers runtime without `nodejs_compat`. A record-only consumer with no token store and no lock file — `record-adapter-do-sqlite`, where the platform's single-writer-per-id model already is the lock — imports the `./record` subpath instead, which never reaches either.
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ A `search` that sanitizes to nothing (a bare `*`, punctuation-only input) is tre

**Search text is repaired, not rejected.** `filter.search` is the one filter carrying a query language, and it holds what a person typed into a box — where an unbalanced quote (`5" nails`), a trailing operator (`cats AND`) or a leading one is ordinary input on the way to a longer query, not a malformed request. The FTS sanitizers close an odd trailing quote, drop operators left without an operand, and reduce everything outside a phrase to letters, digits, marks, whitespace, parens and quotes — so the search runs against the terms actually present rather than failing. Text inside a phrase is left alone: `"cats AND dogs"` is literal to the engine, and rewriting inside it would change what was asked for.

**The reduction is an allow-list, not a list of characters to strip.** FTS5's column-filter syntax is wider than `colname:term` — `-name` and `{a b}` filter columns with no colon in them — so a strip-list written against the colon form still let `-cats` and even the hyphen in `cats-dogs` reach the engine as column names, answered with `no such column`. Naming what may pass means the next piece of syntax nobody anticipated arrives as a separator rather than as an error, and nothing is lost: the default tokenizer already splits terms on those characters, so `cats-dogs` searches for the two tokens it was indexed as. Two grammar rules the same reduction has to respect: a group needs an explicit operator beside it (`cats (dogs)` is an error where `cats AND (dogs)` is not, so the implied `AND` is written back in), and a control character ends the string early for SQLite's C API, so control characters are dropped inside a phrase as well as outside.
**The reduction is an allow-list, not a list of characters to strip.** FTS5's column-filter syntax is wider than `colname:term` — `-name` and `{a b}` filter columns with no colon in them — so a strip-list written against the colon form would let `-cats` and even the hyphen in `cats-dogs` reach the engine as column names, answered with `no such column`. Naming what may pass means the next piece of syntax nobody anticipated arrives as a separator rather than as an error, and nothing is lost: the default tokenizer already splits terms on those characters, so `cats-dogs` searches for the two tokens it was indexed as. Two grammar rules the same reduction has to respect: a group needs an explicit operator beside it (`cats (dogs)` is an error where `cats AND (dogs)` is not, so the implied `AND` is written back in), and a control character ends the string early for SQLite's C API, so control characters are dropped inside a phrase as well as outside.

The repair claims no completeness against the engine's grammar, so adapters back it with a rule that does: **whatever reaches the engine as search text and fails to parse surfaces as `StackBadRequestError`** (`bad_request`/400), never as a raw engine error. Membership in the [error taxonomy](./wire-format.md#the-taxonomy-root) is what gives a server a status to answer with — an error outside it has no mapping, and ordinary search input would land as a 500. The rule is scoped to `filter.search` deliberately: every other clause is built from bound parameters, so a parse failure in one is the adapter's own bug and must not be relabelled as the caller's.

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ There is no `previous`. Prior state is already a first-class, addressable thing:

**An `async` handler is permitted; it is simply not awaited.** The handler type is `(change: RecordChange) => void`, and a `void` return means the value is ignored, not that the function must be synchronous. Passing an `async` function is the normal way to defer work: it runs to its first `await`, yields, and the emitter moves on. What it does not buy is ordering or completion.

**What "never delays a write" does and does not promise.** It is a guarantee about the emitter: no handler's returned promise is awaited, so a subscriber cannot extend a write by deferring work. It is not a guarantee against a handler's _synchronous_ body. Handlers run inline on the caller's thread, and a record adapter backed by `node:sqlite` has just run its write synchronously on that same thread ([Adapters § Adapter backends](./adapters.md#adapter-backends)), so a handler doing heavy synchronous work extends that occupied window. Handlers should return promptly and defer anything substantial.
**What "never delays a write" does and does not promise.** It is a guarantee about the emitter: no handler's returned promise is awaited, so a subscriber cannot extend a write by deferring work. It is not a guarantee against a handler's _synchronous_ body. Handlers run inline on the caller's thread, and a record adapter backed by `node:sqlite` has just run its write synchronously on that same thread ([Adapters § SQLite-backed adapters](./adapters.md#sqlite-backed-adapters)), so a handler doing heavy synchronous work extends that occupied window. Handlers should return promptly and defer anything substantial.

An indexer that must not miss a write does not get that from a blocking hook — it gets it from a resumable cursor and idempotent application. A blocking hook buys a world where every subscriber is a latency and failure dependency of every write.

Expand Down
6 changes: 4 additions & 2 deletions docs/spec/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@ type AssociationEdit = Exclude<AssociationChange, { op: 'repoint' }>;
Undoing one entry's association changes is at most two calls, one per half of [the partition](./access-control.md#storage-unifies-the-api-does-not), passing each element's inverse to `amendAssociations()` / `amendAccess()`:

```ts
import { isAuthorityAssociation } from '@haverstack/core';

const inverse = (change: AssociationChange): AssociationEdit =>
change.op === 'add'
? { op: 'remove', association: change.association }
: { op: 'add', association: change.op === 'repoint' ? change.previous : change.association };

const edits = (entry.associations ?? []).map(inverse);
const data = edits.filter((e) => !isAuthority(e.association));
const authority = edits.filter((e) => isAuthority(e.association));
const data = edits.filter((e) => !isAuthorityAssociation(e.association));
const authority = edits.filter((e) => isAuthorityAssociation(e.association));
if (data.length) await stack.amendAssociations(recordId, data);
if (authority.length) await stack.amendAccess(recordId, authority);
```
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/adapter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export {
} from '../query-validation.js';
export { contentSortEntry, contentSortKey, compareSortEntries } from './sort.js';
export type { SortEntry } from './sort.js';
export { AUTHORITY_ASSOCIATION_KINDS, partitionAssociations } from '../associations/identity.js';
12 changes: 11 additions & 1 deletion packages/core/src/associations/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,24 @@ function stripAssociationAnnotation(association: Association): Association {
return identity as Association;
}

/**
* The association kinds that carry authority. Every other kind is data.
* Storage that partitions by kind in a query reads this list rather than
* restating it, so a new authority kind can't fall into the data half.
*/
export const AUTHORITY_ASSOCIATION_KINDS: readonly AuthorityAssociation['kind'][] = [
'permission',
'anyone',
];

/**
* Whether an association carries authority rather than data — the partition
* `StackRecord.permissions` and `StackRecord.associations` project, and the
* line `associate()`/`dissociate()` refuse to cross.
* See docs/spec/access-control.md § Record-level permissions.
*/
export function isAuthorityAssociation(a: Association): a is AuthorityAssociation {
return a?.kind === 'permission' || a?.kind === 'anyone';
return (AUTHORITY_ASSOCIATION_KINDS as readonly string[]).includes(a.kind);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,4 @@ export { hashSchema, isCompatible } from './schema.js';
export type { SchemaDriftViolation } from './schema.js';
export type { ValidationError } from './validate.js';
export { applyMergePatch } from './merge.js';
export { assertOneSurface } from './associations/identity.js';
export { assertOneSurface, isAuthorityAssociation } from './associations/identity.js';
8 changes: 2 additions & 6 deletions packages/core/src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
applyAssociationEdits,
assertOneSurface,
associationEqual,
partitionAssociations,
} from './associations/identity.js';

/** Core resolves the default direction; an adapter only ever sees an explicit one. */
Expand Down Expand Up @@ -712,10 +713,6 @@ export class IncapableMemoryAdapter extends MemoryAdapter {
}
}

/** Whether an association carries authority — mirrors core's own predicate. */
const isAuthority = (a: Association): a is AuthorityAssociation =>
a.kind === 'permission' || a.kind === 'anyone';

/**
* A record's whole association table, both projections rejoined — what
* amendAssociations() acts on, since storage keys all kinds alike.
Expand All @@ -739,8 +736,7 @@ function withAssociationSet(record: StackRecord, associations: Association[]): S
[],
);
const { associations: _dropA, permissions: _dropP, ...rest } = record;
const data = keyed.filter((a): a is DataAssociation => !isAuthority(a));
const authority = keyed.filter(isAuthority);
const { associations: data, permissions: authority } = partitionAssociations(keyed);
return {
...(rest as StackRecord),
...(data.length && { associations: data }),
Expand Down
Loading
Loading