@fabrika/platform-node 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 +2 -2
- package/src/index.ts +8 -4
- package/src/job-queue-postgres.ts +45 -17
- package/src/postgres-migrations.ts +1 -144
- package/src/service-postgres-migrations.ts +2 -6
- package/src/uuid.ts +0 -94
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/platform-node",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@fabrika/platform": "^0.2
|
|
35
|
+
"@fabrika/platform": "^0.3.2"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -20,14 +20,19 @@
|
|
|
20
20
|
|
|
21
21
|
export { FileSystemAssetServer, type FileSystemAssetServerOptions } from './asset-server-fs'
|
|
22
22
|
export { S3BlobStore, type S3BlobStoreOptions } from './blob-s3'
|
|
23
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
decodeJob,
|
|
25
|
+
type Job,
|
|
26
|
+
PostgresJobConsumer,
|
|
27
|
+
type PostgresJobConsumerOptions,
|
|
28
|
+
PostgresJobQueue,
|
|
29
|
+
type PostgresJobQueueOptions,
|
|
30
|
+
} from './job-queue-postgres'
|
|
24
31
|
export { rewritePlaceholders, type RewrittenSql } from './placeholders'
|
|
25
32
|
export {
|
|
26
33
|
applyPostgresMigrations,
|
|
27
34
|
definePostgresMigrationPlan,
|
|
28
35
|
platformNodePostgresMigrationBundle,
|
|
29
|
-
type PostgresLegacyMigrationEffect,
|
|
30
|
-
type PostgresLegacyMigrationLedger,
|
|
31
36
|
type PostgresMigration,
|
|
32
37
|
type PostgresMigrationBundle,
|
|
33
38
|
postgresMigrationFilenames,
|
|
@@ -43,5 +48,4 @@ export {
|
|
|
43
48
|
type PostgresServiceMigrationsConfig,
|
|
44
49
|
} from './service-postgres-migrations'
|
|
45
50
|
export { PostgresDatabase, PostgresStatement, type SqlBindValue } from './sql-postgres'
|
|
46
|
-
export { uuidv7 } from './uuid'
|
|
47
51
|
export { type BackgroundTasks, type BackgroundTasksOptions, createBackgroundTasks } from './wait-until'
|
|
@@ -25,10 +25,9 @@
|
|
|
25
25
|
// `FOR UPDATE SKIP LOCKED` is the one Postgres-specific statement in this package; the DDL itself
|
|
26
26
|
// (migrations/0001_jobs.sql) is in the SQLite ∩ Postgres common subset.
|
|
27
27
|
|
|
28
|
-
import type
|
|
29
|
-
import { uuidv7 } from './uuid'
|
|
28
|
+
import { type JobQueue, type SqlDatabase, uuidv7 } from '@fabrika/platform'
|
|
30
29
|
|
|
31
|
-
/** A claimed
|
|
30
|
+
/** A claimed delivery. Raw claims carry stored JSON text; typed claims carry the caller's decoded payload. */
|
|
32
31
|
export interface Job<T> {
|
|
33
32
|
id: string
|
|
34
33
|
payload: T
|
|
@@ -38,6 +37,12 @@ export interface Job<T> {
|
|
|
38
37
|
maxAttempts: number
|
|
39
38
|
}
|
|
40
39
|
|
|
40
|
+
/** Parse and narrow one stored delivery without losing its settlement metadata. */
|
|
41
|
+
export function decodeJob<T>(job: Job<string>, decode: (payload: unknown) => T): Job<T> {
|
|
42
|
+
const parsed: unknown = JSON.parse(job.payload)
|
|
43
|
+
return { ...job, payload: decode(parsed) }
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
export interface PostgresJobQueueOptions {
|
|
42
47
|
/** Table holding the jobs. Must already exist; see migrations/0001_jobs.sql. */
|
|
43
48
|
table?: string
|
|
@@ -121,6 +126,12 @@ export class PostgresJobQueue<T> implements JobQueue<T> {
|
|
|
121
126
|
* than reaching a handler that assumes a shape.
|
|
122
127
|
*/
|
|
123
128
|
async claim(options: { limit: number; visibilityTimeoutMs: number; decode: (payload: unknown) => T }): Promise<Job<T>[]> {
|
|
129
|
+
const jobs = await this.claimRaw(options)
|
|
130
|
+
return jobs.map((job) => decodeJob(job, options.decode))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Claim stored JSON text so consumers can decode and settle each delivery independently. */
|
|
134
|
+
async claimRaw(options: { limit: number; visibilityTimeoutMs: number }): Promise<Job<string>[]> {
|
|
124
135
|
const now = this.now()
|
|
125
136
|
const { results } = await this.db
|
|
126
137
|
.prepare(this.claimSql)
|
|
@@ -131,10 +142,7 @@ export class PostgresJobQueue<T> implements JobQueue<T> {
|
|
|
131
142
|
// restores enqueue order, because ids are monotonic UUIDv7 and therefore strictly time-ordered.
|
|
132
143
|
return results
|
|
133
144
|
.sort((left, right) => (left.id < right.id ? -1 : 1))
|
|
134
|
-
.map((row) => {
|
|
135
|
-
const parsed: unknown = JSON.parse(row.payload)
|
|
136
|
-
return { id: row.id, payload: options.decode(parsed), attempts: row.attempts, maxAttempts: row.max_attempts }
|
|
137
|
-
})
|
|
145
|
+
.map((row) => ({ id: row.id, payload: row.payload, attempts: row.attempts, maxAttempts: row.max_attempts }))
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
/** The job is done (or permanently abandoned) — drop the row. Idempotent. */
|
|
@@ -149,7 +157,7 @@ export class PostgresJobQueue<T> implements JobQueue<T> {
|
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
export interface PostgresJobConsumerOptions<T> {
|
|
152
|
-
/** Narrow the stored JSON to the message type.
|
|
160
|
+
/** Narrow the stored JSON to the message type. Invalid deliveries retry until their budget is spent. */
|
|
153
161
|
decode: (payload: unknown) => T
|
|
154
162
|
/** Handle one job. A throw schedules a retry until `maxAttempts` is spent. */
|
|
155
163
|
handler: (job: Job<T>) => Promise<void>
|
|
@@ -196,23 +204,26 @@ export class PostgresJobConsumer<T> {
|
|
|
196
204
|
visibilityTimeoutMs: options.visibilityTimeoutMs ?? DEFAULT_VISIBILITY_TIMEOUT_MS,
|
|
197
205
|
retryDelayMs: options.retryDelayMs ?? ((attempts) => Math.min(60_000, 1_000 * 2 ** attempts)),
|
|
198
206
|
}
|
|
199
|
-
this.onError = options.onError ?? ((
|
|
200
|
-
console.error('job handler failed
|
|
207
|
+
this.onError = options.onError ?? (() => {
|
|
208
|
+
console.error('job handler failed')
|
|
201
209
|
})
|
|
202
210
|
this.onAbandoned = options.onAbandoned ?? ((job) => {
|
|
203
211
|
console.warn(`job ${job.id} abandoned after ${job.attempts} attempt(s)`)
|
|
204
212
|
})
|
|
205
213
|
}
|
|
206
214
|
|
|
207
|
-
/** Run one pass. Returns
|
|
215
|
+
/** Run one pass. Returns the raw claimed count, including invalid deliveries. Exposed for tests. */
|
|
208
216
|
async poll(): Promise<number> {
|
|
209
|
-
const jobs = await this.queue.
|
|
217
|
+
const jobs = await this.queue.claimRaw({
|
|
210
218
|
limit: this.options.batchSize,
|
|
211
219
|
visibilityTimeoutMs: this.options.visibilityTimeoutMs,
|
|
212
|
-
decode: this.options.decode,
|
|
213
220
|
})
|
|
214
221
|
for (const job of jobs) {
|
|
215
|
-
|
|
222
|
+
try {
|
|
223
|
+
await this.handleRaw(job)
|
|
224
|
+
} catch {
|
|
225
|
+
console.error('job settlement failed')
|
|
226
|
+
}
|
|
216
227
|
}
|
|
217
228
|
return jobs.length
|
|
218
229
|
}
|
|
@@ -249,19 +260,34 @@ export class PostgresJobConsumer<T> {
|
|
|
249
260
|
let handled = 0
|
|
250
261
|
try {
|
|
251
262
|
handled = await this.poll()
|
|
252
|
-
} catch
|
|
263
|
+
} catch {
|
|
253
264
|
// A claim failure is usually the database being unreachable — log short and back off.
|
|
254
|
-
console.error('job poll failed
|
|
265
|
+
console.error('job poll failed')
|
|
255
266
|
}
|
|
256
267
|
if (this.running) {
|
|
257
268
|
this.schedule(handled > 0 ? 0 : this.options.pollIntervalMs)
|
|
258
269
|
}
|
|
259
270
|
}
|
|
260
271
|
|
|
272
|
+
private async handleRaw(raw: Job<string>): Promise<void> {
|
|
273
|
+
let job: Job<T>
|
|
274
|
+
try {
|
|
275
|
+
job = decodeJob(raw, this.options.decode)
|
|
276
|
+
} catch {
|
|
277
|
+
if (raw.attempts >= raw.maxAttempts) {
|
|
278
|
+
await this.queue.ack(raw.id)
|
|
279
|
+
console.warn('undecodable job abandoned', { id: raw.id, attempts: raw.attempts, maxAttempts: raw.maxAttempts })
|
|
280
|
+
} else {
|
|
281
|
+
await this.queue.defer(raw.id, this.options.retryDelayMs(raw.attempts))
|
|
282
|
+
}
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
await this.handle(job)
|
|
286
|
+
}
|
|
287
|
+
|
|
261
288
|
private async handle(job: Job<T>): Promise<void> {
|
|
262
289
|
try {
|
|
263
290
|
await this.options.handler(job)
|
|
264
|
-
await this.queue.ack(job.id)
|
|
265
291
|
} catch (error) {
|
|
266
292
|
this.onError(error, job)
|
|
267
293
|
if (job.attempts >= job.maxAttempts) {
|
|
@@ -270,6 +296,8 @@ export class PostgresJobConsumer<T> {
|
|
|
270
296
|
return
|
|
271
297
|
}
|
|
272
298
|
await this.queue.defer(job.id, this.options.retryDelayMs(job.attempts))
|
|
299
|
+
return
|
|
273
300
|
}
|
|
301
|
+
await this.queue.ack(job.id)
|
|
274
302
|
}
|
|
275
303
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SqlDatabase
|
|
1
|
+
import type { SqlDatabase } from '@fabrika/platform'
|
|
2
2
|
import { readdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
@@ -15,23 +15,6 @@ export interface PostgresMigration {
|
|
|
15
15
|
export interface PostgresMigrationBundle {
|
|
16
16
|
name: string
|
|
17
17
|
migrations: readonly PostgresMigration[]
|
|
18
|
-
/** Only service-owned bundles may adopt matching entries from the old unqualified ledger. */
|
|
19
|
-
adoptLegacy?: boolean
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface PostgresLegacyMigrationLedger {
|
|
23
|
-
/** Old filename-only ledger. */
|
|
24
|
-
table: string
|
|
25
|
-
/** Tables created atomically by this service's first migration. Every sentinel must exist. */
|
|
26
|
-
sentinelTables: readonly string[]
|
|
27
|
-
/** Extra effect probes for migrations whose filename could have collided in a shared old ledger. */
|
|
28
|
-
effects?: readonly PostgresLegacyMigrationEffect[]
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export interface PostgresLegacyMigrationEffect {
|
|
32
|
-
migration: string
|
|
33
|
-
/** A parameterless SELECT returning one boolean column named `proven`. */
|
|
34
|
-
probeSql: string
|
|
35
18
|
}
|
|
36
19
|
|
|
37
20
|
export interface PostgresMigrationPlan {
|
|
@@ -40,14 +23,12 @@ export interface PostgresMigrationPlan {
|
|
|
40
23
|
/** Service-specific session advisory lock. Keep this stable across runner upgrades. */
|
|
41
24
|
lockKey: number
|
|
42
25
|
bundles: readonly PostgresMigrationBundle[]
|
|
43
|
-
legacy?: PostgresLegacyMigrationLedger
|
|
44
26
|
}
|
|
45
27
|
|
|
46
28
|
export interface ReadPostgresMigrationBundleOptions {
|
|
47
29
|
name: string
|
|
48
30
|
directory: string
|
|
49
31
|
files?: readonly string[]
|
|
50
|
-
adoptLegacy?: boolean
|
|
51
32
|
}
|
|
52
33
|
|
|
53
34
|
interface LedgerRow {
|
|
@@ -55,19 +36,6 @@ interface LedgerRow {
|
|
|
55
36
|
name: string
|
|
56
37
|
}
|
|
57
38
|
|
|
58
|
-
interface LegacyLedgerRow {
|
|
59
|
-
name: string
|
|
60
|
-
applied_at: number | string
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface PresenceRow {
|
|
64
|
-
present: boolean
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
interface ProvenRow {
|
|
68
|
-
proven: boolean
|
|
69
|
-
}
|
|
70
|
-
|
|
71
39
|
/** Read one ordered migration bundle from disk. An explicit file list fails closed when a file is absent. */
|
|
72
40
|
export function readPostgresMigrationBundle(options: ReadPostgresMigrationBundleOptions): PostgresMigrationBundle {
|
|
73
41
|
validateBundleName(options.name)
|
|
@@ -88,7 +56,6 @@ export function readPostgresMigrationBundle(options: ReadPostgresMigrationBundle
|
|
|
88
56
|
name,
|
|
89
57
|
sql: readFileSync(join(options.directory, name), 'utf8'),
|
|
90
58
|
})),
|
|
91
|
-
...(options.adoptLegacy === true ? { adoptLegacy: true } : {}),
|
|
92
59
|
}
|
|
93
60
|
validateBundle(bundle)
|
|
94
61
|
return bundle
|
|
@@ -122,36 +89,6 @@ export function definePostgresMigrationPlan(plan: PostgresMigrationPlan): Postgr
|
|
|
122
89
|
identities.add(identity)
|
|
123
90
|
}
|
|
124
91
|
}
|
|
125
|
-
if (plan.legacy !== undefined) {
|
|
126
|
-
validateIdentifier(plan.legacy.table, 'legacy migration ledger table')
|
|
127
|
-
if (plan.legacy.sentinelTables.length === 0) {
|
|
128
|
-
throw new Error('Legacy migration adoption requires at least one service sentinel')
|
|
129
|
-
}
|
|
130
|
-
for (const sentinel of plan.legacy.sentinelTables) {
|
|
131
|
-
validateIdentifier(sentinel, 'legacy migration sentinel table')
|
|
132
|
-
}
|
|
133
|
-
const adoptingBundles = plan.bundles.filter((bundle) => bundle.adoptLegacy === true)
|
|
134
|
-
if (adoptingBundles.length !== 1) {
|
|
135
|
-
throw new Error('Legacy migration adoption requires exactly one adopting bundle')
|
|
136
|
-
}
|
|
137
|
-
const adoptable = new Set(
|
|
138
|
-
adoptingBundles.flatMap((bundle) => bundle.migrations.map((migration) => migration.name)),
|
|
139
|
-
)
|
|
140
|
-
const effectNames = new Set<string>()
|
|
141
|
-
for (const effect of plan.legacy.effects ?? []) {
|
|
142
|
-
validateMigrationName(effect.migration)
|
|
143
|
-
if (!adoptable.has(effect.migration)) {
|
|
144
|
-
throw new Error(`Legacy migration effect references non-adoptable migration ${effect.migration}`)
|
|
145
|
-
}
|
|
146
|
-
if (effectNames.has(effect.migration)) {
|
|
147
|
-
throw new Error(`Duplicate legacy migration effect ${effect.migration}`)
|
|
148
|
-
}
|
|
149
|
-
if (effect.probeSql.trim() === '') {
|
|
150
|
-
throw new Error(`Legacy migration effect ${effect.migration} is empty`)
|
|
151
|
-
}
|
|
152
|
-
effectNames.add(effect.migration)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
92
|
return plan
|
|
156
93
|
}
|
|
157
94
|
|
|
@@ -194,8 +131,6 @@ export async function applyPostgresMigrations(
|
|
|
194
131
|
const fresh: string[] = []
|
|
195
132
|
try {
|
|
196
133
|
await ensureLedger(db, plan.ledgerTable)
|
|
197
|
-
const beforeAdoption = await appliedIdentities(db, plan.ledgerTable)
|
|
198
|
-
if (beforeAdoption.size === 0) await adoptLegacyMigrations(db, plan)
|
|
199
134
|
const applied = await appliedIdentities(db, plan.ledgerTable)
|
|
200
135
|
for (const bundle of plan.bundles) {
|
|
201
136
|
for (const migration of bundle.migrations) {
|
|
@@ -272,84 +207,6 @@ async function appliedIdentities(db: SqlDatabase, ledgerTable: string): Promise<
|
|
|
272
207
|
return new Set(results.map((row) => postgresMigrationIdentity(row.bundle, row.name)))
|
|
273
208
|
}
|
|
274
209
|
|
|
275
|
-
async function adoptLegacyMigrations(db: SqlDatabase, plan: PostgresMigrationPlan): Promise<void> {
|
|
276
|
-
const legacy = plan.legacy
|
|
277
|
-
if (legacy === undefined) return
|
|
278
|
-
const sentinelStates = await Promise.all(legacy.sentinelTables.map((sentinel) => relationExists(db, sentinel)))
|
|
279
|
-
const presentSentinels = sentinelStates.filter(Boolean).length
|
|
280
|
-
if (presentSentinels === 0) return
|
|
281
|
-
if (presentSentinels !== sentinelStates.length) {
|
|
282
|
-
throw new Error('Legacy migration service sentinels are inconsistent')
|
|
283
|
-
}
|
|
284
|
-
if (!(await relationExists(db, legacy.table))) {
|
|
285
|
-
throw new Error('Legacy migration ledger is missing for an existing service schema')
|
|
286
|
-
}
|
|
287
|
-
for (const sentinel of legacy.sentinelTables) {
|
|
288
|
-
if (!(await relationExists(db, sentinel))) {
|
|
289
|
-
throw new Error('Legacy migration service sentinels changed during adoption')
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
const { results } = await db.prepare(`SELECT name, applied_at FROM ${legacy.table}`).all<LegacyLedgerRow>()
|
|
293
|
-
const legacyApplied = new Map(results.map((row) => [row.name, appliedAt(row.applied_at)]))
|
|
294
|
-
const adoptingBundle = plan.bundles.find((bundle) => bundle.adoptLegacy === true)
|
|
295
|
-
if (adoptingBundle === undefined) throw new Error('Legacy migration adopting bundle is missing')
|
|
296
|
-
const firstMigration = adoptingBundle.migrations[0]
|
|
297
|
-
if (firstMigration === undefined || !legacyApplied.has(firstMigration.name)) {
|
|
298
|
-
throw new Error(`Legacy migration ledger does not prove ${adoptingBundle.name}'s first migration`)
|
|
299
|
-
}
|
|
300
|
-
for (const effect of legacy.effects ?? []) {
|
|
301
|
-
if (!legacyApplied.has(effect.migration)) continue
|
|
302
|
-
const row = await db.prepare(effect.probeSql).first<ProvenRow>()
|
|
303
|
-
if (row === null || typeof row.proven !== 'boolean') {
|
|
304
|
-
throw new Error(`Legacy migration effect ${effect.migration} did not return a boolean proven column`)
|
|
305
|
-
}
|
|
306
|
-
if (!row.proven) {
|
|
307
|
-
throw new Error(`Legacy migration effect ${effect.migration} is missing`)
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
const statements: SqlStatement[] = []
|
|
311
|
-
for (const bundle of plan.bundles) {
|
|
312
|
-
if (bundle.adoptLegacy !== true) continue
|
|
313
|
-
for (const migration of bundle.migrations) {
|
|
314
|
-
const timestamp = legacyApplied.get(migration.name)
|
|
315
|
-
if (timestamp === undefined) continue
|
|
316
|
-
statements.push(
|
|
317
|
-
db
|
|
318
|
-
.prepare(`INSERT INTO ${plan.ledgerTable} (bundle, name, applied_at)
|
|
319
|
-
VALUES (?, ?, ?)
|
|
320
|
-
ON CONFLICT (bundle, name) DO NOTHING`)
|
|
321
|
-
.bind(
|
|
322
|
-
bundle.name,
|
|
323
|
-
migration.name,
|
|
324
|
-
timestamp,
|
|
325
|
-
),
|
|
326
|
-
)
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
if (statements.length > 0) await db.batch(statements)
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async function relationExists(db: SqlDatabase, name: string): Promise<boolean> {
|
|
333
|
-
const row = await db
|
|
334
|
-
.prepare(`SELECT EXISTS (
|
|
335
|
-
SELECT 1
|
|
336
|
-
FROM pg_catalog.pg_class relation
|
|
337
|
-
JOIN pg_catalog.pg_namespace namespace ON namespace.oid = relation.relnamespace
|
|
338
|
-
WHERE namespace.nspname = current_schema()
|
|
339
|
-
AND relation.relname = ?
|
|
340
|
-
AND relation.relkind IN ('r', 'p')
|
|
341
|
-
) AS present`)
|
|
342
|
-
.bind(name)
|
|
343
|
-
.first<PresenceRow>()
|
|
344
|
-
return row?.present === true
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function appliedAt(value: number | string): number {
|
|
348
|
-
const parsed = typeof value === 'number' ? value : Number(value)
|
|
349
|
-
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new Error('Legacy migration ledger contains an invalid timestamp')
|
|
350
|
-
return parsed
|
|
351
|
-
}
|
|
352
|
-
|
|
353
210
|
function unixNow(): number {
|
|
354
211
|
return Math.floor(Date.now() / 1_000)
|
|
355
212
|
}
|
|
@@ -2,7 +2,6 @@ import type { SqlDatabase } from '@fabrika/platform'
|
|
|
2
2
|
import {
|
|
3
3
|
applyPostgresMigrations,
|
|
4
4
|
definePostgresMigrationPlan,
|
|
5
|
-
type PostgresLegacyMigrationLedger,
|
|
6
5
|
type PostgresMigration,
|
|
7
6
|
type PostgresMigrationBundle,
|
|
8
7
|
postgresMigrationFilenames,
|
|
@@ -18,7 +17,6 @@ export interface PostgresServiceMigrationsConfig {
|
|
|
18
17
|
/** Stable service advisory lock. */
|
|
19
18
|
readonly lockKey: number
|
|
20
19
|
readonly directory: string
|
|
21
|
-
readonly legacy: PostgresLegacyMigrationLedger
|
|
22
20
|
/** Runtime-owned bundles applied before the service schema. */
|
|
23
21
|
readonly dependencies?: readonly PostgresMigrationBundle[]
|
|
24
22
|
}
|
|
@@ -32,14 +30,13 @@ export interface PostgresServiceMigrations {
|
|
|
32
30
|
|
|
33
31
|
/**
|
|
34
32
|
* Define the repeated service migration wrapper while keeping service identity and lifecycle local.
|
|
35
|
-
* Bundle names, ledgers, lock ids, dependency order
|
|
33
|
+
* Bundle names, ledgers, lock ids, and dependency order remain explicit inputs.
|
|
36
34
|
*/
|
|
37
35
|
export function definePostgresServiceMigrations(config: PostgresServiceMigrationsConfig): PostgresServiceMigrations {
|
|
38
36
|
const read = (directory: string = config.directory): PostgresMigration[] => [
|
|
39
37
|
...readPostgresMigrationBundle({
|
|
40
38
|
name: config.name,
|
|
41
39
|
directory,
|
|
42
|
-
adoptLegacy: true,
|
|
43
40
|
}).migrations,
|
|
44
41
|
]
|
|
45
42
|
const plan = (migrations: readonly PostgresMigration[] = read()): PostgresMigrationPlan =>
|
|
@@ -48,9 +45,8 @@ export function definePostgresServiceMigrations(config: PostgresServiceMigration
|
|
|
48
45
|
lockKey: config.lockKey,
|
|
49
46
|
bundles: [
|
|
50
47
|
...(config.dependencies ?? []),
|
|
51
|
-
{ name: config.name, migrations
|
|
48
|
+
{ name: config.name, migrations },
|
|
52
49
|
],
|
|
53
|
-
legacy: config.legacy,
|
|
54
50
|
})
|
|
55
51
|
const applyQualified = (
|
|
56
52
|
db: SqlDatabase,
|
package/src/uuid.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
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
|
-
}
|