@fabrika/platform-node 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.
@@ -0,0 +1,176 @@
1
+ // `SqlDatabase` over Postgres, using Bun's BUILT-IN SQL client (`Bun.SQL`).
2
+ //
3
+ // No driver dependency: `postgres`/`pg` would add a package, a second connection pool implementation
4
+ // and a second set of type-coercion rules for nothing — `Bun.SQL` already gives a pooled client,
5
+ // `unsafe(text, values)` for pre-built parameterised statements (which is exactly what a D1-shaped
6
+ // port needs — `prepare()` then `bind()`, not a tagged template) and `begin()` for real transactions.
7
+ // The runtime is Bun (ADR-0003: the control plane itself executes deploys on Zerops), so the built-in
8
+ // is available by construction.
9
+ //
10
+ // WHERE POSTGRES DIVERGES FROM D1 — the port hides the API, not the engine. Read this before moving
11
+ // an existing service onto this driver:
12
+ //
13
+ // * `meta.changes` — Bun reports `count` on the result. For a SELECT that is the ROW COUNT, which D1
14
+ // reports as 0, so `run()` maps `count` to `changes` only for INSERT/UPDATE/DELETE/MERGE and
15
+ // reports 0 otherwise. The guarded-UPDATE idiom (`changes === 0` means the guard did not match) is
16
+ // therefore accurate on both, including for `… RETURNING`, where `count` is the affected-row count.
17
+ // * BIGINT / NUMERIC COLUMNS COME BACK AS STRINGS. Bun decodes by column type, not by value: `int8`
18
+ // and `numeric` are `string` (or `bigint` with `{ bigint: true }`), never `number`, even for a
19
+ // value of 5. SQLite/D1 hand back a `number`. Any column a row shape types as `number` must be
20
+ // `INTEGER` (int4) in the Postgres migration, or the caller must coerce. int4 tops out at
21
+ // 2147483647 — fine for the unix-SECONDS timestamps this codebase uses, NOT for unix millis.
22
+ // * PARAMETER TYPES ARE CHECKED. `WHERE text_col = $1` bound with a number fails with
23
+ // `operator does not exist: text = integer`; SQLite would have compared them happily (and returned
24
+ // no rows). This surfaces latent bind-type bugs as loud errors rather than silent empty results.
25
+ // * `undefined` binds as NULL (D1 throws). Kept deliberately: both existing bun:sqlite test harnesses
26
+ // already coerce `undefined` → null, so this matches what the services are tested against.
27
+ // * SQLite-only SQL still fails, obviously: `unixepoch()` does not exist in Postgres. Several
28
+ // statements in `@fabrika/control` and `@fabrika/iam` use it today.
29
+ // * Prepared statements are cached per connection by default. Behind a transaction-mode pooler
30
+ // (pgbouncer) construct the client with `{ prepare: false }`.
31
+
32
+ import type { SqlDatabase, SqlQueryResult, SqlRunResult, SqlStatement } from '@fabrika/platform'
33
+ import { rewritePlaceholders, type RewrittenSql } from './placeholders'
34
+
35
+ /** Command tags whose row count IS an affected-row count. Anything else reports `changes: 0`, as D1 does. */
36
+ const MUTATING_COMMANDS = new Set(['INSERT', 'UPDATE', 'DELETE', 'MERGE'])
37
+
38
+ /** What a bound value may be. Anything else is a programming error and throws at bind time, as D1 does. */
39
+ export type SqlBindValue = string | number | bigint | boolean | Date | Uint8Array | null
40
+
41
+ /**
42
+ * One prepared statement. IMMUTABLE: `bind()` returns a NEW statement carrying the values and leaves
43
+ * the receiver untouched, which is D1's contract and what call sites rely on when they prepare once
44
+ * and bind in a loop.
45
+ */
46
+ export class PostgresStatement implements SqlStatement {
47
+ constructor(
48
+ private readonly sql: Bun.SQL,
49
+ private readonly text: string,
50
+ private readonly placeholders: number,
51
+ private readonly values: readonly SqlBindValue[],
52
+ ) {}
53
+
54
+ bind(...values: unknown[]): SqlStatement {
55
+ return new PostgresStatement(this.sql, this.text, this.placeholders, values.map(toBindValue))
56
+ }
57
+
58
+ async first<T = Record<string, unknown>>(): Promise<T | null> {
59
+ const rows = await this.rows<T>(this.sql)
60
+ return rows[0] ?? null
61
+ }
62
+
63
+ async all<T = Record<string, unknown>>(): Promise<SqlQueryResult<T>> {
64
+ return { results: await this.rows<T>(this.sql) }
65
+ }
66
+
67
+ async run(): Promise<SqlRunResult> {
68
+ const result: unknown = await this.execute<Record<string, unknown>>(this.sql)
69
+ return { meta: { changes: changesOf(result) } }
70
+ }
71
+
72
+ /**
73
+ * Run this statement on a specific handle — the pool, or the transaction-scoped handle `batch()`
74
+ * opens. Internal to the package; the port only ever sees `first`/`all`/`run`.
75
+ */
76
+ async rows<T>(handle: Bun.SQL): Promise<T[]> {
77
+ return this.execute<T>(handle)
78
+ }
79
+
80
+ private execute<T>(handle: Bun.SQL): Promise<T[]> {
81
+ if (this.values.length !== this.placeholders) {
82
+ throw new Error(`statement expects ${this.placeholders} bound value(s), got ${this.values.length}`)
83
+ }
84
+ return handle.unsafe<T[]>(this.text, [...this.values])
85
+ }
86
+ }
87
+
88
+ /**
89
+ * A Postgres-backed `SqlDatabase`. Rewritten statement text is memoised per SQL string, so the
90
+ * prepare-once/bind-many idiom tokenises each statement exactly once.
91
+ */
92
+ export class PostgresDatabase implements SqlDatabase {
93
+ private readonly rewrites = new Map<string, RewrittenSql>()
94
+
95
+ constructor(private readonly sql: Bun.SQL) {}
96
+
97
+ /**
98
+ * Open a pooled client. The URL is a CREDENTIAL — it is never logged, and never included in an
99
+ * error message raised here.
100
+ */
101
+ static connect(url: string, options: Omit<Bun.SQL.PostgresOrMySQLOptions, 'url' | 'adapter'> = {}): PostgresDatabase {
102
+ return new PostgresDatabase(new Bun.SQL({ ...options, url, adapter: 'postgres' }))
103
+ }
104
+
105
+ prepare(sql: string): SqlStatement {
106
+ let rewritten = this.rewrites.get(sql)
107
+ if (rewritten === undefined) {
108
+ rewritten = rewritePlaceholders(sql)
109
+ this.rewrites.set(sql, rewritten)
110
+ }
111
+ return new PostgresStatement(this.sql, rewritten.text, rewritten.count, [])
112
+ }
113
+
114
+ /** Run every statement in ONE transaction, in order. A throw rolls the whole batch back. */
115
+ async batch<T = Record<string, unknown>>(statements: SqlStatement[]): Promise<SqlQueryResult<T>[]> {
116
+ const run = async (tx: Bun.SQL): Promise<SqlQueryResult<T>[]> => {
117
+ const out: SqlQueryResult<T>[] = []
118
+ for (const statement of statements) {
119
+ if (!(statement instanceof PostgresStatement)) {
120
+ throw new Error('batch() accepts only statements created by this database')
121
+ }
122
+ out.push({ results: await statement.rows<T>(tx) })
123
+ }
124
+ return out
125
+ }
126
+ return this.sql.begin(run)
127
+ }
128
+
129
+ /** Close the pool. Waits for in-flight queries unless a timeout is given. */
130
+ async close(options?: { timeout?: number }): Promise<void> {
131
+ await this.sql.close(options)
132
+ }
133
+ }
134
+
135
+ /** The rows-affected count D1 would report, or 0 for a statement that changes nothing. */
136
+ function changesOf(result: unknown): number {
137
+ const command: unknown = readProperty(result, 'command')
138
+ if (typeof command === 'string' && !MUTATING_COMMANDS.has(command)) {
139
+ return 0
140
+ }
141
+ const count: unknown = readProperty(result, 'count')
142
+ return typeof count === 'number' ? count : 0
143
+ }
144
+
145
+ function readProperty(value: unknown, key: string): unknown {
146
+ if (typeof value !== 'object' || value === null) {
147
+ return undefined
148
+ }
149
+ return Reflect.get(value, key)
150
+ }
151
+
152
+ /**
153
+ * Narrow a bound value to something the driver can send. `undefined` becomes NULL (see the divergence
154
+ * note at the top); anything else non-scalar is a bug in the call site and throws immediately, rather
155
+ * than being silently JSON-ified into a column.
156
+ */
157
+ function toBindValue(value: unknown): SqlBindValue {
158
+ if (value === undefined) {
159
+ return null
160
+ }
161
+ if (
162
+ value === null
163
+ || typeof value === 'string'
164
+ || typeof value === 'number'
165
+ || typeof value === 'bigint'
166
+ || typeof value === 'boolean'
167
+ || value instanceof Date
168
+ || value instanceof Uint8Array
169
+ ) {
170
+ return value
171
+ }
172
+ if (value instanceof ArrayBuffer) {
173
+ return new Uint8Array(value)
174
+ }
175
+ throw new Error(`unsupported bind value of type ${typeof value}`)
176
+ }
package/src/uuid.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * UUIDv7 generator — time-ordered ids, so a jobs table orders by id chronologically and a keyset
3
+ * cursor works, monotonic WITHIN a millisecond as well as across one. Ids are minted CALLER-SIDE,
4
+ * never by SQL (the repo-wide convention, and the one thing that keeps inserts identical across
5
+ * SQLite and Postgres). Self-contained so this package has no dependencies beyond the port it
6
+ * implements.
7
+ *
8
+ * RFC 9562 layout, with §6.2's "fixed bit-length dedicated counter" monotonic variant:
9
+ *
10
+ * bytes 0..5 48-bit big-endian Unix-millis timestamp
11
+ * byte 6 version nibble `7` + the high 4 bits of a 12-bit counter (`rand_a`)
12
+ * byte 7 the low 8 bits of that counter
13
+ * byte 8 variant bits `10` + 6 random bits
14
+ * bytes 9..15 56 random bits
15
+ *
16
+ * so **62 random bits per id** — down from the 74 of the unguarded Method 0 this used to be — plus 8
17
+ * fresh random bits seeding the counter at each new millisecond.
18
+ *
19
+ * The counter is re-seeded from 8 random bits (the top 4 left zero, the RFC's rollover guard, so at
20
+ * least 3840 ids per millisecond are guaranteed) whenever the millisecond advances, and incremented
21
+ * otherwise. `rand_a` sits immediately after the timestamp and the version nibble in the canonical
22
+ * string, so two ids minted in the same millisecond differ there and a TEXT `ORDER BY` puts them in
23
+ * mint order, strictly.
24
+ *
25
+ * Two clock hazards are handled explicitly rather than by wrapping:
26
+ * - counter exhausted inside one millisecond → borrow one millisecond from the future (RFC 9562
27
+ * §6.2) and re-seed; the borrowed time is repaid as soon as the clock catches up.
28
+ * - the timestamp goes BACKWARDS → keep the last effective millisecond and keep counting, so the
29
+ * sequence never repeats and never decreases.
30
+ *
31
+ * The counter is per GENERATOR — one process. Two ids minted in the same millisecond by two processes
32
+ * tie only if their independently seeded counters collide (~1/256), and the 62 random bits break
33
+ * that; within one process the order is exact. That is the whole promise: strictly increasing per
34
+ * generator, chronological to the millisecond across generators.
35
+ *
36
+ * `millis` is injectable so a caller that already has a clock (the job queue does) mints ids on THAT
37
+ * clock, keeping id order and row order consistent. Because the generator is monotonic, the timestamp
38
+ * actually embedded is `max(millis, last embedded)`: a supplied millisecond BEHIND one already used
39
+ * is clamped, and the counter carries the ordering instead. Ordering is the promise; an exact
40
+ * round-trip of an arbitrary past `millis` is not.
41
+ *
42
+ * `@fabrika/auth-core` and `@fabrika/control` carry deliberate copies of this same algorithm — see
43
+ * the note in each.
44
+ */
45
+
46
+ /** `rand_a` is 12 bits, all of it counter. */
47
+ const COUNTER_MAX = 0xfff
48
+ /** Seed from 8 random bits only — the leading 4 stay zero so a millisecond has room to count. */
49
+ const COUNTER_SEED_MASK = 0xff
50
+
51
+ /** Effective millisecond of the last id. NEVER decreases; a backwards clock is absorbed here. */
52
+ let lastMillis = -1
53
+ let counter = 0
54
+
55
+ export function uuidv7(millis: number = Date.now()): string {
56
+ const bytes = new Uint8Array(16)
57
+ crypto.getRandomValues(bytes)
58
+
59
+ // Byte 7 is read as the counter seed BEFORE the counter overwrites it, so the seed costs no extra
60
+ // randomness.
61
+ const seed = (bytes[7] ?? 0) & COUNTER_SEED_MASK
62
+ if (millis > lastMillis) {
63
+ lastMillis = millis
64
+ counter = seed
65
+ } else if (counter < COUNTER_MAX) {
66
+ // Same millisecond, or a clock that went backwards: hold the effective timestamp, count on.
67
+ counter += 1
68
+ } else {
69
+ // Exhausted — borrow a millisecond from the future rather than wrap back under the last id.
70
+ lastMillis += 1
71
+ counter = seed
72
+ }
73
+
74
+ // 48-bit big-endian timestamp in bytes 0..5.
75
+ bytes[0] = (lastMillis / 2 ** 40) & 0xff
76
+ bytes[1] = (lastMillis / 2 ** 32) & 0xff
77
+ bytes[2] = (lastMillis / 2 ** 24) & 0xff
78
+ bytes[3] = (lastMillis / 2 ** 16) & 0xff
79
+ bytes[4] = (lastMillis / 2 ** 8) & 0xff
80
+ bytes[5] = lastMillis & 0xff
81
+
82
+ // Version 7 in the high nibble of byte 6, the counter in the remaining 12 bits of `rand_a`;
83
+ // variant (10xx) in the high bits of byte 8.
84
+ bytes[6] = 0x70 | (counter >>> 8)
85
+ bytes[7] = counter & 0xff
86
+ bytes[8] = (bytes[8] ?? 0) & 0x3f | 0x80
87
+
88
+ const hex: string[] = []
89
+ for (const b of bytes) {
90
+ hex.push(b.toString(16).padStart(2, '0'))
91
+ }
92
+ const s = hex.join('')
93
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`
94
+ }
@@ -0,0 +1,69 @@
1
+ // `WaitUntil` for a long-running process — the seam that stops call sites branching on the runtime.
2
+ //
3
+ // On Workers `ctx.waitUntil` keeps the isolate alive past the response. A Bun process is already
4
+ // alive, so all this has to do is SUPERVISE: hold a reference so a graceful shutdown can wait for the
5
+ // work, and — the part that actually matters — swallow rejections. An unhandled rejection from a
6
+ // background promise terminates the process by default, which would turn a failed log flush into an
7
+ // outage. Every rejection is logged as a short message and dropped.
8
+ //
9
+ // `drain()` exists for the two moments a process needs it: shutdown (finish what was promised before
10
+ // exiting) and tests (assert on the effect of background work without sleeping).
11
+
12
+ import type { WaitUntil } from '@fabrika/platform'
13
+
14
+ export interface BackgroundTasks {
15
+ /** The port itself. Hand it a promise and forget it; it never throws and never rejects. */
16
+ readonly waitUntil: WaitUntil
17
+ /** How many supervised promises are still in flight. */
18
+ readonly pending: number
19
+ /** Settle once every supervised promise has finished, including ones added while draining. */
20
+ drain(): Promise<void>
21
+ }
22
+
23
+ export interface BackgroundTasksOptions {
24
+ /**
25
+ * Called for every rejection instead of the default log. Keep it cheap and non-throwing — a throw
26
+ * here is itself swallowed. NEVER log the error object: it may quote a clone URL with an embedded
27
+ * token.
28
+ */
29
+ onError?: (error: unknown) => void
30
+ /** Prefix for the default log line, so a process running several of these can tell them apart. */
31
+ label?: string
32
+ }
33
+
34
+ export function createBackgroundTasks(options: BackgroundTasksOptions = {}): BackgroundTasks {
35
+ const label = options.label ?? 'background task'
36
+ const onError = options.onError ?? ((error: unknown) => {
37
+ console.error(`${label} failed:`, error instanceof Error ? error.message : 'unknown error')
38
+ })
39
+ const inFlight = new Set<Promise<void>>()
40
+
41
+ const waitUntil: WaitUntil = (promise) => {
42
+ const supervised = Promise.resolve(promise).then(
43
+ () => {},
44
+ (error: unknown) => {
45
+ try {
46
+ onError(error)
47
+ } catch {
48
+ // A reporter that throws must not resurrect the failure it was meant to absorb.
49
+ }
50
+ },
51
+ ).finally(() => {
52
+ inFlight.delete(supervised)
53
+ })
54
+ inFlight.add(supervised)
55
+ }
56
+
57
+ return {
58
+ waitUntil,
59
+ get pending(): number {
60
+ return inFlight.size
61
+ },
62
+ async drain(): Promise<void> {
63
+ // A task may enqueue another task, so keep going until the set is genuinely empty.
64
+ while (inFlight.size > 0) {
65
+ await Promise.all([...inFlight])
66
+ }
67
+ },
68
+ }
69
+ }