@fabrika/platform 0.9.0-dev.1 → 0.9.0-dev.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/platform",
3
- "version": "0.9.0-dev.1",
3
+ "version": "0.9.0-dev.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,17 +12,18 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
- "!src/**/__tests__/**"
15
+ "!src/**/__tests__/**",
16
+ "types"
16
17
  ],
17
18
  "exports": {
18
19
  ".": {
19
20
  "bun": "./src/index.ts",
20
- "types": "./src/index.ts",
21
+ "types": "./types/src/index.d.ts",
21
22
  "default": "./src/index.ts"
22
23
  },
23
24
  "./testing": {
24
25
  "bun": "./src/testing.ts",
25
- "types": "./src/testing.ts",
26
+ "types": "./types/src/testing.d.ts",
26
27
  "default": "./src/testing.ts"
27
28
  }
28
29
  },
@@ -0,0 +1,19 @@
1
+ import type { DeployLocks } from './ports';
2
+ import type { SqlDatabase } from './sql';
3
+ export interface SqlDeployLocksOptions {
4
+ /** Table holding the leases. Must already exist; the DDL is the owning service's migration. */
5
+ table?: string;
6
+ /** Injectable clock so lease expiry is testable without sleeping. */
7
+ now?: () => number;
8
+ }
9
+ export declare class SqlDeployLocks implements DeployLocks {
10
+ private readonly db;
11
+ private readonly acquireSql;
12
+ private readonly renewSql;
13
+ private readonly releaseSql;
14
+ private readonly now;
15
+ constructor(db: SqlDatabase, options?: SqlDeployLocksOptions);
16
+ acquire(key: string, holder: string, ttlMs: number): Promise<boolean>;
17
+ renew(key: string, holder: string, ttlMs: number): Promise<boolean>;
18
+ release(key: string, holder: string): Promise<void>;
19
+ }
@@ -0,0 +1,5 @@
1
+ export { SqlDeployLocks, type SqlDeployLocksOptions } from './deploy-locks-sql';
2
+ export { ObjectExistsError, ObjectTooLargeError } from './ports';
3
+ export type { AssetServer, BlobStore, DeployLocks, FileObjectStore, HttpService, JobQueue, ObjectMetadata, ObjectWriteOptions, StoredObject, WaitUntil, } from './ports';
4
+ export type { SqlDatabase, SqlQueryResult, SqlRunResult, SqlStatement } from './sql';
5
+ export { uuidv7 } from './uuid';
@@ -0,0 +1,97 @@
1
+ /** Bytes written to blob storage (run logs). Both R2 and S3/MinIO satisfy this. */
2
+ export interface BlobStore {
3
+ put(key: string, value: string | ArrayBuffer | ReadableStream): Promise<void>;
4
+ get(key: string): Promise<{
5
+ body: ReadableStream;
6
+ text(): Promise<string>;
7
+ } | null>;
8
+ delete(key: string): Promise<void>;
9
+ }
10
+ /** What the store recorded for an object. `contentType` is what the writer declared, or null. */
11
+ export interface ObjectMetadata {
12
+ readonly size: number;
13
+ readonly contentType: string | null;
14
+ }
15
+ export interface StoredObject extends ObjectMetadata {
16
+ /** One stream, consumable once. */
17
+ readonly body: ReadableStream<Uint8Array>;
18
+ /** These exact bytes' version, from the same answer as the body: an S3 ETag. It changes when the object is replaced. */
19
+ readonly version: string;
20
+ }
21
+ export interface ObjectWriteOptions {
22
+ readonly contentType: string;
23
+ /** The most bytes the object may have. The write stops reading `body` once more arrive. */
24
+ readonly maxBytes: number;
25
+ }
26
+ /** A write named a key that already holds an object: a file's object is written once. Read by `name`. */
27
+ export declare class ObjectExistsError extends Error {
28
+ readonly name = "ObjectExistsError";
29
+ constructor();
30
+ }
31
+ /** The body of a write was longer than its `maxBytes`. Read by `name`, never by a cross-package `instanceof`. */
32
+ export declare class ObjectTooLargeError extends Error {
33
+ readonly name = "ObjectTooLargeError";
34
+ readonly maxBytes: number;
35
+ constructor(maxBytes: number);
36
+ }
37
+ /**
38
+ * The objects of an application's file collections (S3/MinIO). Separate from `BlobStore` because its callers
39
+ * need a size bound enforced while the bytes stream and the metadata read back; an R2 adapter that ignored
40
+ * either would silently drop a limit.
41
+ */
42
+ export interface FileObjectStore {
43
+ /**
44
+ * Streams `body` to a new `key`. A key that already holds an object is refused with `ObjectExistsError`, and nothing
45
+ * is written. Past `options.maxBytes` it stops reading, keeps nothing under `key` and rejects with
46
+ * `ObjectTooLargeError`. Any other failure also leaves nothing stored.
47
+ */
48
+ write(key: string, body: ReadableStream<Uint8Array>, options: ObjectWriteOptions): Promise<ObjectMetadata>;
49
+ /** The object with its metadata and version, in one answer, or `null` for a missing key. `signal` stops it. */
50
+ read(key: string, signal?: AbortSignal): Promise<StoredObject | null>;
51
+ /** The object only while it is still `version`, in one answer; `null` when it is missing or was replaced. */
52
+ readVersion(key: string, version: string, signal?: AbortSignal): Promise<StoredObject | null>;
53
+ /** Idempotent: a missing key is not an error. */
54
+ delete(key: string): Promise<void>;
55
+ }
56
+ /** Enqueue a deploy job. The CONSUMER side is platform-specific and deliberately not modelled here. */
57
+ export interface JobQueue<T> {
58
+ send(message: T, options?: {
59
+ delaySeconds?: number;
60
+ }): Promise<void>;
61
+ }
62
+ /**
63
+ * Per-target mutual exclusion, keyed by `<app>:<env>`. Matches the seam the control plane already
64
+ * had around its Durable Object, so swapping the implementation touches no call site.
65
+ *
66
+ * Contract, unchanged from the DO it replaces:
67
+ * - NON-REENTRANT — a live lease returns false even to the same holder, so a redelivered message
68
+ * defers rather than double-running.
69
+ * - TTL-BOUNDED — a consumer that dies mid-deploy self-heals when the lease expires, instead of
70
+ * wedging that app-env forever.
71
+ * - HOLDER-CHECKED release — a late release from a superseded run can never free a lease that a
72
+ * newer run has since taken.
73
+ *
74
+ * The database-backed implementation expresses all three as one conditional UPDATE, which is atomic
75
+ * on SQLite and Postgres alike — so this port has ONE implementation on every platform.
76
+ */
77
+ export interface DeployLocks {
78
+ /** Take the lease for `key`. `true` when acquired (free or expired), `false` when live elsewhere. */
79
+ acquire(key: string, holder: string, ttlMs: number): Promise<boolean>;
80
+ /** Extend a live lease owned by `holder`; an expired or replaced lease cannot be renewed. */
81
+ renew(key: string, holder: string, ttlMs: number): Promise<boolean>;
82
+ /** Release the lease iff `holder` still owns it. Idempotent; a stale holder's call is a no-op. */
83
+ release(key: string, holder: string): Promise<void>;
84
+ }
85
+ /** One HTTP-speaking service behind a platform-native or network transport. */
86
+ export interface HttpService {
87
+ fetch(request: Request): Promise<Response>;
88
+ }
89
+ /** Serves the built SPA for non-API paths. */
90
+ export interface AssetServer extends HttpService {
91
+ }
92
+ /**
93
+ * Keep work alive past the response. On Workers this is `ctx.waitUntil`; in a long-running process
94
+ * it is a supervised background promise. Implementations MUST swallow-and-log rejections — a
95
+ * background failure must never take the process down.
96
+ */
97
+ export type WaitUntil = (promise: Promise<unknown>) => void;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A prepared statement. `bind()` returns a NEW statement; it never mutates the receiver (D1
3
+ * semantics) — call sites build a base statement and bind it repeatedly, so a mutating `bind()`
4
+ * corrupts them silently.
5
+ *
6
+ * Two further contracts every driver must honour, both relied on by existing call sites:
7
+ * - **`bind()` is optional.** A statement with no placeholders is executed directly, and `bind()`
8
+ * may legally be called with zero arguments.
9
+ * - **SQL NULL maps to JS `null`, never `undefined`.** Row shapes are checked with `=== null`.
10
+ */
11
+ export interface SqlStatement {
12
+ /** Bind positional parameters, left to right, for this statement's `?` placeholders. */
13
+ bind(...values: unknown[]): SqlStatement;
14
+ /** First row, or `null` when the query matched nothing. */
15
+ first<T = Record<string, unknown>>(): Promise<T | null>;
16
+ /** All matching rows. */
17
+ all<T = Record<string, unknown>>(): Promise<SqlQueryResult<T>>;
18
+ /** Execute a write. Read `meta.changes` to learn whether it affected anything. */
19
+ run(): Promise<SqlRunResult>;
20
+ }
21
+ /** The result of `all()` — wrapped in `{ results }` to match D1 so no call site changes. */
22
+ export interface SqlQueryResult<T> {
23
+ results: T[];
24
+ }
25
+ /**
26
+ * The result of `run()`. `changes` is how the guarded-UPDATE idioms across this codebase detect a
27
+ * no-op, so it is REQUIRED: a driver that left it undefined would turn every successful guarded
28
+ * write into a silent "not found". `D1Meta.changes` is likewise non-optional, so D1 still satisfies
29
+ * this structurally.
30
+ *
31
+ * It counts rows AFFECTED BY A WRITE. A `SELECT` reports `0` — drivers whose underlying client
32
+ * returns a row count for reads must map that to `0` rather than passing it through.
33
+ */
34
+ export interface SqlRunResult {
35
+ meta: {
36
+ /** Rows actually affected. `0` means the guard did not match — never treat it as success. */
37
+ changes: number;
38
+ };
39
+ }
40
+ /** A database handle. `batch()` executes its statements atomically, in order. */
41
+ export interface SqlDatabase {
42
+ prepare(sql: string): SqlStatement;
43
+ batch<T = Record<string, unknown>>(statements: SqlStatement[]): Promise<SqlQueryResult<T>[]>;
44
+ }
@@ -0,0 +1,38 @@
1
+ import { type BlobStore, type FileObjectStore, type JobQueue, type ObjectMetadata, type ObjectWriteOptions, type StoredObject } from './ports';
2
+ import type { SqlDatabase } from './sql';
3
+ /** A blob store that keeps string values in memory. */
4
+ export declare class MemoryBlobs implements BlobStore {
5
+ readonly values: Map<string, string>;
6
+ put(key: string, value: string | ArrayBuffer | ReadableStream): Promise<void>;
7
+ get(key: string): Promise<{
8
+ body: ReadableStream;
9
+ text(): Promise<string>;
10
+ } | null>;
11
+ delete(key: string): Promise<void>;
12
+ }
13
+ /** A file object store in memory, with the port's size bound enforced while the body streams. */
14
+ export declare class MemoryFileObjects implements FileObjectStore {
15
+ #private;
16
+ readonly objects: Map<string, {
17
+ readonly bytes: Uint8Array<ArrayBuffer>;
18
+ readonly contentType: string;
19
+ readonly version: string;
20
+ }>;
21
+ write(key: string, body: ReadableStream<Uint8Array>, options: ObjectWriteOptions): Promise<ObjectMetadata>;
22
+ /** Puts `bytes` under `key` whatever it held, under a new version: what a write outside the upload path does. */
23
+ replace(key: string, bytes: Uint8Array<ArrayBuffer>, contentType?: string): void;
24
+ read(key: string): Promise<StoredObject | null>;
25
+ readVersion(key: string, version: string): Promise<StoredObject | null>;
26
+ delete(key: string): Promise<void>;
27
+ }
28
+ /** A queue that records every sent message. */
29
+ export declare class MemoryQueue<T> implements JobQueue<T> {
30
+ readonly messages: T[];
31
+ send(message: T): Promise<void>;
32
+ }
33
+ /** A queue that accepts and discards every message. */
34
+ export declare class EmptyQueue<T> implements JobQueue<T> {
35
+ send(): Promise<void>;
36
+ }
37
+ /** A database for code paths that must not query it. */
38
+ export declare const unusedDatabase: SqlDatabase;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * UUIDv7 generator — time-ordered ids, so a table orders by id chronologically and a keyset cursor
3
+ * works, monotonic WITHIN a millisecond as well as across one. Ids are minted CALLER-SIDE, never by
4
+ * SQL (the repo-wide convention, and the one thing that keeps inserts identical across SQLite and
5
+ * Postgres). Runtime-neutral: Web Crypto's `crypto.getRandomValues` exists on Workers and Bun alike.
6
+ *
7
+ * RFC 9562 layout, with §6.2's "fixed bit-length dedicated counter" monotonic variant:
8
+ *
9
+ * bytes 0..5 48-bit big-endian Unix-millis timestamp
10
+ * byte 6 version nibble `7` + the high 4 bits of a 12-bit counter (`rand_a`)
11
+ * byte 7 the low 8 bits of that counter
12
+ * byte 8 variant bits `10` + 6 random bits
13
+ * bytes 9..15 56 random bits
14
+ *
15
+ * so **62 random bits per id** — down from the 74 of the unguarded Method 0 this used to be — plus 8
16
+ * fresh random bits seeding the counter at each new millisecond.
17
+ *
18
+ * The counter is re-seeded from 8 random bits (the top 4 left zero, the RFC's rollover guard, so at
19
+ * least 3840 ids per millisecond are guaranteed) whenever the millisecond advances, and incremented
20
+ * otherwise. `rand_a` sits immediately after the timestamp and the version nibble in the canonical
21
+ * string, so two ids minted in the same millisecond differ there and a TEXT `ORDER BY` puts them in
22
+ * mint order, strictly.
23
+ *
24
+ * Two clock hazards are handled explicitly rather than by wrapping:
25
+ * - counter exhausted inside one millisecond → borrow one millisecond from the future (RFC 9562
26
+ * §6.2) and re-seed; the borrowed time is repaid as soon as the clock catches up.
27
+ * - the timestamp goes BACKWARDS → keep the last effective millisecond and keep counting, so the
28
+ * sequence never repeats and never decreases.
29
+ *
30
+ * The counter is per GENERATOR — one process, one Worker isolate. Two ids minted in the same
31
+ * millisecond by two generators tie only if their independently seeded counters collide (~1/256), and
32
+ * the 62 random bits break that; within one generator the order is exact. That is the whole promise: strictly increasing per
33
+ * generator, chronological to the millisecond across generators.
34
+ *
35
+ * `millis` is injectable so a caller that already has a clock (a job queue, a repository) mints ids on THAT
36
+ * clock, keeping id order and row order consistent. Because the generator is monotonic, the timestamp
37
+ * actually embedded is `max(millis, last embedded)`: a supplied millisecond BEHIND one already used
38
+ * is clamped, and the counter carries the ordering instead. Ordering is the promise; an exact
39
+ * round-trip of an arbitrary past `millis` is not.
40
+ */
41
+ export declare function uuidv7(millis?: number): string;