@checkstack/script-packages-backend 0.2.0

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
  3. package/drizzle/0001_flawless_drax.sql +15 -0
  4. package/drizzle/meta/0000_snapshot.json +395 -0
  5. package/drizzle/meta/0001_snapshot.json +491 -0
  6. package/drizzle/meta/_journal.json +20 -0
  7. package/drizzle.config.ts +7 -0
  8. package/package.json +32 -0
  9. package/src/atomic-symlink.test.ts +47 -0
  10. package/src/atomic-symlink.ts +66 -0
  11. package/src/blob-gc-runner.test.ts +120 -0
  12. package/src/blob-gc-runner.ts +139 -0
  13. package/src/blob-gc.test.ts +182 -0
  14. package/src/blob-gc.ts +161 -0
  15. package/src/blob-hash.test.ts +70 -0
  16. package/src/blob-hash.ts +56 -0
  17. package/src/blob-store-registry.test.ts +78 -0
  18. package/src/blob-store-registry.ts +75 -0
  19. package/src/blob-store.ts +51 -0
  20. package/src/cache-archive.test.ts +164 -0
  21. package/src/cache-archive.ts +192 -0
  22. package/src/cache-layout.ts +64 -0
  23. package/src/data-dir.test.ts +41 -0
  24. package/src/data-dir.ts +42 -0
  25. package/src/e2e-install-reconcile.test.ts +121 -0
  26. package/src/hooks.ts +20 -0
  27. package/src/index.ts +594 -0
  28. package/src/install-controller.test.ts +257 -0
  29. package/src/install-controller.ts +144 -0
  30. package/src/install-service.test.ts +104 -0
  31. package/src/install-service.ts +116 -0
  32. package/src/install-state-store.ts +131 -0
  33. package/src/lockfile.test.ts +60 -0
  34. package/src/lockfile.ts +0 -0
  35. package/src/npmrc.test.ts +48 -0
  36. package/src/npmrc.ts +42 -0
  37. package/src/package-types.test.ts +293 -0
  38. package/src/package-types.ts +408 -0
  39. package/src/parse-bun-lock.test.ts +62 -0
  40. package/src/parse-bun-lock.ts +59 -0
  41. package/src/reconcile-diff.test.ts +41 -0
  42. package/src/reconcile-diff.ts +26 -0
  43. package/src/reconcile-fs.ts +199 -0
  44. package/src/reconciler.test.ts +289 -0
  45. package/src/reconciler.ts +81 -0
  46. package/src/registry-client.test.ts +314 -0
  47. package/src/registry-client.ts +0 -0
  48. package/src/registry-request-config.ts +63 -0
  49. package/src/registry-token.test.ts +124 -0
  50. package/src/registry-token.ts +104 -0
  51. package/src/resolution-root.test.ts +82 -0
  52. package/src/resolution-root.ts +127 -0
  53. package/src/resolver.test.ts +133 -0
  54. package/src/resolver.ts +132 -0
  55. package/src/router.ts +273 -0
  56. package/src/schema.ts +166 -0
  57. package/src/size-cap.test.ts +32 -0
  58. package/src/size-cap.ts +40 -0
  59. package/src/storage-migration.test.ts +318 -0
  60. package/src/storage-migration.ts +213 -0
  61. package/src/stores.ts +533 -0
  62. package/src/tree-gc.test.ts +184 -0
  63. package/src/tree-gc.ts +160 -0
  64. package/src/tree-retirement.ts +81 -0
  65. package/src/type-acquisition-route.ts +178 -0
  66. package/tsconfig.json +23 -0
