@fabrika/platform 0.0.26 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/platform",
3
- "version": "0.0.26",
3
+ "version": "0.3.1",
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": {
@@ -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
+ }