@hot-updater/supabase 1.0.0-rc.2 → 1.0.0-rc.21

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.
@@ -1,1183 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createHmac } from "node:crypto";
3
- import {
4
- access,
5
- mkdir,
6
- mkdtemp,
7
- readdir,
8
- readFile,
9
- rm,
10
- symlink,
11
- writeFile,
12
- } from "node:fs/promises";
13
- import path from "node:path";
14
- import { fileURLToPath, pathToFileURL } from "node:url";
15
-
16
- import { resolvePackageVersion, transformEnv } from "@hot-updater/cli-tools";
17
- import {
18
- type Bundle,
19
- NIL_UUID,
20
- createReleaseCatalogScopeKey,
21
- encodeChannelKey,
22
- } from "@hot-updater/core";
23
- import {
24
- commitReleaseCatalogMutations,
25
- createDatabaseClient,
26
- createUUIDv7,
27
- } from "@hot-updater/plugin-core";
28
- import { createHotUpdater, registerApiKey } from "@hot-updater/server";
29
- import { createClient } from "@supabase/supabase-js";
30
- import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
31
-
32
- import {
33
- assertDockerComposeAvailable,
34
- findOpenPort,
35
- runCheckedCommand,
36
- spawnRuntime,
37
- stopRuntime,
38
- waitForHttpOk,
39
- } from "../../../../packages/test-utils/src/runtimeProcess";
40
- import { supabaseDatabase } from "../../src/supabaseDatabase";
41
- import { SUPABASE_V1_TABLE_NAMES } from "../../src/supabaseInfrastructureNames";
42
- import { supabaseStorage } from "../../src/supabaseStorage";
43
-
44
- const __filename = fileURLToPath(import.meta.url);
45
- const __dirname = path.dirname(__filename);
46
- const WORKSPACE_ROOT = path.resolve(__dirname, "../../../..");
47
- const FUNCTION_NAME = "hot-updater-function";
48
- const FUNCTION_BASE_PATH = `/${FUNCTION_NAME}`;
49
- const API_KEY = "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE";
50
- const BUCKET_NAME = "hot-updater-bundles";
51
- const DENO_DOCKER_IMAGE = "denoland/deno:alpine";
52
- const DENO_CACHE_VOLUME = "hot-updater-supabase-deno-cache";
53
- const POSTGRES_IMAGE = "postgres:15-alpine";
54
- const POSTGREST_IMAGE = "postgrest/postgrest:v14.6";
55
- const STORAGE_IMAGE = "supabase/storage-api:v1.44.2";
56
- const IMGPROXY_IMAGE = "darthsim/imgproxy:v3.30.1";
57
- const NGINX_IMAGE = "nginx:1.27-alpine";
58
- const POSTGRES_PASSWORD = "postgres";
59
- const POSTGRES_DB = "postgres";
60
- const JWT_SECRET = "super-secret-jwt-token-with-at-least-32-chars";
61
- const JWT_EXPIRY_SECONDS = 60 * 60 * 24 * 365;
62
- const ANON_KEY = createLegacyJwt("anon");
63
- const SERVICE_ROLE_KEY = createLegacyJwt("service_role");
64
- const REQUIRED_BUILD_ARTIFACTS = [
65
- {
66
- command: "pnpm --filter @hot-updater/core build",
67
- path: path.join(WORKSPACE_ROOT, "packages/core/dist/index.mjs"),
68
- },
69
- {
70
- command: "pnpm --filter @hot-updater/server build",
71
- path: path.join(WORKSPACE_ROOT, "packages/server/dist/index.mjs"),
72
- },
73
- {
74
- command: "pnpm --filter @hot-updater/plugin-core build",
75
- path: path.join(WORKSPACE_ROOT, "plugins/plugin-core/dist/index.mjs"),
76
- },
77
- {
78
- command: "pnpm --filter @hot-updater/supabase build",
79
- path: path.join(WORKSPACE_ROOT, "plugins/supabase/dist/index.mjs"),
80
- },
81
- ] as const;
82
-
83
- assertDockerComposeAvailable(
84
- "supabase edge runtime acceptance requires Docker Compose and a running Docker daemon.",
85
- );
86
-
87
- const ensureBuiltArtifacts = async (
88
- artifacts: ReadonlyArray<{ command: string; path: string }>,
89
- ) => {
90
- for (const artifact of artifacts) {
91
- try {
92
- await access(artifact.path);
93
- } catch {
94
- throw new Error(
95
- `Missing built artifact at ${artifact.path}. Run \`${artifact.command}\` before running this test.`,
96
- );
97
- }
98
- }
99
- };
100
-
101
- const toRuntimeBundle = (bundle: Bundle): Bundle => {
102
- return {
103
- ...bundle,
104
- storageUri: `supabase-storage://${BUCKET_NAME}/${bundle.id}/bundle.zip`,
105
- };
106
- };
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
-
182
- describe.sequential("supabase edge runtime acceptance", () => {
183
- let runtimeRoot: string | undefined;
184
- let storageRepoPath = "";
185
- let composeFilePath = "";
186
- let composeProjectName = "";
187
- let gatewayPort = 0;
188
- let edgePort = 0;
189
- let gatewayBaseUrl = "";
190
- let edgeRuntime: ReturnType<typeof spawnRuntime> | undefined;
191
- let database: ReturnType<typeof supabaseDatabase>;
192
- let seedHotUpdater: ReturnType<typeof createHotUpdater>;
193
- let databaseClient: ReturnType<typeof createDatabaseClient>;
194
- let supabaseAdmin: ReturnType<typeof createClient>;
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
-
222
- beforeAll(async () => {
223
- await ensureBuiltArtifacts(REQUIRED_BUILD_ARTIFACTS);
224
-
225
- runtimeRoot = await mkdtemp(
226
- path.join(WORKSPACE_ROOT, "plugins/supabase/runtime-acceptance-"),
227
- );
228
- storageRepoPath = path.join(runtimeRoot, "storage-repo");
229
- gatewayPort = await findOpenPort();
230
- edgePort = await findOpenPort();
231
- gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`;
232
- composeProjectName = `hot-updater-supabase-${process.pid}-${Date.now()}`;
233
- composeFilePath = path.join(runtimeRoot, "docker-compose.yml");
234
-
235
- runCheckedCommand({
236
- command: "git",
237
- args: [
238
- "clone",
239
- "--depth",
240
- "1",
241
- "https://github.com/supabase/storage.git",
242
- storageRepoPath,
243
- ],
244
- cwd: WORKSPACE_ROOT,
245
- });
246
-
247
- await writeSupabaseRuntimeFiles({
248
- runtimeRoot,
249
- gatewayPort,
250
- storageRepoPath,
251
- });
252
-
253
- try {
254
- runCheckedCommand({
255
- command: "docker",
256
- args: [
257
- "compose",
258
- "-p",
259
- composeProjectName,
260
- "-f",
261
- composeFilePath,
262
- "up",
263
- "-d",
264
- ],
265
- cwd: WORKSPACE_ROOT,
266
- });
267
- } catch (error) {
268
- let dbLogs = "";
269
-
270
- try {
271
- const result = spawnSync(
272
- "docker",
273
- [
274
- "compose",
275
- "-p",
276
- composeProjectName,
277
- "-f",
278
- composeFilePath,
279
- "logs",
280
- "--no-color",
281
- "db",
282
- ],
283
- {
284
- cwd: WORKSPACE_ROOT,
285
- encoding: "utf8",
286
- },
287
- );
288
- dbLogs = [result.stdout, result.stderr].filter(Boolean).join("\n");
289
- } catch {
290
- dbLogs = "failed to collect database logs";
291
- }
292
-
293
- throw new Error(
294
- [
295
- error instanceof Error ? error.message : String(error),
296
- "",
297
- "Database logs:",
298
- dbLogs,
299
- ].join("\n"),
300
- );
301
- }
302
-
303
- await waitForRestApiReady(gatewayBaseUrl, 180_000);
304
- await waitForUrlOk(`${gatewayBaseUrl}/storage/v1/status`, 180_000);
305
-
306
- supabaseAdmin = createClient(gatewayBaseUrl, SERVICE_ROLE_KEY);
307
- await ensureBucketExists(supabaseAdmin);
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
-
320
- seedHotUpdater = createHotUpdater({
321
- database,
322
- clientAccess: { type: "public" },
323
- storage: [
324
- supabaseStorage({
325
- supabaseUrl: gatewayBaseUrl,
326
- supabaseServiceRoleKey: SERVICE_ROLE_KEY,
327
- bucketName: BUCKET_NAME,
328
- }),
329
- ],
330
- });
331
-
332
- edgeRuntime = spawnRuntime({
333
- command: "docker",
334
- args: [
335
- "run",
336
- "--rm",
337
- "--network",
338
- `${composeProjectName}_default`,
339
- "--add-host",
340
- "host.docker.internal:host-gateway",
341
- "-p",
342
- `127.0.0.1:${edgePort}:8000`,
343
- "-e",
344
- `SUPABASE_URL=http://host.docker.internal:${gatewayPort}`,
345
- "-e",
346
- `SUPABASE_SERVICE_ROLE_KEY=${SERVICE_ROLE_KEY}`,
347
- "-e",
348
- "DENO_DIR=/deno-dir",
349
- "-v",
350
- `${WORKSPACE_ROOT}:${WORKSPACE_ROOT}:ro`,
351
- "-v",
352
- `${runtimeRoot}:${runtimeRoot}`,
353
- "-v",
354
- `${DENO_CACHE_VOLUME}:/deno-dir`,
355
- "-w",
356
- runtimeRoot,
357
- DENO_DOCKER_IMAGE,
358
- "run",
359
- "--no-lock",
360
- "--node-modules-dir=manual",
361
- "--allow-env",
362
- "--allow-net",
363
- "--allow-read",
364
- "--allow-sys",
365
- "--unstable-sloppy-imports",
366
- "--import-map",
367
- path.join(runtimeRoot, "import_map.json"),
368
- path.join(runtimeRoot, "supabase/edge-functions/index.ts"),
369
- ],
370
- cwd: WORKSPACE_ROOT,
371
- });
372
-
373
- await waitForHttpOk({
374
- url: `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/ping`,
375
- child: edgeRuntime.child,
376
- logs: edgeRuntime.logs,
377
- timeoutMs: 90_000,
378
- });
379
- }, 300_000);
380
-
381
- beforeEach(async () => {
382
- if (!supabaseAdmin) {
383
- throw new Error("Supabase admin client was not initialized.");
384
- }
385
-
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;
398
- }
399
- });
400
-
401
- afterAll(async () => {
402
- if (edgeRuntime) {
403
- await stopRuntime(edgeRuntime.child);
404
- }
405
-
406
- if (composeFilePath) {
407
- runCheckedCommand({
408
- command: "docker",
409
- args: [
410
- "compose",
411
- "-p",
412
- composeProjectName,
413
- "-f",
414
- composeFilePath,
415
- "down",
416
- "-v",
417
- "--remove-orphans",
418
- ],
419
- cwd: WORKSPACE_ROOT,
420
- });
421
- }
422
-
423
- if (runtimeRoot) {
424
- await rm(runtimeRoot, { recursive: true, force: true });
425
- }
426
- }, 60_000);
427
-
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
- });
460
-
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(" "),
512
- );
513
-
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",
547
- );
548
- }
549
- });
550
-
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
- `);
562
-
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);
580
-
581
- await databaseClient.insertBundle(owner);
582
-
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
- }
591
- });
592
-
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,
607
- },
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",
617
- patches: [
618
- {
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,
624
- },
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,
652
- },
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);
700
- });
701
-
702
- it("serves unversioned Release Catalog routes from the edge function entrypoint", async () => {
703
- const bundle = toRuntimeBundle({
704
- id: "00000000-0000-0000-0000-000000000001",
705
- platform: "ios",
706
- fileHash: "hash",
707
- gitCommitHash: null,
708
- storageUri: "storage://unused",
709
- archiveByteSize: 3_000_000_001,
710
- });
711
-
712
- await uploadBundleObject(supabaseAdmin, bundle.id);
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);
720
-
721
- const response = await fetch(
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 } },
724
- );
725
-
726
- expect(response.ok).toBe(true);
727
- await expect(response.json()).resolves.toMatchObject({
728
- catalogId: expect.stringMatching(/^[0-9a-f-]{36}$/),
729
- releases: [{ bundleId: "00000000-0000-0000-0000-000000000001" }],
730
- });
731
- });
732
-
733
- it("does not support the legacy exact path", async () => {
734
- const response = await fetch(
735
- `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/api/check-update`,
736
- );
737
-
738
- expect(response.status).toBe(404);
739
- });
740
-
741
- it("does not expose management routes from the edge function entrypoint", async () => {
742
- const response = await fetch(
743
- `http://127.0.0.1:${edgePort}${FUNCTION_BASE_PATH}/admin/bundles`,
744
- );
745
-
746
- expect(response.status).toBe(404);
747
- await expect(response.json()).resolves.toEqual({
748
- error: "Not found",
749
- });
750
- });
751
- });
752
-
753
- function base64UrlEncode(value: string | Buffer) {
754
- return Buffer.from(value)
755
- .toString("base64")
756
- .replace(/\+/g, "-")
757
- .replace(/\//g, "_")
758
- .replace(/=+$/g, "");
759
- }
760
-
761
- function createLegacyJwt(role: "anon" | "service_role") {
762
- const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
763
- const payload = base64UrlEncode(
764
- JSON.stringify({
765
- role,
766
- iss: "supabase-test",
767
- iat: Math.floor(Date.now() / 1000),
768
- exp: Math.floor(Date.now() / 1000) + JWT_EXPIRY_SECONDS,
769
- }),
770
- );
771
- const signature = createHmac("sha256", JWT_SECRET)
772
- .update(`${header}.${payload}`)
773
- .digest("base64")
774
- .replace(/\+/g, "-")
775
- .replace(/\//g, "_")
776
- .replace(/=+$/g, "");
777
-
778
- return `${header}.${payload}.${signature}`;
779
- }
780
-
781
- const waitForUrlOk = async (url: string, timeoutMs = 90_000) => {
782
- const deadline = Date.now() + timeoutMs;
783
- let lastError = "no response";
784
-
785
- while (Date.now() < deadline) {
786
- try {
787
- const response = await fetch(url);
788
- if (response.ok) {
789
- return;
790
- }
791
-
792
- lastError = `${response.status} ${response.statusText}`;
793
- } catch (error) {
794
- lastError = error instanceof Error ? error.message : String(error);
795
- }
796
-
797
- await sleep(500);
798
- }
799
-
800
- throw new Error(`Timed out waiting for ${url}: ${lastError}`);
801
- };
802
-
803
- const waitForRestApiReady = async (baseUrl: string, timeoutMs = 90_000) => {
804
- const deadline = Date.now() + timeoutMs;
805
- let lastError = "no response";
806
-
807
- while (Date.now() < deadline) {
808
- try {
809
- const response = await fetch(
810
- `${baseUrl}/rest/v1/${SUPABASE_V1_TABLE_NAMES.bundles}?select=id&limit=1`,
811
- {
812
- headers: {
813
- apikey: SERVICE_ROLE_KEY,
814
- Authorization: `Bearer ${SERVICE_ROLE_KEY}`,
815
- },
816
- },
817
- );
818
- if (response.ok) {
819
- return;
820
- }
821
-
822
- lastError = `${response.status} ${response.statusText}: ${await response.text()}`;
823
- } catch (error) {
824
- lastError = error instanceof Error ? error.message : String(error);
825
- }
826
-
827
- await sleep(500);
828
- }
829
-
830
- throw new Error(`Timed out waiting for PostgREST: ${lastError}`);
831
- };
832
-
833
- const sleep = async (ms: number) => {
834
- await new Promise((resolve) => setTimeout(resolve, ms));
835
- };
836
-
837
- const ensureBucketExists = async (
838
- supabaseAdmin: ReturnType<typeof createClient>,
839
- ) => {
840
- const { data: buckets, error: listError } =
841
- await supabaseAdmin.storage.listBuckets();
842
-
843
- if (listError) {
844
- throw listError;
845
- }
846
-
847
- if (buckets.some((bucket) => bucket.name === BUCKET_NAME)) {
848
- return;
849
- }
850
-
851
- const { error } = await supabaseAdmin.storage.createBucket(BUCKET_NAME);
852
-
853
- if (error) {
854
- throw error;
855
- }
856
- };
857
-
858
- const uploadBundleObject = async (
859
- supabaseAdmin: ReturnType<typeof createClient>,
860
- bundleId: string,
861
- ) => {
862
- await uploadStorageObject(
863
- supabaseAdmin,
864
- `${bundleId}/bundle.zip`,
865
- Buffer.from("zip"),
866
- "application/zip",
867
- );
868
- };
869
-
870
- const uploadStorageObject = async (
871
- supabaseAdmin: ReturnType<typeof createClient>,
872
- key: string,
873
- body: string | Buffer,
874
- contentType: string,
875
- ) => {
876
- const { error } = await supabaseAdmin.storage
877
- .from(BUCKET_NAME)
878
- .upload(key, body, {
879
- contentType,
880
- cacheControl: "31536000",
881
- upsert: true,
882
- });
883
-
884
- if (error) {
885
- throw error;
886
- }
887
- };
888
-
889
- const loadSupabaseInitSql = async (storageRepoPath: string) => {
890
- const storageMigrationsDir = path.join(storageRepoPath, "migrations/tenant");
891
- const storageMigrationFiles = (await readdir(storageMigrationsDir))
892
- .filter((file) => file.endsWith(".sql"))
893
- .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
894
- const storageMigrations = await Promise.all(
895
- storageMigrationFiles.map(async (file) => {
896
- const contents = await readFile(
897
- path.join(storageMigrationsDir, file),
898
- "utf8",
899
- );
900
- const trimmed = contents.trimEnd();
901
- return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
902
- }),
903
- );
904
-
905
- const migrationsDir = path.join(
906
- WORKSPACE_ROOT,
907
- "plugins/supabase/supabase/migrations",
908
- );
909
- const migrationFiles = (await readdir(migrationsDir))
910
- .filter((file) => file.endsWith(".sql"))
911
- .sort();
912
- const migrations = await Promise.all(
913
- migrationFiles.map(async (file) => {
914
- const contents = await readFile(path.join(migrationsDir, file), "utf8");
915
- return contents.replaceAll("%%BUCKET_NAME%%", BUCKET_NAME);
916
- }),
917
- );
918
-
919
- return `
920
- CREATE EXTENSION IF NOT EXISTS pgcrypto;
921
-
922
- DO $$
923
- BEGIN
924
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
925
- CREATE ROLE anon NOLOGIN NOINHERIT;
926
- END IF;
927
-
928
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
929
- CREATE ROLE authenticated NOLOGIN NOINHERIT;
930
- END IF;
931
-
932
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
933
- CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS;
934
- END IF;
935
-
936
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticator') THEN
937
- CREATE ROLE authenticator LOGIN PASSWORD '${POSTGRES_PASSWORD}' NOINHERIT;
938
- END IF;
939
-
940
- IF NOT EXISTS (
941
- SELECT 1 FROM pg_roles WHERE rolname = 'supabase_storage_admin'
942
- ) THEN
943
- CREATE ROLE supabase_storage_admin LOGIN PASSWORD '${POSTGRES_PASSWORD}' SUPERUSER;
944
- END IF;
945
- END $$;
946
-
947
- GRANT anon TO authenticator;
948
- GRANT authenticated TO authenticator;
949
- GRANT service_role TO authenticator;
950
-
951
- ${migrations.join("\n\n")}
952
-
953
- SET search_path TO storage, public, extensions;
954
-
955
- ${storageMigrations.join("\n\n")}
956
-
957
- SET search_path TO public;
958
-
959
- GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
960
- GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon, authenticated;
961
- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO service_role;
962
- GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated, service_role;
963
- GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated, service_role;
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
-
970
- ALTER DEFAULT PRIVILEGES IN SCHEMA public
971
- GRANT SELECT ON TABLES TO anon, authenticated;
972
- ALTER DEFAULT PRIVILEGES IN SCHEMA public
973
- GRANT ALL PRIVILEGES ON TABLES TO service_role;
974
- ALTER DEFAULT PRIVILEGES IN SCHEMA public
975
- GRANT USAGE, SELECT ON SEQUENCES TO anon, authenticated, service_role;
976
- ALTER DEFAULT PRIVILEGES IN SCHEMA public
977
- GRANT EXECUTE ON FUNCTIONS TO anon, authenticated, service_role;
978
- `.trim();
979
- };
980
-
981
- const createComposeFile = ({
982
- gatewayPort,
983
- runtimeRoot,
984
- }: {
985
- gatewayPort: number;
986
- runtimeRoot: string;
987
- }) => {
988
- return `
989
- services:
990
- db:
991
- image: ${POSTGRES_IMAGE}
992
- environment:
993
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
994
- POSTGRES_DB: ${POSTGRES_DB}
995
- healthcheck:
996
- test: ["CMD-SHELL", "pg_isready -U postgres -d ${POSTGRES_DB}"]
997
- interval: 5s
998
- timeout: 5s
999
- retries: 20
1000
- volumes:
1001
- - ${path.join(runtimeRoot, "db-init")}:/docker-entrypoint-initdb.d:ro
1002
-
1003
- rest:
1004
- image: ${POSTGREST_IMAGE}
1005
- depends_on:
1006
- db:
1007
- condition: service_healthy
1008
- environment:
1009
- PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
1010
- PGRST_DB_SCHEMAS: public,storage
1011
- PGRST_DB_MAX_ROWS: 1000
1012
- PGRST_DB_EXTRA_SEARCH_PATH: public
1013
- PGRST_DB_ANON_ROLE: anon
1014
- PGRST_JWT_SECRET: ${JWT_SECRET}
1015
- PGRST_DB_USE_LEGACY_GUCS: "false"
1016
- PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
1017
- PGRST_APP_SETTINGS_JWT_EXP: "3600"
1018
-
1019
- imgproxy:
1020
- image: ${IMGPROXY_IMAGE}
1021
- environment:
1022
- IMGPROXY_BIND: ":5001"
1023
- IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
1024
- IMGPROXY_USE_ETAG: "true"
1025
-
1026
- storage:
1027
- image: ${STORAGE_IMAGE}
1028
- restart: on-failure
1029
- depends_on:
1030
- db:
1031
- condition: service_healthy
1032
- rest:
1033
- condition: service_started
1034
- imgproxy:
1035
- condition: service_started
1036
- environment:
1037
- ANON_KEY: ${ANON_KEY}
1038
- SERVICE_KEY: ${SERVICE_ROLE_KEY}
1039
- POSTGREST_URL: http://rest:3000
1040
- AUTH_JWT_SECRET: ${JWT_SECRET}
1041
- DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
1042
- STORAGE_PUBLIC_URL: http://gateway:8000
1043
- REQUEST_ALLOW_X_FORWARDED_PATH: "true"
1044
- FILE_SIZE_LIMIT: 52428800
1045
- STORAGE_BACKEND: file
1046
- GLOBAL_S3_BUCKET: ${BUCKET_NAME}
1047
- FILE_STORAGE_BACKEND_PATH: /var/lib/storage
1048
- TENANT_ID: stub
1049
- REGION: stub
1050
- ENABLE_IMAGE_TRANSFORMATION: "false"
1051
- IMGPROXY_URL: http://imgproxy:5001
1052
- S3_PROTOCOL_ACCESS_KEY_ID: stub
1053
- S3_PROTOCOL_ACCESS_KEY_SECRET: stub
1054
- volumes:
1055
- - storage-data:/var/lib/storage
1056
-
1057
- gateway:
1058
- image: ${NGINX_IMAGE}
1059
- depends_on:
1060
- storage:
1061
- condition: service_started
1062
- rest:
1063
- condition: service_started
1064
- ports:
1065
- - "0.0.0.0:${gatewayPort}:8000"
1066
- volumes:
1067
- - ${path.join(runtimeRoot, "nginx.conf")}:/etc/nginx/nginx.conf:ro
1068
-
1069
- volumes:
1070
- storage-data:
1071
- `.trim();
1072
- };
1073
-
1074
- const createNginxConfig = () => {
1075
- return `
1076
- events {}
1077
-
1078
- http {
1079
- client_max_body_size 100m;
1080
-
1081
- server {
1082
- listen 8000;
1083
-
1084
- location /rest/v1/ {
1085
- proxy_pass http://rest:3000/;
1086
- proxy_http_version 1.1;
1087
- proxy_set_header Host $host;
1088
- proxy_set_header Authorization $http_authorization;
1089
- proxy_set_header apikey $http_apikey;
1090
- proxy_set_header Content-Profile $http_content_profile;
1091
- proxy_set_header Accept-Profile $http_accept_profile;
1092
- proxy_set_header Prefer $http_prefer;
1093
- proxy_set_header Range $http_range;
1094
- proxy_set_header Range-Unit $http_range_unit;
1095
- proxy_set_header Content-Type $http_content_type;
1096
- }
1097
-
1098
- location /storage/v1/ {
1099
- proxy_pass http://storage:5000/;
1100
- proxy_http_version 1.1;
1101
- proxy_set_header Host $host;
1102
- proxy_set_header Authorization $http_authorization;
1103
- proxy_set_header apikey $http_apikey;
1104
- proxy_set_header x-forwarded-path $request_uri;
1105
- proxy_set_header Content-Type $http_content_type;
1106
- proxy_set_header Content-Length $content_length;
1107
- }
1108
- }
1109
- }
1110
- `.trim();
1111
- };
1112
-
1113
- const writeSupabaseRuntimeFiles = async ({
1114
- runtimeRoot,
1115
- gatewayPort,
1116
- storageRepoPath,
1117
- }: {
1118
- runtimeRoot: string;
1119
- gatewayPort: number;
1120
- storageRepoPath: string;
1121
- }) => {
1122
- await mkdir(path.join(runtimeRoot, "db-init"), { recursive: true });
1123
- await mkdir(path.join(runtimeRoot, "supabase/edge-functions"), {
1124
- recursive: true,
1125
- });
1126
- await symlink(
1127
- path.join(WORKSPACE_ROOT, "plugins/supabase/src"),
1128
- path.join(runtimeRoot, "src"),
1129
- );
1130
- await symlink(
1131
- path.join(WORKSPACE_ROOT, "plugins/supabase/node_modules"),
1132
- path.join(runtimeRoot, "node_modules"),
1133
- );
1134
-
1135
- const transformedEntry = transformEnv(
1136
- path.join(
1137
- WORKSPACE_ROOT,
1138
- "plugins/supabase/supabase/edge-functions/index.ts",
1139
- ),
1140
- {
1141
- BUCKET_NAME,
1142
- FUNCTION_NAME,
1143
- },
1144
- );
1145
- const importMap = {
1146
- imports: {
1147
- "@hot-updater/server": pathToFileURL(
1148
- path.join(WORKSPACE_ROOT, "packages/server/dist/index.mjs"),
1149
- ).href,
1150
- "@hot-updater/supabase/edge": pathToFileURL(
1151
- path.join(runtimeRoot, "hot-updater-supabase-edge.ts"),
1152
- ).href,
1153
- hono: `npm:hono@${resolvePackageVersion("hono", {
1154
- searchFrom: path.join(WORKSPACE_ROOT, "plugins/supabase"),
1155
- })}`,
1156
- },
1157
- };
1158
-
1159
- await writeFile(
1160
- path.join(runtimeRoot, "hot-updater-supabase-edge.ts"),
1161
- `
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)};
1164
- `.trim(),
1165
- );
1166
- await writeFile(
1167
- path.join(runtimeRoot, "supabase/edge-functions/index.ts"),
1168
- transformedEntry,
1169
- );
1170
- await writeFile(
1171
- path.join(runtimeRoot, "import_map.json"),
1172
- JSON.stringify(importMap),
1173
- );
1174
- await writeFile(
1175
- path.join(runtimeRoot, "db-init/00-init.sql"),
1176
- await loadSupabaseInitSql(storageRepoPath),
1177
- );
1178
- await writeFile(
1179
- path.join(runtimeRoot, "docker-compose.yml"),
1180
- createComposeFile({ runtimeRoot, gatewayPort }),
1181
- );
1182
- await writeFile(path.join(runtimeRoot, "nginx.conf"), createNginxConfig());
1183
- };