package/src/stores.ts ADDED
@@ -0,0 +1,533 @@
1
+ import { eq, ne, desc, sql } from "drizzle-orm";
2
+ import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import type {
4
+ BlobGcState,
5
+ ManifestEntry,
6
+ PackageSpec,
7
+ RegistryConfig,
8
+ SatelliteSyncState,
9
+ SizeCapConfig,
10
+ StorageConfig,
11
+ } from "@checkstack/script-packages-common";
12
+ import {
13
+ DEFAULT_BLOCK_BYTES,
14
+ DEFAULT_WARN_BYTES,
15
+ } from "@checkstack/script-packages-common";
16
+ import type { GcBlob } from "./blob-gc";
17
+ import {
18
+ scriptPackages,
19
+ scriptPackageRegistryConfig,
20
+ scriptPackageStorageConfig,
21
+ scriptPackageSizeCap,
22
+ scriptPackageBlob,
23
+ scriptPackageBlobGcState,
24
+ scriptPackageLockfileHistory,
25
+ scriptPackageSatelliteState,
26
+ } from "./schema";
27
+
28
+ const SINGLETON = "singleton";
29
+
30
+ // ─── Allowlist store ───────────────────────────────────────────────────────
31
+
32
+ type AllowlistSchema = { scriptPackages: typeof scriptPackages };
33
+
34
+ export function createPackageStore(db: SafeDatabase<AllowlistSchema>) {
35
+ return {
36
+ async list(): Promise<PackageSpec[]> {
37
+ const rows = await db.select().from(scriptPackages);
38
+ return rows
39
+ .map((r) => ({
40
+ name: r.name,
41
+ version: r.version,
42
+ enabled: r.enabled,
43
+ addedBy: r.addedBy,
44
+ addedAt: r.addedAt,
45
+ updatedAt: r.updatedAt,
46
+ }))
47
+ .toSorted((a, b) => a.name.localeCompare(b.name));
48
+ },
49
+ async upsert(input: {
50
+ name: string;
51
+ version: string;
52
+ addedBy?: string | null;
53
+ }): Promise<PackageSpec> {
54
+ const now = new Date();
55
+ await db
56
+ .insert(scriptPackages)
57
+ .values({
58
+ name: input.name,
59
+ version: input.version,
60
+ addedBy: input.addedBy ?? null,
61
+ enabled: true,
62
+ })
63
+ .onConflictDoUpdate({
64
+ target: scriptPackages.name,
65
+ set: { version: input.version, updatedAt: now },
66
+ });
67
+ const [row] = await db
68
+ .select()
69
+ .from(scriptPackages)
70
+ .where(eq(scriptPackages.name, input.name))
71
+ .limit(1);
72
+ return {
73
+ name: row!.name,
74
+ version: row!.version,
75
+ enabled: row!.enabled,
76
+ addedBy: row!.addedBy,
77
+ addedAt: row!.addedAt,
78
+ updatedAt: row!.updatedAt,
79
+ };
80
+ },
81
+ async remove(name: string): Promise<void> {
82
+ await db.delete(scriptPackages).where(eq(scriptPackages.name, name));
83
+ },
84
+ async setEnabled(input: {
85
+ name: string;
86
+ enabled: boolean;
87
+ }): Promise<PackageSpec> {
88
+ await db
89
+ .update(scriptPackages)
90
+ .set({ enabled: input.enabled, updatedAt: new Date() })
91
+ .where(eq(scriptPackages.name, input.name));
92
+ const [row] = await db
93
+ .select()
94
+ .from(scriptPackages)
95
+ .where(eq(scriptPackages.name, input.name))
96
+ .limit(1);
97
+ return {
98
+ name: row!.name,
99
+ version: row!.version,
100
+ enabled: row!.enabled,
101
+ addedBy: row!.addedBy,
102
+ addedAt: row!.addedAt,
103
+ updatedAt: row!.updatedAt,
104
+ };
105
+ },
106
+ };
107
+ }
108
+
109
+ // ─── Registry config store ───────────────────────────────────────────────
110
+
111
+ type RegistrySchema = {
112
+ scriptPackageRegistryConfig: typeof scriptPackageRegistryConfig;
113
+ };
114
+
115
+ export function createRegistryConfigStore(db: SafeDatabase<RegistrySchema>) {
116
+ return {
117
+ /** DTO view (no token); use `authSecretRef()` for the secret ref. */
118
+ async get(): Promise<RegistryConfig> {
119
+ const [row] = await db
120
+ .select()
121
+ .from(scriptPackageRegistryConfig)
122
+ .where(eq(scriptPackageRegistryConfig.id, SINGLETON))
123
+ .limit(1);
124
+ if (!row) {
125
+ return {
126
+ registryUrl: "https://registry.npmjs.org/",
127
+ scopedRegistries: [],
128
+ hasAuthToken: false,
129
+ ignoreScripts: true,
130
+ };
131
+ }
132
+ return {
133
+ registryUrl: row.registryUrl,
134
+ scopedRegistries: row.scopedRegistries,
135
+ hasAuthToken: Boolean(row.authSecretRef),
136
+ ignoreScripts: row.ignoreScripts,
137
+ updatedAt: row.updatedAt,
138
+ };
139
+ },
140
+ async authSecretRef(): Promise<string | null> {
141
+ const [row] = await db
142
+ .select({ ref: scriptPackageRegistryConfig.authSecretRef })
143
+ .from(scriptPackageRegistryConfig)
144
+ .where(eq(scriptPackageRegistryConfig.id, SINGLETON))
145
+ .limit(1);
146
+ return row?.ref ?? null;
147
+ },
148
+ async set(input: {
149
+ registryUrl: string;
150
+ scopedRegistries: { scope: string; registryUrl: string }[];
151
+ ignoreScripts: boolean;
152
+ /** Pass to set; undefined leaves untouched; null clears. */
153
+ authSecretRef?: string | null;
154
+ }): Promise<void> {
155
+ const set: Record<string, unknown> = {
156
+ registryUrl: input.registryUrl,
157
+ scopedRegistries: input.scopedRegistries,
158
+ ignoreScripts: input.ignoreScripts,
159
+ updatedAt: new Date(),
160
+ };
161
+ if (input.authSecretRef !== undefined) {
162
+ set.authSecretRef = input.authSecretRef;
163
+ }
164
+ await db
165
+ .insert(scriptPackageRegistryConfig)
166
+ .values({ id: SINGLETON, ...set })
167
+ .onConflictDoUpdate({
168
+ target: scriptPackageRegistryConfig.id,
169
+ set,
170
+ });
171
+ },
172
+ };
173
+ }
174
+
175
+ // ─── Storage config store ────────────────────────────────────────────────
176
+
177
+ type StorageSchema = {
178
+ scriptPackageStorageConfig: typeof scriptPackageStorageConfig;
179
+ };
180
+
181
+ export function createStorageConfigStore(db: SafeDatabase<StorageSchema>) {
182
+ return {
183
+ async get(): Promise<StorageConfig> {
184
+ const [row] = await db
185
+ .select()
186
+ .from(scriptPackageStorageConfig)
187
+ .where(eq(scriptPackageStorageConfig.id, SINGLETON))
188
+ .limit(1);
189
+ if (!row) {
190
+ return {
191
+ activeBackend: "postgres",
192
+ migrationStatus: "idle",
193
+ migrationTarget: null,
194
+ migratedCount: 0,
195
+ migrationError: null,
196
+ };
197
+ }
198
+ return {
199
+ activeBackend: row.activeBackend,
200
+ migrationStatus: row.migrationStatus as StorageConfig["migrationStatus"],
201
+ migrationTarget: row.migrationTarget,
202
+ migratedCount: row.migratedCount,
203
+ migrationError: row.migrationError,
204
+ updatedAt: row.updatedAt,
205
+ };
206
+ },
207
+ async setActiveBackend(backend: string): Promise<void> {
208
+ const set = { activeBackend: backend, updatedAt: new Date() };
209
+ await db
210
+ .insert(scriptPackageStorageConfig)
211
+ .values({ id: SINGLETON, ...set })
212
+ .onConflictDoUpdate({
213
+ target: scriptPackageStorageConfig.id,
214
+ set,
215
+ });
216
+ },
217
+ /** Mark a migration as started (idempotent; resets count + error). */
218
+ async beginMigration(target: string): Promise<void> {
219
+ const set = {
220
+ migrationStatus: "migrating" as const,
221
+ migrationTarget: target,
222
+ migratedCount: 0,
223
+ migrationError: null,
224
+ updatedAt: new Date(),
225
+ };
226
+ await db
227
+ .insert(scriptPackageStorageConfig)
228
+ .values({ id: SINGLETON, ...set })
229
+ .onConflictDoUpdate({ target: scriptPackageStorageConfig.id, set });
230
+ },
231
+ /** Update the migrated-blob counter (progress). */
232
+ async setMigratedCount(count: number): Promise<void> {
233
+ await db
234
+ .update(scriptPackageStorageConfig)
235
+ .set({ migratedCount: count, updatedAt: new Date() })
236
+ .where(eq(scriptPackageStorageConfig.id, SINGLETON));
237
+ },
238
+ /**
239
+ * Atomically complete a migration: flip the active backend to the target
240
+ * and mark completed. Done in one UPDATE so a reader never sees a
241
+ * "completed but old active backend" state.
242
+ */
243
+ async completeMigration(target: string): Promise<void> {
244
+ await db
245
+ .update(scriptPackageStorageConfig)
246
+ .set({
247
+ activeBackend: target,
248
+ migrationStatus: "completed",
249
+ migrationError: null,
250
+ updatedAt: new Date(),
251
+ })
252
+ .where(eq(scriptPackageStorageConfig.id, SINGLETON));
253
+ },
254
+ /** Record a migration failure (leaves the active backend unchanged). */
255
+ async failMigration(message: string): Promise<void> {
256
+ await db
257
+ .update(scriptPackageStorageConfig)
258
+ .set({
259
+ migrationStatus: "error",
260
+ migrationError: message,
261
+ updatedAt: new Date(),
262
+ })
263
+ .where(eq(scriptPackageStorageConfig.id, SINGLETON));
264
+ },
265
+ };
266
+ }
267
+
268
+ // ─── Size-cap store ────────────────────────────────────────────────────────
269
+
270
+ type SizeCapSchema = { scriptPackageSizeCap: typeof scriptPackageSizeCap };
271
+
272
+ export function createSizeCapStore(db: SafeDatabase<SizeCapSchema>) {
273
+ return {
274
+ async get(): Promise<SizeCapConfig> {
275
+ const [row] = await db
276
+ .select()
277
+ .from(scriptPackageSizeCap)
278
+ .where(eq(scriptPackageSizeCap.id, SINGLETON))
279
+ .limit(1);
280
+ return {
281
+ warnBytes: row?.warnBytes ?? DEFAULT_WARN_BYTES,
282
+ blockBytes: row?.blockBytes ?? DEFAULT_BLOCK_BYTES,
283
+ };
284
+ },
285
+ async set(cap: SizeCapConfig): Promise<void> {
286
+ const set = {
287
+ warnBytes: cap.warnBytes,
288
+ blockBytes: cap.blockBytes,
289
+ updatedAt: new Date(),
290
+ };
291
+ await db
292
+ .insert(scriptPackageSizeCap)
293
+ .values({ id: SINGLETON, ...set })
294
+ .onConflictDoUpdate({ target: scriptPackageSizeCap.id, set });
295
+ },
296
+ };
297
+ }
298
+
299
+ // ─── Blob index store ──────────────────────────────────────────────────────
300
+
301
+ type BlobIndexSchema = { scriptPackageBlob: typeof scriptPackageBlob };
302
+
303
+ export function createBlobIndexStore(db: SafeDatabase<BlobIndexSchema>) {
304
+ return {
305
+ async record(input: {
306
+ integrity: string;
307
+ name: string;
308
+ version: string;
309
+ backend: string;
310
+ sizeBytes: number;
311
+ }): Promise<void> {
312
+ await db
313
+ .insert(scriptPackageBlob)
314
+ .values(input)
315
+ .onConflictDoUpdate({
316
+ target: scriptPackageBlob.integrity,
317
+ set: { backend: input.backend, sizeBytes: input.sizeBytes },
318
+ });
319
+ },
320
+ async backendFor(integrity: string): Promise<string | undefined> {
321
+ const [row] = await db
322
+ .select({ backend: scriptPackageBlob.backend })
323
+ .from(scriptPackageBlob)
324
+ .where(eq(scriptPackageBlob.integrity, integrity))
325
+ .limit(1);
326
+ return row?.backend;
327
+ },
328
+ /** All indexed blobs (integrity + current backend). */
329
+ async list(): Promise<{ integrity: string; backend: string }[]> {
330
+ const rows = await db
331
+ .select({
332
+ integrity: scriptPackageBlob.integrity,
333
+ backend: scriptPackageBlob.backend,
334
+ })
335
+ .from(scriptPackageBlob);
336
+ return rows;
337
+ },
338
+ /**
339
+ * Blobs whose recorded backend is NOT `target` - the work set for a
340
+ * migration to `target`. Resumability: a resumed migration re-derives
341
+ * this set, so blobs already flipped to `target` are skipped.
342
+ */
343
+ async listNotOnBackend(
344
+ target: string,
345
+ ): Promise<{ integrity: string; backend: string }[]> {
346
+ const rows = await db
347
+ .select({
348
+ integrity: scriptPackageBlob.integrity,
349
+ backend: scriptPackageBlob.backend,
350
+ })
351
+ .from(scriptPackageBlob)
352
+ .where(ne(scriptPackageBlob.backend, target));
353
+ return rows;
354
+ },
355
+ /** Flip a blob's recorded backend after a verified copy. */
356
+ async setBackend(integrity: string, backend: string): Promise<void> {
357
+ await db
358
+ .update(scriptPackageBlob)
359
+ .set({ backend })
360
+ .where(eq(scriptPackageBlob.integrity, integrity));
361
+ },
362
+ /** Every indexed blob with the metadata the GC needs (size + created_at). */
363
+ async listWithMeta(): Promise<GcBlob[]> {
364
+ const rows = await db
365
+ .select({
366
+ integrity: scriptPackageBlob.integrity,
367
+ backend: scriptPackageBlob.backend,
368
+ sizeBytes: scriptPackageBlob.sizeBytes,
369
+ createdAt: scriptPackageBlob.createdAt,
370
+ })
371
+ .from(scriptPackageBlob);
372
+ return rows;
373
+ },
374
+ /** Remove the index row for a GC'd blob (after deleting its bytes). */
375
+ async remove(integrity: string): Promise<void> {
376
+ await db
377
+ .delete(scriptPackageBlob)
378
+ .where(eq(scriptPackageBlob.integrity, integrity));
379
+ },
380
+ };
381
+ }
382
+
383
+ // ─── Lockfile history store ──────────────────────────────────────────────────
384
+
385
+ type LockfileHistorySchema = {
386
+ scriptPackageLockfileHistory: typeof scriptPackageLockfileHistory;
387
+ };
388
+
389
+ export function createLockfileHistoryStore(
390
+ db: SafeDatabase<LockfileHistorySchema>,
391
+ ) {
392
+ return {
393
+ /** Record a successful install's manifest (idempotent on hash). */
394
+ async record(input: {
395
+ lockfileHash: string;
396
+ manifest: ManifestEntry[];
397
+ }): Promise<void> {
398
+ await db
399
+ .insert(scriptPackageLockfileHistory)
400
+ .values({
401
+ lockfileHash: input.lockfileHash,
402
+ manifest: input.manifest,
403
+ recordedAt: new Date(),
404
+ })
405
+ .onConflictDoUpdate({
406
+ target: scriptPackageLockfileHistory.lockfileHash,
407
+ // Re-installing the same hash refreshes its recency so it stays in
408
+ // the retained window.
409
+ set: { recordedAt: new Date() },
410
+ });
411
+ },
412
+ /** Manifests for the most-recent `limit` hashes (newest first). */
413
+ async recent(limit: number): Promise<ManifestEntry[][]> {
414
+ const rows = await db
415
+ .select({ manifest: scriptPackageLockfileHistory.manifest })
416
+ .from(scriptPackageLockfileHistory)
417
+ .orderBy(desc(scriptPackageLockfileHistory.recordedAt))
418
+ .limit(limit);
419
+ return rows.map((r) => r.manifest);
420
+ },
421
+ /** Hashes to keep (the most-recent `limit`); used to prune the rest. */
422
+ async retainedHashes(limit: number): Promise<string[]> {
423
+ const rows = await db
424
+ .select({ lockfileHash: scriptPackageLockfileHistory.lockfileHash })
425
+ .from(scriptPackageLockfileHistory)
426
+ .orderBy(desc(scriptPackageLockfileHistory.recordedAt))
427
+ .limit(limit);
428
+ return rows.map((r) => r.lockfileHash);
429
+ },
430
+ /** Prune history rows older than the most-recent `keep` hashes. */
431
+ async pruneOlderThan(keep: number): Promise<void> {
432
+ const keepHashes = await this.retainedHashes(keep);
433
+ if (keepHashes.length === 0) return;
434
+ await db
435
+ .delete(scriptPackageLockfileHistory)
436
+ .where(
437
+ sql`${scriptPackageLockfileHistory.lockfileHash} NOT IN (${sql.join(
438
+ keepHashes.map((h) => sql`${h}`),
439
+ sql`, `,
440
+ )})`,
441
+ );
442
+ },
443
+ };
444
+ }
445
+
446
+ // ─── Blob-GC state store ─────────────────────────────────────────────────────
447
+
448
+ type BlobGcStateSchema = {
449
+ scriptPackageBlobGcState: typeof scriptPackageBlobGcState;
450
+ };
451
+
452
+ export function createBlobGcStateStore(db: SafeDatabase<BlobGcStateSchema>) {
453
+ return {
454
+ async get(): Promise<BlobGcState> {
455
+ const [row] = await db
456
+ .select()
457
+ .from(scriptPackageBlobGcState)
458
+ .where(eq(scriptPackageBlobGcState.id, SINGLETON))
459
+ .limit(1);
460
+ return {
461
+ lastRunAt: row?.lastRunAt ?? null,
462
+ lastDeleted: row?.lastDeleted ?? 0,
463
+ lastBytesReclaimed: row?.lastBytesReclaimed ?? 0,
464
+ totalBytesReclaimed: row?.totalBytesReclaimed ?? 0,
465
+ };
466
+ },
467
+ /** Record a completed run, accumulating total reclaimed bytes. */
468
+ async recordRun(input: {
469
+ deleted: number;
470
+ bytesReclaimed: number;
471
+ }): Promise<void> {
472
+ await db
473
+ .insert(scriptPackageBlobGcState)
474
+ .values({
475
+ id: SINGLETON,
476
+ lastRunAt: new Date(),
477
+ lastDeleted: input.deleted,
478
+ lastBytesReclaimed: input.bytesReclaimed,
479
+ totalBytesReclaimed: input.bytesReclaimed,
480
+ })
481
+ .onConflictDoUpdate({
482
+ target: scriptPackageBlobGcState.id,
483
+ set: {
484
+ lastRunAt: new Date(),
485
+ lastDeleted: input.deleted,
486
+ lastBytesReclaimed: input.bytesReclaimed,
487
+ totalBytesReclaimed: sql`${scriptPackageBlobGcState.totalBytesReclaimed} + ${input.bytesReclaimed}`,
488
+ },
489
+ });
490
+ },
491
+ };
492
+ }
493
+
494
+ // ─── Satellite sync state store ────────────────────────────────────────────
495
+
496
+ type SatelliteSchema = {
497
+ scriptPackageSatelliteState: typeof scriptPackageSatelliteState;
498
+ };
499
+
500
+ export function createSatelliteStateStore(db: SafeDatabase<SatelliteSchema>) {
501
+ return {
502
+ async list(): Promise<SatelliteSyncState[]> {
503
+ const rows = await db.select().from(scriptPackageSatelliteState);
504
+ return rows.map((r) => ({
505
+ satelliteId: r.satelliteId,
506
+ lockfileHash: r.lockfileHash,
507
+ status: r.status as SatelliteSyncState["status"],
508
+ errorMessage: r.errorMessage,
509
+ syncedAt: r.syncedAt,
510
+ }));
511
+ },
512
+ async report(input: {
513
+ satelliteId: string;
514
+ lockfileHash: string | null;
515
+ status: SatelliteSyncState["status"];
516
+ errorMessage?: string | null;
517
+ }): Promise<void> {
518
+ const set = {
519
+ lockfileHash: input.lockfileHash,
520
+ status: input.status,
521
+ errorMessage: input.errorMessage ?? null,
522
+ syncedAt: new Date(),
523
+ };
524
+ await db
525
+ .insert(scriptPackageSatelliteState)
526
+ .values({ satelliteId: input.satelliteId, ...set })
527
+ .onConflictDoUpdate({
528
+ target: scriptPackageSatelliteState.satelliteId,
529
+ set,
530
+ });
531
+ },
532
+ };
533
+ }