@fabrika/platform 0.2.0 → 0.3.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 +6 -1
- package/src/deploy-locks-sql.ts +8 -0
- package/src/index.ts +1 -0
- package/src/ports.ts +2 -0
- package/src/testing.ts +51 -0
- package/src/uuid.ts +90 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/platform",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"bun": "./src/index.ts",
|
|
20
20
|
"types": "./src/index.ts",
|
|
21
21
|
"default": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./testing": {
|
|
24
|
+
"bun": "./src/testing.ts",
|
|
25
|
+
"types": "./src/testing.ts",
|
|
26
|
+
"default": "./src/testing.ts"
|
|
22
27
|
}
|
|
23
28
|
},
|
|
24
29
|
"scripts": {
|
package/src/deploy-locks-sql.ts
CHANGED
|
@@ -42,6 +42,7 @@ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
|
42
42
|
|
|
43
43
|
export class SqlDeployLocks implements DeployLocks {
|
|
44
44
|
private readonly acquireSql: string
|
|
45
|
+
private readonly renewSql: string
|
|
45
46
|
private readonly releaseSql: string
|
|
46
47
|
private readonly now: () => number
|
|
47
48
|
|
|
@@ -55,6 +56,7 @@ export class SqlDeployLocks implements DeployLocks {
|
|
|
55
56
|
ON CONFLICT (lock_key) DO UPDATE SET holder = excluded.holder, expires_at = excluded.expires_at
|
|
56
57
|
WHERE ${table}.expires_at <= ?`
|
|
57
58
|
this.releaseSql = `DELETE FROM ${table} WHERE lock_key = ? AND holder = ?`
|
|
59
|
+
this.renewSql = `UPDATE ${table} SET expires_at = ? WHERE lock_key = ? AND holder = ? AND expires_at > ?`
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
async acquire(key: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
@@ -63,6 +65,12 @@ export class SqlDeployLocks implements DeployLocks {
|
|
|
63
65
|
return (result.meta.changes ?? 0) > 0
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
async renew(key: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
69
|
+
const now = this.now()
|
|
70
|
+
const result = await this.db.prepare(this.renewSql).bind(now + ttlMs, key, holder, now).run()
|
|
71
|
+
return (result.meta.changes ?? 0) > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
66
74
|
async release(key: string, holder: string): Promise<void> {
|
|
67
75
|
// Deleting the row is what "free" means. The holder predicate makes this a no-op for a run whose
|
|
68
76
|
// lease already expired and was taken over — it cannot delete the newer holder's row.
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,4 @@
|
|
|
11
11
|
export { SqlDeployLocks, type SqlDeployLocksOptions } from './deploy-locks-sql'
|
|
12
12
|
export type { AssetServer, BlobStore, DeployLocks, HttpService, JobQueue, WaitUntil } from './ports'
|
|
13
13
|
export type { SqlDatabase, SqlQueryResult, SqlRunResult, SqlStatement } from './sql'
|
|
14
|
+
export { uuidv7 } from './uuid'
|
package/src/ports.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface JobQueue<T> {
|
|
|
39
39
|
export interface DeployLocks {
|
|
40
40
|
/** Take the lease for `key`. `true` when acquired (free or expired), `false` when live elsewhere. */
|
|
41
41
|
acquire(key: string, holder: string, ttlMs: number): Promise<boolean>
|
|
42
|
+
/** Extend a live lease owned by `holder`; an expired or replaced lease cannot be renewed. */
|
|
43
|
+
renew(key: string, holder: string, ttlMs: number): Promise<boolean>
|
|
42
44
|
/** Release the lease iff `holder` still owns it. Idempotent; a stale holder's call is a no-op. */
|
|
43
45
|
release(key: string, holder: string): Promise<void>
|
|
44
46
|
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BlobStore, JobQueue } from './ports'
|
|
2
|
+
import type { SqlDatabase } from './sql'
|
|
3
|
+
|
|
4
|
+
/** A blob store that keeps string values in memory. */
|
|
5
|
+
export class MemoryBlobs implements BlobStore {
|
|
6
|
+
readonly values = new Map<string, string>()
|
|
7
|
+
|
|
8
|
+
put(key: string, value: string | ArrayBuffer | ReadableStream): Promise<void> {
|
|
9
|
+
if (typeof value !== 'string') throw new Error('test blob store accepts strings only')
|
|
10
|
+
this.values.set(key, value)
|
|
11
|
+
return Promise.resolve()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get(key: string): Promise<{ body: ReadableStream; text(): Promise<string> } | null> {
|
|
15
|
+
const value = this.values.get(key)
|
|
16
|
+
if (value === undefined) return Promise.resolve(null)
|
|
17
|
+
return Promise.resolve({ body: new Blob([value]).stream(), text: () => Promise.resolve(value) })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
delete(key: string): Promise<void> {
|
|
21
|
+
this.values.delete(key)
|
|
22
|
+
return Promise.resolve()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A queue that records every sent message. */
|
|
27
|
+
export class MemoryQueue<T> implements JobQueue<T> {
|
|
28
|
+
readonly messages: T[] = []
|
|
29
|
+
|
|
30
|
+
send(message: T): Promise<void> {
|
|
31
|
+
this.messages.push(message)
|
|
32
|
+
return Promise.resolve()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A queue that accepts and discards every message. */
|
|
37
|
+
export class EmptyQueue<T> implements JobQueue<T> {
|
|
38
|
+
send(): Promise<void> {
|
|
39
|
+
return Promise.resolve()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A database for code paths that must not query it. */
|
|
44
|
+
export const unusedDatabase: SqlDatabase = {
|
|
45
|
+
prepare() {
|
|
46
|
+
throw new Error('database access was not expected')
|
|
47
|
+
},
|
|
48
|
+
batch() {
|
|
49
|
+
return Promise.reject(new Error('database access was not expected'))
|
|
50
|
+
},
|
|
51
|
+
}
|
package/src/uuid.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
|
|
42
|
+
/** `rand_a` is 12 bits, all of it counter. */
|
|
43
|
+
const COUNTER_MAX = 0xfff
|
|
44
|
+
/** Seed from 8 random bits only — the leading 4 stay zero so a millisecond has room to count. */
|
|
45
|
+
const COUNTER_SEED_MASK = 0xff
|
|
46
|
+
|
|
47
|
+
/** Effective millisecond of the last id. NEVER decreases; a backwards clock is absorbed here. */
|
|
48
|
+
let lastMillis = -1
|
|
49
|
+
let counter = 0
|
|
50
|
+
|
|
51
|
+
export function uuidv7(millis: number = Date.now()): string {
|
|
52
|
+
const bytes = new Uint8Array(16)
|
|
53
|
+
crypto.getRandomValues(bytes)
|
|
54
|
+
|
|
55
|
+
// Byte 7 is read as the counter seed BEFORE the counter overwrites it, so the seed costs no extra
|
|
56
|
+
// randomness.
|
|
57
|
+
const seed = (bytes[7] ?? 0) & COUNTER_SEED_MASK
|
|
58
|
+
if (millis > lastMillis) {
|
|
59
|
+
lastMillis = millis
|
|
60
|
+
counter = seed
|
|
61
|
+
} else if (counter < COUNTER_MAX) {
|
|
62
|
+
// Same millisecond, or a clock that went backwards: hold the effective timestamp, count on.
|
|
63
|
+
counter += 1
|
|
64
|
+
} else {
|
|
65
|
+
// Exhausted — borrow a millisecond from the future rather than wrap back under the last id.
|
|
66
|
+
lastMillis += 1
|
|
67
|
+
counter = seed
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 48-bit big-endian timestamp in bytes 0..5.
|
|
71
|
+
bytes[0] = (lastMillis / 2 ** 40) & 0xff
|
|
72
|
+
bytes[1] = (lastMillis / 2 ** 32) & 0xff
|
|
73
|
+
bytes[2] = (lastMillis / 2 ** 24) & 0xff
|
|
74
|
+
bytes[3] = (lastMillis / 2 ** 16) & 0xff
|
|
75
|
+
bytes[4] = (lastMillis / 2 ** 8) & 0xff
|
|
76
|
+
bytes[5] = lastMillis & 0xff
|
|
77
|
+
|
|
78
|
+
// Version 7 in the high nibble of byte 6, the counter in the remaining 12 bits of `rand_a`;
|
|
79
|
+
// variant (10xx) in the high bits of byte 8.
|
|
80
|
+
bytes[6] = 0x70 | (counter >>> 8)
|
|
81
|
+
bytes[7] = counter & 0xff
|
|
82
|
+
bytes[8] = (bytes[8] ?? 0) & 0x3f | 0x80
|
|
83
|
+
|
|
84
|
+
const hex: string[] = []
|
|
85
|
+
for (const b of bytes) {
|
|
86
|
+
hex.push(b.toString(16).padStart(2, '0'))
|
|
87
|
+
}
|
|
88
|
+
const s = hex.join('')
|
|
89
|
+
return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`
|
|
90
|
+
}
|