@fabrika/platform 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@fabrika/platform",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/platform"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ }
28
+ }
@@ -0,0 +1,71 @@
1
+ // `DeployLocks` as ONE row per lease — the Durable Object's three guarantees expressed as properties
2
+ // of a STATEMENT rather than of Cloudflare's single-threaded object, so the same code serves SQLite,
3
+ // D1 and Postgres.
4
+ //
5
+ // It lives in `@fabrika/platform` (with the ports, not with either runtime) because it depends on
6
+ // NOTHING but the `SqlDatabase` port: no `cloudflare:workers`, no `bun:*`, no `node:*`, no I/O
7
+ // primitive of its own. That makes it runtime-neutral by construction, which is why the control-plane
8
+ // Worker and a long-running Bun process can share ONE copy instead of keeping two that drift.
9
+ //
10
+ // The contract (verbatim from the port):
11
+ // * NON-REENTRANT — a live lease is refused even to its own holder, so a redelivered message defers
12
+ // instead of double-running.
13
+ // * TTL-BOUNDED — a consumer that dies mid-deploy never releases; the stored deadline is what lets
14
+ // the next run take over instead of the target being wedged forever.
15
+ // * HOLDER-CHECKED release — a late release from a superseded run cannot free a newer run's lease.
16
+ //
17
+ // `acquire` is ONE conditional upsert and NEVER a read-then-write. Two consumers racing for a free
18
+ // target cannot both observe "free": one inserts, the other conflicts onto the live row and its
19
+ // DO UPDATE is skipped by the `expires_at <= ?` guard. `meta.changes` is the whole answer — 1 means
20
+ // the lease is ours, 0 means someone else holds it. Splitting this into a SELECT and an UPDATE
21
+ // reintroduces exactly the race the DO existed to prevent.
22
+ //
23
+ // GENERIC ON PURPOSE: the table name is a parameter so a control plane that already owns a
24
+ // `deploy_locks` table and a second service that wants its own lease table share one implementation.
25
+ // The DDL is per-dialect and therefore NOT here — see `@fabrika/control`'s migrations (SQLite/D1) and
26
+ // `@fabrika/platform-node`'s `migrations/0002_deploy_locks.sql` (Postgres).
27
+
28
+ import type { DeployLocks } from './ports'
29
+ import type { SqlDatabase } from './sql'
30
+
31
+ export interface SqlDeployLocksOptions {
32
+ /** Table holding the leases. Must already exist; the DDL is the owning service's migration. */
33
+ table?: string
34
+ /** Injectable clock so lease expiry is testable without sleeping. */
35
+ now?: () => number
36
+ }
37
+
38
+ /** The default lease table, matching the `deploy_locks` table both current consumers migrate. */
39
+ const DEFAULT_TABLE = 'deploy_locks'
40
+ /** A table name is interpolated into SQL, so it is restricted to a bare unqualified identifier. */
41
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
42
+
43
+ export class SqlDeployLocks implements DeployLocks {
44
+ private readonly acquireSql: string
45
+ private readonly releaseSql: string
46
+ private readonly now: () => number
47
+
48
+ constructor(private readonly db: SqlDatabase, options: SqlDeployLocksOptions = {}) {
49
+ const table = options.table ?? DEFAULT_TABLE
50
+ if (!IDENTIFIER.test(table)) {
51
+ throw new Error(`invalid lock table name: ${table}`)
52
+ }
53
+ this.now = options.now ?? Date.now
54
+ this.acquireSql = `INSERT INTO ${table} (lock_key, holder, expires_at) VALUES (?, ?, ?)
55
+ ON CONFLICT (lock_key) DO UPDATE SET holder = excluded.holder, expires_at = excluded.expires_at
56
+ WHERE ${table}.expires_at <= ?`
57
+ this.releaseSql = `DELETE FROM ${table} WHERE lock_key = ? AND holder = ?`
58
+ }
59
+
60
+ async acquire(key: string, holder: string, ttlMs: number): Promise<boolean> {
61
+ const now = this.now()
62
+ const result = await this.db.prepare(this.acquireSql).bind(key, holder, now + ttlMs, now).run()
63
+ return (result.meta.changes ?? 0) > 0
64
+ }
65
+
66
+ async release(key: string, holder: string): Promise<void> {
67
+ // Deleting the row is what "free" means. The holder predicate makes this a no-op for a run whose
68
+ // lease already expired and was taken over — it cannot delete the newer holder's row.
69
+ await this.db.prepare(this.releaseSql).bind(key, holder).run()
70
+ }
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // @fabrika/platform — the ports every runtime-specific implementation must satisfy, plus the
2
+ // implementations that need nothing BUT those ports.
3
+ //
4
+ // The rule is not "types only" — it is: NO runtime-specific imports and NO I/O primitives of its own.
5
+ // Nothing here may import `cloudflare:workers`, `bun:*`, or `node:*`; nothing here may open a socket,
6
+ // touch a filesystem, or reach for a global that only one runtime has. A portable implementation
7
+ // written purely against a port (`SqlDeployLocks` over `SqlDatabase`) is runtime-neutral by
8
+ // construction and belongs here — keeping one copy instead of one per runtime is the whole point.
9
+ // Runtime-specific implementations live with the workers that use them, or in @fabrika/platform-node.
10
+
11
+ export { SqlDeployLocks, type SqlDeployLocksOptions } from './deploy-locks-sql'
12
+ export type { AssetServer, BlobStore, DeployLocks, HttpService, JobQueue, WaitUntil } from './ports'
13
+ export type { SqlDatabase, SqlQueryResult, SqlRunResult, SqlStatement } from './sql'
package/src/ports.ts ADDED
@@ -0,0 +1,59 @@
1
+ // The remaining platform ports. Each one names a capability the control plane needs, in the
2
+ // narrowest form the code actually uses — not the full surface of whatever Cloudflare primitive
3
+ // happens to provide it today. Widening a port is a decision; keep them boring.
4
+ //
5
+ // UNLIKE `SqlDatabase`, these are NOT satisfied structurally by the Cloudflare bindings, and no
6
+ // amount of wishing makes them so: `R2Bucket.put` resolves to `R2Object | null` and `Queue.send` to
7
+ // `QueueSendResponse`, neither of which is assignable to `Promise<void>`. The Cloudflare side wraps
8
+ // them in await-and-discard adapters (`packages/control/src/platform-cf.ts`). That is the correct
9
+ // shape — a port should describe what callers need, not inherit a vendor's return values — but it
10
+ // does mean adopting one of these ports costs a small adapter rather than nothing.
11
+
12
+ /** Bytes written to blob storage (run logs). Both R2 and S3/MinIO satisfy this. */
13
+ export interface BlobStore {
14
+ put(key: string, value: string | ArrayBuffer | ReadableStream): Promise<void>
15
+ get(key: string): Promise<{ body: ReadableStream; text(): Promise<string> } | null>
16
+ delete(key: string): Promise<void>
17
+ }
18
+
19
+ /** Enqueue a deploy job. The CONSUMER side is platform-specific and deliberately not modelled here. */
20
+ export interface JobQueue<T> {
21
+ send(message: T, options?: { delaySeconds?: number }): Promise<void>
22
+ }
23
+
24
+ /**
25
+ * Per-target mutual exclusion, keyed by `<app>:<env>`. Matches the seam the control plane already
26
+ * had around its Durable Object, so swapping the implementation touches no call site.
27
+ *
28
+ * Contract, unchanged from the DO it replaces:
29
+ * - NON-REENTRANT — a live lease returns false even to the same holder, so a redelivered message
30
+ * defers rather than double-running.
31
+ * - TTL-BOUNDED — a consumer that dies mid-deploy self-heals when the lease expires, instead of
32
+ * wedging that app-env forever.
33
+ * - HOLDER-CHECKED release — a late release from a superseded run can never free a lease that a
34
+ * newer run has since taken.
35
+ *
36
+ * The database-backed implementation expresses all three as one conditional UPDATE, which is atomic
37
+ * on SQLite and Postgres alike — so this port has ONE implementation on every platform.
38
+ */
39
+ export interface DeployLocks {
40
+ /** Take the lease for `key`. `true` when acquired (free or expired), `false` when live elsewhere. */
41
+ acquire(key: string, holder: string, ttlMs: number): Promise<boolean>
42
+ /** Release the lease iff `holder` still owns it. Idempotent; a stale holder's call is a no-op. */
43
+ release(key: string, holder: string): Promise<void>
44
+ }
45
+
46
+ /** One HTTP-speaking service behind a platform-native or network transport. */
47
+ export interface HttpService {
48
+ fetch(request: Request): Promise<Response>
49
+ }
50
+
51
+ /** Serves the built SPA for non-API paths. */
52
+ export interface AssetServer extends HttpService {}
53
+
54
+ /**
55
+ * Keep work alive past the response. On Workers this is `ctx.waitUntil`; in a long-running process
56
+ * it is a supervised background promise. Implementations MUST swallow-and-log rejections — a
57
+ * background failure must never take the process down.
58
+ */
59
+ export type WaitUntil = (promise: Promise<unknown>) => void
package/src/sql.ts ADDED
@@ -0,0 +1,61 @@
1
+ // The SQL port — the ONE database seam every service sits on.
2
+ //
3
+ // Shape is deliberately D1's: a real `D1Database` satisfies `SqlDatabase` STRUCTURALLY, with no
4
+ // adapter and no rewriting of the ~90 statements the services already carry. The Postgres driver
5
+ // reimplements this same surface (see @fabrika/platform-node), so the SQL bodies stay in ONE place
6
+ // instead of being duplicated per backend.
7
+ //
8
+ // The cost of that choice lands on the SQL, not on the code: every statement must stay inside the
9
+ // SQLite ∩ Postgres common subset. In practice that is cheap — both dialects support `ON CONFLICT`
10
+ // and `RETURNING`, and this codebase already generates ids and timestamps caller-side (never
11
+ // `strftime`/`unixepoch`/`AUTOINCREMENT` in a query). Placeholder syntax is the one real divergence
12
+ // and the Postgres driver rewrites `?` → `$n` mechanically. DDL is NOT covered here — migrations are
13
+ // per-dialect and are the genuinely expensive part of the port.
14
+
15
+ /**
16
+ * A prepared statement. `bind()` returns a NEW statement; it never mutates the receiver (D1
17
+ * semantics) — call sites build a base statement and bind it repeatedly, so a mutating `bind()`
18
+ * corrupts them silently.
19
+ *
20
+ * Two further contracts every driver must honour, both relied on by existing call sites:
21
+ * - **`bind()` is optional.** A statement with no placeholders is executed directly, and `bind()`
22
+ * may legally be called with zero arguments.
23
+ * - **SQL NULL maps to JS `null`, never `undefined`.** Row shapes are checked with `=== null`.
24
+ */
25
+ export interface SqlStatement {
26
+ /** Bind positional parameters, left to right, for this statement's `?` placeholders. */
27
+ bind(...values: unknown[]): SqlStatement
28
+ /** First row, or `null` when the query matched nothing. */
29
+ first<T = Record<string, unknown>>(): Promise<T | null>
30
+ /** All matching rows. */
31
+ all<T = Record<string, unknown>>(): Promise<SqlQueryResult<T>>
32
+ /** Execute a write. Read `meta.changes` to learn whether it affected anything. */
33
+ run(): Promise<SqlRunResult>
34
+ }
35
+
36
+ /** The result of `all()` — wrapped in `{ results }` to match D1 so no call site changes. */
37
+ export interface SqlQueryResult<T> {
38
+ results: T[]
39
+ }
40
+
41
+ /**
42
+ * The result of `run()`. `changes` is how the guarded-UPDATE idioms across this codebase detect a
43
+ * no-op, so it is REQUIRED: a driver that left it undefined would turn every successful guarded
44
+ * write into a silent "not found". `D1Meta.changes` is likewise non-optional, so D1 still satisfies
45
+ * this structurally.
46
+ *
47
+ * It counts rows AFFECTED BY A WRITE. A `SELECT` reports `0` — drivers whose underlying client
48
+ * returns a row count for reads must map that to `0` rather than passing it through.
49
+ */
50
+ export interface SqlRunResult {
51
+ meta: {
52
+ /** Rows actually affected. `0` means the guard did not match — never treat it as success. */
53
+ changes: number
54
+ }
55
+ }
56
+
57
+ /** A database handle. `batch()` executes its statements atomically, in order. */
58
+ export interface SqlDatabase {
59
+ prepare(sql: string): SqlStatement
60
+ batch<T = Record<string, unknown>>(statements: SqlStatement[]): Promise<SqlQueryResult<T>[]>
61
+ }