Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .changeset/store-emdash-scaffold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
"@otta-sh/store-emdash": minor
---

New package: the storage port Otta's commerce adapters will be written against, and a
dialect harness that runs it against a real host repository rather than a stand-in.

- **One structural port, two tiers.** `StorageAccess` names exactly the nine methods an
adapter needs — `get`/`put`/`delete`/`query`/`count`, and the conditional-write group
`updateIf`/`getVersioned`/`compareAndSet`/`compareAndDelete`. It is written in terms of
the host's own storage types, imported as types only, so the filter algebra and the
result unions are named once rather than copied and left to drift. In production the
plugin injects `ctx.storage`; in tests the harness injects a real repository. Because
the port is the only thing an adapter sees, changing which build of the host supplies
it is a dependency change rather than an adapter rewrite — which is the point.
- **The boundary is enforced, not documented.** Three dependency-cruiser rules replace
the blanket EmDash ban this package had to be exempted from — it exists to name the
host's types, so the blanket ban forbade the one import it is for.
`store-emdash-runs-no-host-code` says the host may be *named* and never *executed*: a
type import passes, a runtime import of the same module fails `pnpm lint`. It is its own
rule so that allowance cannot leak onto the others — written as one clause it also
permitted `import type { Pool } from "pg"`. `store-emdash-is-sandbox-clean` carries the
perimeter: no DB driver, no filesystem or socket builtin, no HTTP client, no sibling
server package (matched by lookahead, so a future store package is banned the day it
exists). `store-emdash-no-console-react` keeps react, react-dom and the two component
libraries out of the **whole** package, tests included. Four plants prove each edge:
runtime host import fails, `react` in a test fails, a type-only host import passes, a
type-only `pg` import fails.
- **The host it needs does not exist on the registry yet, and the manifest says so.** The
port is written against the conditional-write primitives, which no published `emdash`
release carries. The specifier stays the plain registry version so adopting a release is
a one-line change; until then the workspace override that redirects it to a build
carrying the primitives is load-bearing, and the package description, the README and
this note all say that rather than letting an exact peer pin imply a compatibility that
does not hold.
- **Real databases, never mocks — including the one that can race.** The harness builds
its collections out of real repository instances over in-memory SQLite and, when a
Postgres connection is configured, over a fresh schema migrated by the host's own
migration runner. Never a hand-built table: revisions come from a trigger that
migration creates, and without it every compare-and-set would see an unchanging
revision and quietly agree with itself. One database per test file, rows cleared
between cases — emptying the table is also the only reset that keeps that trigger. The
suite pins the round trip, the indexed query with ordering and paging past the host's
page ceiling, `count`, `delete`, the guarded decrement that stops at its guard, the
guarded update that never inserts, create-if-absent, the stale-revision refusals for
both set and delete, and the refusal to query a field the collection never declared
(asserted on the field, not on the host's wording). A collection declared with a unique
index proves the composed allow-list — and the README records what that does NOT buy:
no physical index exists in either tier, so uniqueness is never enforced there and
once-only must come from a conditional write. On Postgres it adds the case SQLite
cannot express: ten concurrent compare-and-sets on one revision, exactly one of which
applies, every loser either refused or retryably aborted, and the surviving document
the winner's.
103 changes: 101 additions & 2 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,115 @@ module.exports = {
"and still binds the same package; violating either fails `pnpm lint`. " +
"sites/staging is deliberately out of scope (it is the EmDash HOST: it " +
"imports `emdash` types and renders React storefront components) and " +
"`pnpm lint` cruises `packages` only.",
"`pnpm lint` cruises `packages` only. " +
"@otta-sh/store-emdash is the SECOND exemption, and it is a HANDOFF to " +
"three rules below, not a hole: that package exists to name the host's " +
"plugin-storage types, so the blanket ban would forbid the one import it " +
"is for. What replaces it, precisely, because 'nothing is lost' was " +
"claimed once here and was false: `store-emdash-no-console-react` bans " +
"react, react-dom, kumo and phosphor across the WHOLE package including " +
"`test/` (the first split bound `src` only, which left the tests free); " +
"`store-emdash-runs-no-host-code` bans `emdash` and @emdash-cms/* in " +
"`src` as RUNTIME imports while permitting type-only ones; and " +
"`store-emdash-is-sandbox-clean` adds the DB/Node/HTTP/sibling-package " +
"perimeter this rule says nothing about. The exemption is written on " +
"`from` rather than on `to` because dependency-cruiser cannot express one " +
"rule whose forbidden list varies by source, and a type-only carve-out " +
"here would have loosened the ban for admin-react and every other " +
"package too.",
severity: "error",
from: { path: "^packages/", pathNot: "^packages/admin-react/" },
from: { path: "^packages/", pathNot: "^packages/(admin-react|store-emdash)/" },
to: {
// Same both-forms shape as the rules above: a resolved node_modules
// path (direct or pnpm-store) or a bare specifier left unresolved by
// pnpm's strict isolation.
path: "(node_modules/(react|react-dom|emdash|@emdash-cms/[^/]+|@cloudflare/kumo|@phosphor-icons/react)(/|$)|^(react|react-dom|emdash|@emdash-cms/[^/]+|@cloudflare/kumo|@phosphor-icons/react)(/|$))",
},
},
{
name: "store-emdash-no-console-react",
comment:
"The console quarantine, restated for the ONE package whose `from` " +
"`console-react-is-quarantined` exempts. That exemption exists because " +
"the blanket ban names `emdash`, which is the one import " +
"@otta-sh/store-emdash is FOR — but react has nothing to do with that, " +
"and losing the react ban as a side effect of the EmDash carve-out would " +
"be exactly the silent hole ADR-0014 Decision 1 forbids. So this rule " +
"binds the WHOLE package, `test/` included: unlike the IO rule below, " +
"there is no version of importing react here that is legitimate in a " +
"Node test, and the first version of this split bound `src` only and " +
"left `test/**` free to import react, react-dom, kumo and phosphor with " +
"nothing catching it. Deliberately carries NO `dependencyTypesNot`: a " +
"type-only react import is a signal that a component is being written " +
"where none belongs, and it costs nothing to refuse.",
severity: "error",
from: { path: "^packages/store-emdash/" },
to: {
// Both spellings, as in every rule here: resolved into node_modules
// (direct or via the pnpm store), or left a bare specifier by pnpm's
// strict isolation.
path: "(node_modules/(react|react-dom|@cloudflare/kumo|@phosphor-icons/react)(/|$)|^(react|react-dom|@cloudflare/kumo|@phosphor-icons/react)(/|$))",
},
},
{
name: "store-emdash-is-sandbox-clean",
comment:
"@otta-sh/store-emdash's src is commerce-truth code that runs INSIDE the " +
"workerd sandbox, bound to the `ctx.storage` the host injects. It " +
"therefore carries the same perimeter as `plugin-is-sandbox-clean`: no " +
"DB driver, no filesystem/process/socket builtin, no HTTP or WS client, " +
"no sibling server package. Type-only imports are NOT exempt here, and " +
"the exemption is not a detail: `dependencyTypesNot` on a whole `to` " +
'clause would have permitted `import type { Pool } from "pg"` and ' +
'`import type { Stats } from "node:fs"`, which are how a module starts ' +
"being written against a host it must never touch. Only the EmDash " +
"clause below gets that allowance, and it gets it precisely because it " +
"is the seam. Sibling store packages are matched by negative lookahead " +
"rather than by name, so a future store-d1 is banned on the day it is " +
"created instead of the day someone remembers this list. Test code is " +
"exempt, as it is for every rule here — `test/describe-each-dialect.ts` " +
"runs in NODE and constructs real `PluginStorageRepository` instances " +
"over better-sqlite3 and Postgres on purpose: real databases, never " +
"mocks. That harness is why the ban can be this strict in `src` without " +
"costing coverage. (`react` and friends are banned across the whole " +
"package by `store-emdash-no-console-react` above.)",
severity: "error",
from: { path: "^packages/store-emdash/src" },
to: {
// Both spellings, as above. The builtin half is the optional-`node:`
// form the plugin rule's comment explains — dependency-cruiser reports
// `from "node:fs"` under the bare name `fs`.
path: "(node_modules/(pg|pg-pool|kysely|better-sqlite3|workerd|hono|node-fetch|undici|axios|ws)(/|$)|node_modules/@otta-sh/admin-react(/|$)|^(pg|pg-pool|kysely|better-sqlite3|workerd|hono|node-fetch|undici|axios|ws)(/|$)|^@otta-sh/admin-react(/|$)|^(node:)?(fs|child_process|net|http|https|os|dgram|dns|tls|worker_threads|cluster|vm)(/|$)|^packages/(service|payments-[^/]+|admin-react)/|^packages/(?!store-emdash/)store-[^/]+/)",
},
},
{
name: "store-emdash-runs-no-host-code",
comment:
"The seam, as a rule. @otta-sh/store-emdash may NAME EmDash's storage " +
"types and may never EXECUTE EmDash's code: `src/storage-access.ts` is " +
"written in terms of the host's `StorageCollection` and its conditional-" +
"write result types, and the implementation arrives injected — " +
"`ctx.storage` in production, a real `PluginStorageRepository` in the " +
"harness. That is what makes replacing the host build a dependency " +
"change rather than an adapter rewrite. `dependencyTypesNot: " +
"['type-only']` is the whole rule: a type import emits no code and " +
"cannot put host behaviour inside the isolate, while a runtime import of " +
"the same module fails the build. It is a SEPARATE rule from " +
"`store-emdash-is-sandbox-clean` for exactly that reason — the " +
"allowance is specific to the host and must not leak onto the IO bans, " +
"which is what a single merged clause did in the first version. " +
"`^emdash$|^emdash/` rather than a bare prefix, so a package merely " +
"NAMED like the host is not swept in.",
severity: "error",
from: { path: "^packages/store-emdash/src" },
to: {
path: "(node_modules/(emdash|@emdash-cms/[^/]+)(/|$)|^emdash$|^emdash/|^@emdash-cms/)",
// The one allowance in this package's perimeter, and the reason the
// structural port can be written against the host's own types instead
// of a hand-mirrored copy left to drift.
dependencyTypesNot: ["type-only"],
},
},
{
name: "admin-presentation-is-dependency-free",
comment:
Expand Down
45 changes: 45 additions & 0 deletions packages/store-emdash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# @otta-sh/store-emdash

Commerce store adapters over EmDash's plugin-storage primitives.

## The host this needs

The port is written against the **conditional-write primitives** — `updateIf`,
`getVersioned`, `compareAndSet`, `compareAndDelete`. No published `emdash`
release carries them yet. The manifest's `emdash` specifier is the plain
registry version so that adopting a release is a one-line change, and until then
the workspace override redirecting it to the vendored build is **load-bearing**:
without it the package resolves a host that lacks the primitives, and the failure
is a type error against a real installed package rather than a missing dependency.

## The seam

`src/storage-access.ts` declares a **structural `StorageAccess` port**: the nine
methods the adapters use, written in terms of the host's own types via
`import type`. Nothing in `src/` imports host code at runtime — three
dependency-cruiser rules in `pnpm lint` enforce that, the react quarantine, and
the sandbox perimeter. Production injects `ctx.storage`, tests inject a real
repository; `collectionOf<T>` is the single audited narrowing between the untyped
map and a typed collection.

## The dialect harness

`test/describe-each-dialect.ts` builds the port out of **real
`PluginStorageRepository` instances** on in-memory SQLite, and on Postgres when
`PG_CONNECTION_STRING` is set. One database per test FILE; rows are cleared
between cases. Real databases, never mocks: only Postgres can lose a race, so the
concurrency case runs there alone.

The schema always comes from the host's `runMigrations`; never hand-create the
storage table. Revisions come from a trigger that migration creates — which is
also why cases reset by emptying the table rather than recreating it.

## Known gap: no physical indexes

Declared indexes reach a collection through the repository's `indexes`
constructor argument — indexes plus unique indexes, as the host composes them —
and that argument is only the **queryable-field allow-list**. The host's
index-materializing function is unexported, so neither tier creates a physical
index, and a `uniqueIndexes` declaration enforces **nothing** here. No adapter may
depend on the host to reject a duplicate: once-only has to be enforced by a
conditional write.
50 changes: 50 additions & 0 deletions packages/store-emdash/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@otta-sh/store-emdash",
"version": "0.0.1",
"description": "Commerce store adapters for Otta over the EmDash plugin-storage primitives — bound to a structural StorageAccess port, never to the host at runtime. REQUIRES the conditional-write primitives (updateIf/getVersioned/compareAndSet/compareAndDelete), which no published emdash release carries yet: the `emdash` specifier here is the plain registry version, and the workspace override that redirects it to the vendored build is load-bearing until a release ships them.",
"homepage": "https://github.com/UrumiAI/otta.sh#readme",
"bugs": {
"url": "https://github.com/UrumiAI/otta.sh/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/UrumiAI/otta.sh.git",
"directory": "packages/store-emdash"
},
"files": [
"dist"
],
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
}
},
"scripts": {
"build": "tsdown"
},
"dependencies": {
"@otta-sh/domain": "workspace:*"
},
"devDependencies": {
"@types/better-sqlite3": "catalog:",
"@types/pg": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "0.37.0",
"kysely": "catalog:",
"pg": "catalog:",
"tsdown": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"emdash": "0.37.0"
}
}
11 changes: 11 additions & 0 deletions packages/store-emdash/src/clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Clock } from "@otta-sh/domain";

