@hot-updater/supabase 0.36.7 → 1.0.0-rc.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 (37) hide show
  1. package/dist/edge.cjs +6 -3
  2. package/dist/edge.d.cts +14 -2
  3. package/dist/edge.d.mts +14 -2
  4. package/dist/edge.mjs +5 -2
  5. package/dist/iac/index.cjs +607 -50
  6. package/dist/iac/index.d.cts +26 -8
  7. package/dist/iac/index.d.mts +26 -8
  8. package/dist/iac/index.mjs +604 -52
  9. package/dist/index.cjs +3 -145
  10. package/dist/index.d.cts +7 -24
  11. package/dist/index.d.mts +7 -24
  12. package/dist/index.mjs +2 -117
  13. package/dist/init/index.cjs +5 -2
  14. package/dist/init/index.d.cts +2 -2
  15. package/dist/init/index.d.mts +2 -2
  16. package/dist/init/index.mjs +5 -2
  17. package/dist/supabaseDatabase-BDIBsXpz.d.cts +13 -0
  18. package/dist/supabaseDatabase-CP9WalBi.d.mts +13 -0
  19. package/dist/supabaseStorage-Cw294tR4.mjs +557 -0
  20. package/dist/supabaseStorage-yP7u4m7E.cjs +568 -0
  21. package/package.json +9 -9
  22. package/supabase/edge-functions/index.ts +9 -13
  23. package/supabase/edge-functions/runtime.docker.integration.spec.ts +417 -202
  24. package/supabase/migrations/20260818000000_hot-updater_1.0.0.sql +583 -0
  25. package/supabase/migrations.spec.ts +127 -46
  26. package/dist/supabaseEdgeFunctionStorage-BRxGvt-r.d.mts +0 -19
  27. package/dist/supabaseEdgeFunctionStorage-BSLlUeqi.cjs +0 -386
  28. package/dist/supabaseEdgeFunctionStorage-CU396KO3.d.cts +0 -19
  29. package/dist/supabaseEdgeFunctionStorage-pV0s_mTF.mjs +0 -356
  30. package/supabase/migrations/20250103114225_init.sql +0 -228
  31. package/supabase/migrations/20250314000000_hot-updater_0.13.0.sql +0 -134
  32. package/supabase/migrations/20250516000000_hot-updater_0.18.0.sql +0 -194
  33. package/supabase/migrations/20251014000000_hot-updater_0.21.0.sql +0 -184
  34. package/supabase/migrations/20260401000000_hot-updater_0.29.0.sql +0 -503
  35. package/supabase/migrations/20260414000000_hot-updater_0.30.0.sql +0 -50
  36. package/supabase/migrations/20260422000000_hot-updater_0.31.0.sql +0 -20
  37. package/supabase/migrations/20260520014100_hot-updater_rls.sql +0 -48
@@ -16,15 +16,16 @@ import { fileURLToPath, pathToFileURL } from "node:url";
16
16
  import { resolvePackageVersion, transformEnv } from "@hot-updater/cli-tools";
17
17
  import {
18
18
  type Bundle,
19
- type GetBundlesArgs,
20
19
  NIL_UUID,
21
- type UpdateInfo,
20
+ createReleaseCatalogScopeKey,
21
+ encodeChannelKey,
22
22
  } from "@hot-updater/core";
23
- import { createHotUpdater } from "@hot-updater/server";
24
23
  import {
25
- setupBsdiffManifestUpdateInfoTestSuite,
26
- setupGetUpdateInfoTestSuite,
27
- } from "@hot-updater/test-utils";
24
+ commitReleaseCatalogMutations,
25
+ createDatabaseClient,
26
+ createUUIDv7,
27
+ } from "@hot-updater/plugin-core";
28
+ import { createHotUpdater, registerApiKey } from "@hot-updater/server";
28
29
  import { createClient } from "@supabase/supabase-js";
29
30
  import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
30
31
 
@@ -34,10 +35,10 @@ import {
34
35
  runCheckedCommand,
35
36
  spawnRuntime,
36
37
  stopRuntime,
37
- formatRuntimeLogs,
38
38
  waitForHttpOk,
39
39
  } from "../../../../packages/test-utils/src/runtimeProcess";
40
40
  import { supabaseDatabase } from "../../src/supabaseDatabase";
41
+ import { SUPABASE_V1_TABLE_NAMES } from "../../src/supabaseInfrastructureNames";
41
42
  import { supabaseStorage } from "../../src/supabaseStorage";
42
43
 
43
44
  const __filename = fileURLToPath(import.meta.url);
