@entitle/store-sql
v0.2.0
Published
Dialect-independent internals shared by Entitle's SQL PolicyStore backends
Readme
@entitle/store-sql
Everything @entitle/store-sqlite and @entitle/store-postgres agree on, in one
place.
This package is not a store. It implements no PolicyStore, opens no
connection, holds no SQL a dialect would recognise as a whole statement, and has
no factory of its own. It owns the row shapes, the row-to-domain converters, the
identifier check, the table-name derivation, the migration ledger and -- since #41
-- the statements over the append-only assignment log, rendered through a
dialect descriptor. Those are the parts of two SQL backends that are the same
because the schema is the same, not because the dialect is.
Published, and not for you
It is on the registry, and that is a mechanical consequence rather than an
invitation: @entitle/store-sqlite and @entitle/store-postgres require it at
runtime, and a dependency of a published package has to be installable. There is
no version of "keep it private and bundle it into both stores" that does not
either declare a runtime import as a devDependency -- which the packaging
invariants forbid, for the reason that the declaration is what makes a consumer's
install correct -- or fork the shared code back into two copies inside two
tarballs, which is the thing this package exists to stop.
So: it is public in the sense that npm can install it, and internal in the sense that its surface is decided by what those two stores need. It ships at the same version as every other Entitle package and moves with them. Depend on it directly and you are depending on an implementation detail; nothing here is a promise to a third party.
If you are writing your own PolicyStore, the thing to reach for is the contract
and its executable specification -- PolicyStore and
policyStoreConformanceCases from @entitle/core/conformance -- not this.
What it owns
| Export | What it is |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| validateIdentifier | The one regex a table prefix or schema name has to match before it is interpolated into a statement |
| TABLE_SUFFIXES, coreTableNames | The tables an Entitle schema has, rendered by a dialect-supplied render |
| AssignmentRow, HistoryRow, EventRow, LastEventRow, OverrideRow, PolicyIdRow | The row shapes both schemas read back |
| AssignmentColumns, AssignmentKeyColumns, assignmentColumns | One assignment as the write path holds it, and the serialisation that gets it there |
| OverrideColumns, overrideColumns | One override as the write path holds it -- nine columns named rather than counted, with #37's limits normalisation |
| assignmentRowKey, rowToAssignment, rowToHistoryEntry, rowToOverride | Row to domain, with every fail-closed guard the contract requires |
| overrideMapFromEntries, overrideMapFromRows | Override rows as an OverrideMap, built so a feature named __proto__ cannot corrupt it |
| schemaVersion, pendingMigrations, storedSchemaVersion, migrationLedgerSql | The migration ledger: the version is the migration count, the marker is rewritten, the pending set is [current, count) |
| assignmentsBySubject, overridesBySubject | The scaffolding around a batch read, taking the dialect's statement as a callback |
| SqlDialect, createBinder, keysetPredicate, ascendingOrder | The rendering seam: placeholders and text collation, a binder that hands out a placeholder as it records the value, and the keyset predicate built from the same key list as the ORDER BY |
| historyPageSql, listAssignmentsPageSql, lastEventForKeySql, insertEventSql, pruneHistorySql | The five statements over the assignment log and the paginated holder read |
| EVENT_TYPES, eventTypeCheckList, EVENT_COLUMN_LIST, eventValues, EventColumns | One log event as columns, and the CHECK list rendered from the same array as the TypeScript union |
| rowToAssignmentEvent, pageRowToHistoryEntry, lastEventForKey, toPage, historyCursorForRow, listCursorForRow | Log row to domain, and the page-plus-cursor a paginated read answers with |
What it deliberately does not own, and why
The issue that created this package proposed going further: a SqlDriver with
query, transaction and placeholder, against which the PolicyStore method
bodies would live once. That is not what was built, and the reason is a property
of one of the two drivers rather than a preference.
better-sqlite3 is synchronous, and that is load-bearing twice over.
createSqliteStore is a synchronous factory, so its migration path cannot
await anything; and db.transaction(fn) requires fn to be synchronous,
because what makes the SQLite store's multi-statement writes atomic is that no
other caller can run at all between the first statement and the commit. A
shared async body doing await tx.query(a); await tx.query(b) inside a
transaction would give the event loop a turn in the middle of one, at which point
a second addAssignment on the same connection reaches BEGIN inside an open
transaction. The store's own concurrency suite states the property this would
destroy: two handles in one thread take their locks strictly one after the other.
An all-Promise driver therefore costs either the synchronous factory -- a
breaking change to a published API -- or the write atomicity that was the whole
of one of the fixes this extraction was sequenced after. Both are behaviour
changes, and the brief for this work was to move code that is already correct.
A dialect is not a driver, and that is where #41 pushed the line. The
statements that issue added -- a self-anti-join for the history page, a
lexicographic OR-chain for the keyset, a correlated EXISTS for the prune -- are
the first in this repository that are hard to read rather than merely long, and
each has a wrong version one character away that is silent in exactly one store.
So they are rendered once, from a SqlDialect that says only how the dialect
spells a bound parameter and what it appends to a text ordering key. That has no
execution model in it: the text is shared, and each store still owns its
connection, its transaction shape and its prepared-statement cache.
So the line is drawn where it can be drawn without paying either: the pure and the decodable are shared, the execution model is not. Each store keeps
- its DDL and its migration bodies, which are transliterations rather than
copies -- one dialect rebuilds a table to add a constraint, the other alters a
column's type; one holds an instant as constrained
TEXT, the other astimestamptz; - its
runMigrations, which is aBEGIN IMMEDIATEtransaction on one side and a session advisory lock on a dedicated pooled client on the other, chosen separately and for reasons written down in each; - its statements and its write orchestration, including the batch reads' fast
paths, which are an
unnestarray match against a prepared-per-arity row-valueIN; - its backend-specific refusals, such as the year-0000 instant that ISO 8601 has and PostgreSQL's calendar does not.
A later change that makes the two execution models genuinely one -- an async SQLite factory, say -- is what would make the driver seam cheap. Until then a seam that forces them together buys line count and sells a durability guarantee.