/**
* Real time, for the in-process adapters. `Date` only — no `node:` import, so
* it is safe inside the workerd sandbox. Tests use the domain's `FixedClock`.
*/
export const systemClock: Clock = {
now(): Date {
return new Date();
},
};
30 changes: 30 additions & 0 deletions packages/store-emdash/src/collection-of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { StorageAccess, StorageCollection } from "./storage-access.js";

/**
* The ONE audited narrowing in this package.
*
* `StorageAccess` is keyed by collection name and says nothing about which
* document type lives under which key — it cannot: the host builds `ctx.storage`
* from the descriptor's declared collections, and the descriptor carries index
* names, not TypeScript types. So somewhere a `StorageCollection<unknown>` has to
* become a `StorageCollection<Order>`, and the only question is whether that
* happens once, in a function with a name, or silently at every call site with a
* cast an adapter author can get wrong per collection.
*
* It happens here. An adapter asks for the collection it owns, states the
* document type once, and gets a missing-collection failure as an error naming
* the collection rather than as `undefined.get is not a function` several frames
* later — a real outcome, because `ctx.storage` only holds what the descriptor
* declared, and the descriptor is edited in a different file from the adapter.
*/
export function collectionOf<T>(storage: StorageAccess, name: string): StorageCollection<T> {
const collection = storage[name];
if (collection === undefined) {
throw new Error(
`storage collection '${name}' is not declared — add it to the plugin descriptor's storage config`,
);
}
// Safe by the argument above: the runtime object is the host's collection for
// `name`, and `T` is the caller's statement of what it stores there.
return collection as StorageCollection<T>;
}
19 changes: 19 additions & 0 deletions packages/store-emdash/src/id-gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { IdGen } from "@otta-sh/domain";