@@ -45,8 +46,7 @@ const __dirname = path.dirname(__filename);
45
46
  const WORKSPACE_ROOT = path.resolve(__dirname, "../../../..");
46
47
  const FUNCTION_NAME = "hot-updater-function";
47
48
  const FUNCTION_BASE_PATH = `/${FUNCTION_NAME}`;
48
- const HOT_UPDATER_BASE_PATH = "/";
49
- const LEGACY_HOT_UPDATER_BASE_PATH = "/api/check-update";
49
+ const API_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
50
50
  const BUCKET_NAME = "hot-updater-bundles";
51
51
  const DENO_DOCKER_IMAGE = "denoland/deno:alpine";
52
52
  const DENO_CACHE_VOLUME = "hot-updater-supabase-deno-cache";
@@ -98,28 +98,6 @@ const ensureBuiltArtifacts = async (
98
98
  }
99
99
  };
100
100
 
101
- const createCanonicalPath = (args: GetBundlesArgs) => {
102
- const channel = args.channel ?? "production";
103
- const minBundleId = args.minBundleId ?? NIL_UUID;
104
- const cohortSegment = args.cohort
105
- ? `/${encodeURIComponent(args.cohort)}`
106
- : "";
107
- const joinHotUpdaterPath = (routePath: string) =>
108
- HOT_UPDATER_BASE_PATH === "/"
109
- ? routePath
110
- : `${HOT_UPDATER_BASE_PATH}${routePath}`;
111
-
112
- if (args._updateStrategy === "appVersion") {
113
- return joinHotUpdaterPath(
114
- `/app-version/${encodeURIComponent(args.platform)}/${encodeURIComponent(args.appVersion)}/${encodeURIComponent(channel)}/${encodeURIComponent(minBundleId)}/${encodeURIComponent(args.bundleId)}${cohortSegment}`,
115
- );
116
- }
117
-
118
- return joinHotUpdaterPath(
119
- `/fingerprint/${encodeURIComponent(args.platform)}/${encodeURIComponent(args.fingerprintHash)}/${encodeURIComponent(channel)}/${encodeURIComponent(minBundleId)}/${encodeURIComponent(args.bundleId)}${cohortSegment}`,
120
- );
121
- };
122
-
123
101
  const toRuntimeBundle = (bundle: Bundle): Bundle => {
124
102
  return {
125
103
  ...bundle,
@@ -127,6 +105,80 @@ const toRuntimeBundle = (bundle: Bundle): Bundle => {
127
105
  };
128
106
  };
129
107
 
108
+ const runtimeBundle = (id: string, overrides: Partial<Bundle> = {}): Bundle =>
109
+ toRuntimeBundle({
110
+ platform: "ios",
111
+ fileHash: `hash-${id}`,
112
+ gitCommitHash: null,
113
+ storageUri: "storage://unused",
114
+ archiveByteSize: 3_000_000_001,
115
+ ...overrides,
116
+ id,
117
+ });
118
+
119
+ const seedProductionRelease = async ({
120
+ bundle,
121
+ database,
122
+ }: {
123
+ readonly bundle: Bundle;
124
+ readonly database: ReturnType<typeof supabaseDatabase>;
125
+ }) => {
126
+ const channelName = "production";
127
+ const channelKey = encodeChannelKey(channelName);
128
+ const channel = (
129
+ await database.models.channels.insert({
130
+ row: { id: `channel:${channelKey}`, name: channelName },
131
+ onConflict: "returnExisting",
132
+ })
133
+ ).row;
134
+ const scopeKey = createReleaseCatalogScopeKey({
135
+ channelKey,
136
+ platform: bundle.platform,
137
+ strategy: "APP_VERSION",
138
+ });
139
+ const now = Date.now();
140
+ await commitReleaseCatalogMutations({
141
+ database,
142
+ mutations: [
143
+ {
144
+ mutation: {
145
+ operation: "insert",
146
+ row: {
147
+ bundle_id: bundle.id,
148
+ channel_id: channel.id,
149
+ created_at_ms: now,
150
+ enabled: true,
151
+ fingerprint_hash: null,
152
+ id: createUUIDv7(),
153
+ kind: "BUNDLE",
154
+ message: "hello",
155
+ operation: "DEPLOY",
156
+ platform: bundle.platform,
157
+ revision: 1,
158
+ rollout_cohort_count: 1_000,
159
+ scope_key: scopeKey,
160
+ should_force_update: false,
161
+ source_release_id: null,
162
+ strategy: "APP_VERSION",
163
+ target_app_version: "1.0",
164
+ target_cohorts: [],
165
+ updated_at_ms: now,
166
+ },
167
+ },
168
+ scope: {
169
+ channelId: channel.id,
170
+ channelName,
171
+ fingerprintHash: null,
172
+ platform: bundle.platform,
173
+ scopeKey,
174
+ strategy: "APP_VERSION",
175
+ },
176
+ updatedAtMs: now,
177
+ },
178
+ ],
179
+ });
180
+ };
181
+
130
182
  describe.sequential("supabase edge runtime acceptance", () => {
131
183
  let runtimeRoot: string | undefined;
132
184
  let storageRepoPath = "";
@@ -136,9 +188,37 @@ describe.sequential("supabase edge runtime acceptance", () => {
136
188
  let edgePort = 0;
137
189
  let gatewayBaseUrl = "";
138
190
  let edgeRuntime: ReturnType<typeof spawnRuntime> | undefined;
191
+ let database: ReturnType<typeof supabaseDatabase>;
139
192
  let seedHotUpdater: ReturnType<typeof createHotUpdater>;
193
+ let databaseClient: ReturnType<typeof createDatabaseClient>;
140
194
  let supabaseAdmin: ReturnType<typeof createClient>;
141
195
 
196
+ const runDatabaseSql = (statement: string): void => {
197
+ runCheckedCommand({
198
+ command: "docker",
199
+ args: [
200
+ "compose",
201
+ "-p",
202
+ composeProjectName,
203
+ "-f",
204
+ composeFilePath,
205
+ "exec",
206
+ "-T",
207
+ "db",
208
+ "psql",
209
+ "-U",
210
+ "postgres",
211
+ "-d",
212
+ POSTGRES_DB,
213
+ "-v",
214
+ "ON_ERROR_STOP=1",
215
+ "-c",
216
+ statement,
217
+ ],
218
+ cwd: WORKSPACE_ROOT,
219
+ });
220
+ };
221
+
142
222
  beforeAll(async () => {
143
223
  await ensureBuiltArtifacts(REQUIRED_BUILD_ARTIFACTS);
144
224
 
@@ -226,23 +306,27 @@ describe.sequential("supabase edge runtime acceptance", () => {
226
306
  supabaseAdmin = createClient(gatewayBaseUrl, SERVICE_ROLE_KEY);
227
307
  await ensureBucketExists(supabaseAdmin);
228
308
 
309
+ database = supabaseDatabase({
310
+ supabaseUrl: gatewayBaseUrl,
311
+ supabaseServiceRoleKey: SERVICE_ROLE_KEY,
312
+ });
313
+ await registerApiKey({
314
+ apiKey: API_KEY,
315
+ apiKeys: database.models.apiKeys,
316
+ name: "Runtime acceptance",
317
+ });
318
+ databaseClient = createDatabaseClient(database);
319
+
229
320
  seedHotUpdater = createHotUpdater({
230
- database: supabaseDatabase({
231
- supabaseUrl: gatewayBaseUrl,
232
- supabaseAnonKey: SERVICE_ROLE_KEY,
233
- }),
234
- storages: [
321
+ database,
322
+ clientAccess: { type: "public" },
323
+ storage: [
235
324
  supabaseStorage({
236
325
  supabaseUrl: gatewayBaseUrl,
237
- supabaseAnonKey: SERVICE_ROLE_KEY,
326
+ supabaseServiceRoleKey: SERVICE_ROLE_KEY,
238
327
  bucketName: BUCKET_NAME,
239
328
  }),
240
329
  ],
241
- basePath: HOT_UPDATER_BASE_PATH,
242
- routes: {
243
- updateCheck: true,
244
- bundles: false,
245
- },
246
330
  });
247
331
 
248
332
  edgeRuntime = spawnRuntime({
@@ -299,13 +383,18 @@ describe.sequential("supabase edge runtime acceptance", () => {
299
383
  throw new Error("Supabase admin client was not initialized.");
300
384
  }
301
385
 
302
- const { error } = await supabaseAdmin
303
- .from("bundles")
304
- .delete()
305
- .neq("id", NIL_UUID);
306
-
307
- if (error) {
308
- throw error;
386
+ for (const [table, key] of [
387
+ [SUPABASE_V1_TABLE_NAMES.releaseCatalogs, "scope_key"],
388
+ [SUPABASE_V1_TABLE_NAMES.releases, "id"],
389
+ [SUPABASE_V1_TABLE_NAMES.bundlePatches, "id"],
390
+ [SUPABASE_V1_TABLE_NAMES.bundles, "id"],
391
+ [SUPABASE_V1_TABLE_NAMES.channels, "id"],
392
+ ] as const) {
393
+ const { error } = await supabaseAdmin
394
+ .from(table)
395
+ .delete()
396
+ .neq(key, NIL_UUID);
397
+ if (error) throw error;
309
398
  }
310
399
  });
311
400
 
@@ -336,193 +425,314 @@ describe.sequential("supabase edge runtime acceptance", () => {
336
425
  }
337
426
  }, 60_000);
338
427
 
339
- const seedRuntimeBundles = async (bundles: Bundle[]) => {
340
- for (const bundle of bundles.map(toRuntimeBundle)) {
341
- await seedHotUpdater.insertBundle(bundle);
342
- }
343
- };
428
+ it("returns one canonical Channel row under concurrent inserts", async () => {
429
+ const database = supabaseDatabase({
430
+ supabaseUrl: gatewayBaseUrl,
431
+ supabaseServiceRoleKey: SERVICE_ROLE_KEY,
432
+ });
433
+ const channelName = "concurrent-channel";
434
+ const results = await Promise.all([
435
+ database.models.channels.insert({
436
+ row: {
437
+ id: "00000000-0000-0000-0000-000000000091",
438
+ name: channelName,
439
+ },
440
+ onConflict: "returnExisting",
441
+ }),
442
+ database.models.channels.insert({
443
+ row: {
444
+ id: "00000000-0000-0000-0000-000000000092",
445
+ name: channelName,
446
+ },
447
+ onConflict: "returnExisting",
448
+ }),
449
+ ]);
450
+
451
+ expect(new Set(results.map(({ row }) => row.id)).size).toBe(1);
452
+ expect(results.filter(({ inserted }) => inserted)).toHaveLength(1);
453
+ const stored = await supabaseAdmin
454
+ .from("hot_updater_v1_channels")
455
+ .select("id, name")
456
+ .eq("name", channelName);
457
+ if (stored.error) throw stored.error;
458
+ expect(stored.data).toEqual([results[0]?.row]);
459
+ });
344
460
 
345
- const requestUpdateInfo = async (
346
- args: GetBundlesArgs,
347
- ): Promise<UpdateInfo | null> => {
348
- const response = await fetch(
349
- `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}${createCanonicalPath(args)}`,
461
+ it("rolls back a patch-bearing insert when one base bundle is missing", async () => {
462
+ const base = runtimeBundle("00000000-0000-0000-0000-000000000101");
463
+ const owner = {
464
+ ...base,
465
+ id: "00000000-0000-0000-0000-000000000102",
466
+ fileHash: "hash-owner",
467
+ patches: [
468
+ {
469
+ baseBundleId: base.id,
470
+ baseFileHash: base.fileHash,
471
+ patchFileHash: "hash-valid-patch",
472
+ patchStorageUri: "storage://valid-patch",
473
+ byteSize: 3_000_000_002,
474
+ },
475
+ {
476
+ baseBundleId: "00000000-0000-0000-0000-000000000199",
477
+ baseFileHash: "hash-missing-base",
478
+ patchFileHash: "hash-invalid-patch",
479
+ patchStorageUri: "storage://invalid-patch",
480
+ byteSize: 3_000_000_003,
481
+ },
482
+ ],
483
+ } satisfies Bundle;
484
+ await databaseClient.insertBundle(base);
485
+
486
+ await expect(
487
+ databaseClient.mutate((database) => database.insertBundle(owner)),
488
+ ).rejects.toBeDefined();
489
+
490
+ const ownerResult = await supabaseAdmin
491
+ .from("hot_updater_v1_bundles")
492
+ .select("id")
493
+ .eq("id", owner.id)
494
+ .maybeSingle();
495
+ if (ownerResult.error) throw ownerResult.error;
496
+ expect(ownerResult.data).toBeNull();
497
+ const patchResult = await supabaseAdmin
498
+ .from("hot_updater_v1_bundle_patches")
499
+ .select("id")
500
+ .eq("bundle_id", owner.id);
501
+ if (patchResult.error) throw patchResult.error;
502
+ expect(patchResult.data).toEqual([]);
503
+ });
504
+
505
+ it("preserves extension defaults and generated values during atomic inserts", async () => {
506
+ runDatabaseSql(
507
+ [
508
+ "ALTER TABLE public.hot_updater_v1_bundles",
509
+ "ADD COLUMN tenant_tag text NOT NULL DEFAULT 'default-tenant',",
510
+ "ADD COLUMN file_hash_upper text GENERATED ALWAYS AS (upper(file_hash)) STORED",
511
+ ].join(" "),
350
512
  );
351
513
 
352
- if (!response.ok) {
353
- throw new Error(
354
- [
355
- `Edge runtime returned ${response.status} ${response.statusText}`,
356
- await response.text(),
357
- edgeRuntime ? formatRuntimeLogs(edgeRuntime.logs) : "",
358
- ].join("\n\n"),
514
+ try {
515
+ const base = runtimeBundle("00000000-0000-0000-0000-000000000151");
516
+ const owner = {
517
+ ...base,
518
+ id: "00000000-0000-0000-0000-000000000152",
519
+ fileHash: "hash-owner",
520
+ patches: [
521
+ {
522
+ baseBundleId: base.id,
523
+ baseFileHash: base.fileHash,
524
+ patchFileHash: "hash-patch",
525
+ patchStorageUri: "storage://patch",
526
+ byteSize: 3_000_000_002,
527
+ },
528
+ ],
529
+ } satisfies Bundle;
530
+ await databaseClient.insertBundle(base);
531
+
532
+ await databaseClient.insertBundle(owner);
533
+
534
+ const result = await supabaseAdmin
535
+ .from("hot_updater_v1_bundles")
536
+ .select("tenant_tag, file_hash_upper")
537
+ .eq("id", owner.id)
538
+ .single();
539
+ if (result.error) throw result.error;
540
+ expect(result.data).toEqual({
541
+ tenant_tag: "default-tenant",
542
+ file_hash_upper: "HASH-OWNER",
543
+ });
544
+ } finally {
545
+ runDatabaseSql(
546
+ "ALTER TABLE public.hot_updater_v1_bundles DROP COLUMN tenant_tag, DROP COLUMN file_hash_upper",
359
547
  );
360
548
  }
549
+ });
361
550
 
362
- return response.json();
363
- };
551
+ it("does not resolve public-schema JSON function shadows in atomic RPCs", async () => {
552
+ runDatabaseSql(`
553
+ CREATE FUNCTION public.jsonb_populate_record(public.hot_updater_v1_bundles, jsonb)
554
+ RETURNS public.hot_updater_v1_bundles
555
+ LANGUAGE plpgsql
556
+ AS $$
557
+ BEGIN
558
+ RAISE EXCEPTION 'public jsonb_populate_record shadow executed';
559
+ END;
560
+ $$
561
+ `);
364
562
 
365
- const getUpdateInfo = async (bundles: Bundle[], args: GetBundlesArgs) => {
366
- if (!supabaseAdmin) {
367
- throw new Error("Supabase admin client was not initialized.");
368
- }
563
+ try {
564
+ const base = runtimeBundle("00000000-0000-0000-0000-000000000551");
565
+ const owner = {
566
+ ...base,
567
+ id: "00000000-0000-0000-0000-000000000552",
568
+ fileHash: "hash-owner",
569
+ patches: [
570
+ {
571
+ baseBundleId: base.id,
572
+ baseFileHash: base.fileHash,
573
+ patchFileHash: "hash-patch",
574
+ patchStorageUri: "storage://patch",
575
+ byteSize: 3_000_000_002,
576
+ },
577
+ ],
578
+ } satisfies Bundle;
579
+ await databaseClient.insertBundle(base);
369
580
 
370
- for (const bundle of bundles) {
371
- await uploadBundleObject(supabaseAdmin, bundle.id);
372
- }
373
- await seedRuntimeBundles(bundles);
374
- return requestUpdateInfo(args);
375
- };
581
+ await databaseClient.insertBundle(owner);
376
582
 
377
- setupGetUpdateInfoTestSuite({
378
- getUpdateInfo,
379
- manifestArtifacts: {
380
- prepareArtifacts: async (fixture) => {
381
- await Promise.all([
382
- uploadStorageObject(
383
- supabaseAdmin,
384
- `${fixture.currentBundleId}/manifest.json`,
385
- JSON.stringify(fixture.currentManifest),
386
- "application/json",
387
- ),
388
- uploadStorageObject(
389
- supabaseAdmin,
390
- `${fixture.nextBundleId}/manifest.json`,
391
- JSON.stringify(fixture.nextManifest),
392
- "application/json",
393
- ),
394
- uploadStorageObject(
395
- supabaseAdmin,
396
- `${fixture.nextBundleId}/files/${fixture.changedAssetPath}.br`,
397
- "next-bundle-bytes",
398
- "application/javascript",
399
- ),
400
- ]);
401
-
402
- return {
403
- currentArtifacts: {
404
- assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/files`,
405
- manifestFileHash: "sig:manifest-current",
406
- manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/manifest.json`,
407
- },
408
- nextArtifacts: {
409
- assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/files`,
410
- manifestFileHash: "sig:manifest-next",
411
- manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/manifest.json`,
412
- },
413
- };
414
- },
415
- expectFileUrl: (fileUrl, fixture) => {
416
- expect(fileUrl).toContain(
417
- `/storage/v1/object/sign/${BUCKET_NAME}/${fixture.nextBundleId}/files/${fixture.changedAssetPath}.br`,
418
- );
419
- },
420
- expectManifestUrl: (manifestUrl, fixture) => {
421
- expect(manifestUrl).toContain(
422
- `/storage/v1/object/sign/${BUCKET_NAME}/${fixture.nextBundleId}/manifest.json`,
423
- );
424
- },
425
- },
583
+ await expect(
584
+ databaseClient.getBundleById(owner.id),
585
+ ).resolves.toMatchObject({ id: owner.id, patches: owner.patches });
586
+ } finally {
587
+ runDatabaseSql(
588
+ "DROP FUNCTION public.jsonb_populate_record(public.hot_updater_v1_bundles, jsonb)",
589
+ );
590
+ }
426
591
  });
427
592
 
428
- setupBsdiffManifestUpdateInfoTestSuite({
429
- seedBundles: seedRuntimeBundles,
430
- getUpdateInfo: requestUpdateInfo,
431
- prepareArtifacts: async (fixture) => {
432
- await Promise.all([
433
- uploadStorageObject(
434
- supabaseAdmin,
435
- `${fixture.currentBundleId}/manifest.json`,
436
- JSON.stringify(fixture.currentManifest),
437
- "application/json",
438
- ),
439
- uploadStorageObject(
440
- supabaseAdmin,
441
- `${fixture.nextBundleId}/manifest.json`,
442
- JSON.stringify(fixture.nextManifest),
443
- "application/json",
444
- ),
445
- uploadStorageObject(
446
- supabaseAdmin,
447
- `${fixture.nextBundleId}/files/${fixture.assetPath}`,
448
- "next-bundle-bytes",
449
- "application/javascript",
450
- ),
451
- uploadStorageObject(
452
- supabaseAdmin,
453
- fixture.patchPath,
454
- "patch-bytes",
455
- "application/octet-stream",
456
- ),
457
- uploadBundleObject(supabaseAdmin, fixture.currentBundleId),
458
- uploadBundleObject(supabaseAdmin, fixture.nextBundleId),
459
- ]);
460
-
461
- return {
462
- currentArtifacts: {
463
- assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/files`,
464
- manifestFileHash: "sig:manifest-current",
465
- manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.currentBundleId}/manifest.json`,
593
+ it("rolls back scalar and patch replacement when a new base is missing", async () => {
594
+ const base = runtimeBundle("00000000-0000-0000-0000-000000000201");
595
+ const owner = {
596
+ ...base,
597
+ id: "00000000-0000-0000-0000-000000000202",
598
+ fileHash: "hash-owner",
599
+ gitCommitHash: "before",
600
+ patches: [
601
+ {
602
+ baseBundleId: base.id,
603
+ baseFileHash: base.fileHash,
604
+ patchFileHash: "hash-old-patch",
605
+ patchStorageUri: "storage://old-patch",
606
+ byteSize: 3_000_000_002,
466
607
  },
467
- nextArtifacts: {
468
- assetBaseStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/files`,
469
- manifestFileHash: "sig:manifest-next",
470
- manifestStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.nextBundleId}/manifest.json`,
608
+ ],
609
+ } satisfies Bundle;
610
+ await databaseClient.insertBundle(base);
611
+ await databaseClient.insertBundle(owner);
612
+
613
+ await expect(
614
+ databaseClient.mutate((database) =>
615
+ database.updateBundleById(owner.id, {
616
+ gitCommitHash: "after",
471
617
  patches: [
472
618
  {
473
- baseBundleId: fixture.currentBundleId,
474
- baseFileHash: "hash-old-bundle",
475
- patchFileHash: "hash-bsdiff",
476
- patchStorageUri: `supabase-storage://${BUCKET_NAME}/${fixture.patchPath}`,
619
+ baseBundleId: "00000000-0000-0000-0000-000000000299",
620
+ baseFileHash: "hash-missing-base",
621
+ patchFileHash: "hash-invalid-patch",
622
+ patchStorageUri: "storage://invalid-patch",
623
+ byteSize: 3_000_000_003,
477
624
  },
478
625
  ],
626
+ }),
627
+ ),
628
+ ).rejects.toBeDefined();
629
+
630
+ await expect(databaseClient.getBundleById(owner.id)).resolves.toMatchObject(
631
+ {
632
+ gitCommitHash: "before",
633
+ patches: owner.patches,
634
+ },
635
+ );
636
+ });
637
+
638
+ it("atomically applies explicit nulls and an empty patch list", async () => {
639
+ const base = runtimeBundle("00000000-0000-0000-0000-000000000301");
640
+ const owner = {
641
+ ...base,
642
+ id: "00000000-0000-0000-0000-000000000302",
643
+ fileHash: "hash-owner",
644
+ gitCommitHash: "before",
645
+ patches: [
646
+ {
647
+ baseBundleId: base.id,
648
+ baseFileHash: base.fileHash,
649
+ patchFileHash: "hash-old-patch",
650
+ patchStorageUri: "storage://old-patch",
651
+ byteSize: 3_000_000_002,
479
652
  },
480
- };
481
- },
482
- expectPatchUrl: (patchUrl, fixture) => {
483
- expect(patchUrl).toContain(
484
- `/storage/v1/object/sign/${BUCKET_NAME}/${fixture.patchPath}`,
485
- );
486
- },
653
+ ],
654
+ } satisfies Bundle;
655
+ await databaseClient.insertBundle(base);
656
+ await databaseClient.insertBundle(owner);
657
+
658
+ await databaseClient.mutate((database) =>
659
+ database.updateBundleById(owner.id, {
660
+ gitCommitHash: null,
661
+ patches: [],
662
+ }),
663
+ );
664
+
665
+ await expect(databaseClient.getBundleById(owner.id)).resolves.toMatchObject(
666
+ {
667
+ gitCommitHash: null,
668
+ patches: [],
669
+ },
670
+ );
671
+ });
672
+
673
+ it("maps a missing aggregate update to the public not-found error", async () => {
674
+ const result = databaseClient.updateBundleById(
675
+ "00000000-0000-0000-0000-000000000401",
676
+ { patches: [] },
677
+ );
678
+
679
+ await expect(result).rejects.toMatchObject({
680
+ name: "DatabaseBundleNotFoundError",
681
+ bundleId: "00000000-0000-0000-0000-000000000401",
682
+ });
683
+ });
684
+
685
+ it("denies the generic commit RPC to the anonymous role", async () => {
686
+ const response = await fetch(
687
+ `${gatewayBaseUrl}/rest/v1/rpc/hot_updater_v1_commit`,
688
+ {
689
+ method: "POST",
690
+ headers: {
691
+ apikey: ANON_KEY,
692
+ Authorization: `Bearer ${ANON_KEY}`,
693
+ "Content-Type": "application/json",
694
+ },
695
+ body: JSON.stringify({ p_commit: { changes: [] } }),
696
+ },
697
+ );
698
+
699
+ expect(response.ok).toBe(false);
487
700
  });
488
701
 
489
- it("serves canonical routes from the edge function entrypoint", async () => {
702
+ it("serves unversioned Release Catalog routes from the edge function entrypoint", async () => {
490
703
  const bundle = toRuntimeBundle({
491
704
  id: "00000000-0000-0000-0000-000000000001",
492
705
  platform: "ios",
493
- targetAppVersion: "1.0",
494
- shouldForceUpdate: false,
495
- enabled: true,
496
706
  fileHash: "hash",
497
707
  gitCommitHash: null,
498
- message: "hello",
499
- channel: "production",
500
708
  storageUri: "storage://unused",
501
- fingerprintHash: null,
709
+ archiveByteSize: 3_000_000_001,
502
710
  });
503
711
 
504
712
  await uploadBundleObject(supabaseAdmin, bundle.id);
505
713
  await seedHotUpdater.insertBundle(bundle);
714
+ await seedProductionRelease({ bundle, database });
715
+
716
+ const unauthorized = await fetch(
717
+ `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.0.0`,
718
+ );
719
+ expect(unauthorized.status).toBe(401);
506
720
 
507
721
  const response = await fetch(
508
- `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}${createCanonicalPath({
509
- appVersion: "1.0",
510
- bundleId: NIL_UUID,
511
- platform: "ios",
512
- _updateStrategy: "appVersion",
513
- })}`,
722
+ `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/release-catalogs/app-version/ios/cHJvZHVjdGlvbg/1.0.0`,
723
+ { headers: { "x-api-key": API_KEY } },
514
724
  );
515
725
 
516
726
  expect(response.ok).toBe(true);
517
727
  await expect(response.json()).resolves.toMatchObject({
518
- id: "00000000-0000-0000-0000-000000000001",
519
- status: "UPDATE",
728
+ catalogId: expect.stringMatching(/^[0-9a-f-]{36}$/),
729
+ releases: [{ bundleId: "00000000-0000-0000-0000-000000000001" }],
520
730
  });
521
731
  });
522
732
 
523
733
  it("does not support the legacy exact path", async () => {
524
734
  const response = await fetch(
525
- `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}${LEGACY_HOT_UPDATER_BASE_PATH}`,
735
+ `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/api/check-update`,
526
736
  );
527
737
 
528
738
  expect(response.status).toBe(404);
@@ -530,7 +740,7 @@ describe.sequential("supabase edge runtime acceptance", () => {
530
740
 
531
741
  it("does not expose management routes from the edge function entrypoint", async () => {
532
742
  const response = await fetch(
533
- `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/api/bundles`,
743
+ `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/admin/bundles`,
534
744
  );
535
745
 
536
746
  expect(response.status).toBe(404);
@@ -597,7 +807,7 @@ const waitForRestApiReady = async (baseUrl: string, timeoutMs = 90_000) => {
597
807
  while (Date.now() < deadline) {
598
808
  try {
599
809
  const response = await fetch(
600
- `${baseUrl}/rest/v1/bundles?select=id&limit=1`,
810
+ `${baseUrl}/rest/v1/${SUPABASE_V1_TABLE_NAMES.bundles}?select=id&limit=1`,
601
811
  {
602
812
  headers: {
603
813
  apikey: SERVICE_ROLE_KEY,
@@ -747,12 +957,16 @@ ${storageMigrations.join("\n\n")}
747
957
  SET search_path TO public;
748
958
 
749
959
  GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
750
- GRANT USAGE ON TYPE platforms TO anon, authenticated, service_role;
751
960
  GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon, authenticated;
752
961
  GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO service_role;
753
962
  GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated, service_role;
754
963
  GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated, service_role;
755
964
 
965
+ REVOKE EXECUTE ON FUNCTION public.hot_updater_v1_commit(jsonb)
966
+ FROM anon, authenticated;
967
+ REVOKE EXECUTE ON FUNCTION public.hot_updater_v1_delete_channel(text)
968
+ FROM anon, authenticated;
969
+
756
970
  ALTER DEFAULT PRIVILEGES IN SCHEMA public
757
971
  GRANT SELECT ON TABLES TO anon, authenticated;
758
972
  ALTER DEFAULT PRIVILEGES IN SCHEMA public
@@ -924,6 +1138,7 @@ const writeSupabaseRuntimeFiles = async ({
924
1138
  "plugins/supabase/supabase/edge-functions/index.ts",
925
1139
  ),
926
1140
  {
1141
+ BUCKET_NAME,
927
1142
  FUNCTION_NAME,
928
1143
  },
929
1144
  );
@@ -932,7 +1147,7 @@ const writeSupabaseRuntimeFiles = async ({
932
1147
  "@hot-updater/server": pathToFileURL(
933
1148
  path.join(WORKSPACE_ROOT, "packages/server/dist/index.mjs"),
934
1149
  ).href,
935
- "@hot-updater/supabase": pathToFileURL(
1150
+ "@hot-updater/supabase/edge": pathToFileURL(
936
1151
  path.join(runtimeRoot, "hot-updater-supabase-edge.ts"),
937
1152
  ).href,
938
1153
  hono: `npm:hono@${resolvePackageVersion("hono", {
@@ -944,8 +1159,8 @@ const writeSupabaseRuntimeFiles = async ({
944
1159
  await writeFile(
945
1160
  path.join(runtimeRoot, "hot-updater-supabase-edge.ts"),
946
1161
  `
947
- export { supabaseEdgeFunctionDatabase } from ${JSON.stringify(pathToFileURL(path.join(WORKSPACE_ROOT, "plugins/supabase/src/supabaseEdgeFunctionDatabase.ts")).href)};
948
- export { supabaseEdgeFunctionStorage } from ${JSON.stringify(pathToFileURL(path.join(WORKSPACE_ROOT, "plugins/supabase/src/supabaseEdgeFunctionStorage.ts")).href)};
1162
+ export { supabaseDatabase } from ${JSON.stringify(pathToFileURL(path.join(WORKSPACE_ROOT, "plugins/supabase/src/supabaseDatabase.ts")).href)};
1163
+ export { supabaseEdgeFunctionStorage as supabaseStorage } from ${JSON.stringify(pathToFileURL(path.join(WORKSPACE_ROOT, "plugins/supabase/src/supabaseEdgeFunctionStorage.ts")).href)};
949
1164
  `.trim(),
950
1165
  );
951
1166
  await writeFile(