@fabrika/platform 0.8.1 → 0.9.0-cloudflare.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 +5 -4
- package/types/src/deploy-locks-sql.d.ts +19 -0
- package/types/src/index.d.ts +5 -0
- package/types/src/ports.d.ts +97 -0
- package/types/src/sql.d.ts +44 -0
- package/types/src/testing.d.ts +38 -0
- package/types/src/uuid.d.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-cloudflare.1",
|
|
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;
|