/**
* Zero-dep collision-free id source for the in-process adapters. WebCrypto off
* `globalThis`, never `node:crypto`: this module is bundled into the workerd
* sandbox, where a `node:` import is a runtime failure the type system would not
* have caught.
*
* This duplicates `@otta-sh/store-postgres`'s `uuidIdGen` on purpose. Importing
* it instead is forbidden by `store-emdash-is-sandbox-clean`, and rightly: that
* package's entry pulls a Kysely/pg graph into a module that ships inside the
* isolate. The duplication is also temporary in one direction — the
* store-postgres copy goes when that package does, and this one is what remains.
*/
export const uuidIdGen: IdGen = {
newId(): string {
return globalThis.crypto.randomUUID();
},
};
22 changes: 22 additions & 0 deletions packages/store-emdash/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export { systemClock } from "./clock.js";
export { collectionOf } from "./collection-of.js";
export { uuidIdGen } from "./id-gen.js";
export {
isStorageQueryError,
isStorageSerializationError,
type ConditionalDeleteResult,
type ConditionalWriteResult,
type NumericDelta,
type OrderBy,
type QueryOptions,
type QueryResult,
type StorageAccess,
type StorageCollection,
type StorageQueryError,
type StorageSerializationError,
type UpdateIfArgs,
type UpdateIfResult,
type Versioned,
type WhereClause,
type WhereValue,
} from "./storage-access.js";
Loading
Loading