@hot-updater/supabase 1.0.0-rc.18 → 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.
- package/dist/edge.cjs +1 -1
- package/dist/edge.d.cts +3 -3
- package/dist/edge.d.mts +3 -3
- package/dist/edge.mjs +1 -1
- package/dist/index.cjs +11 -12
- package/dist/index.d.cts +3 -4
- package/dist/index.d.mts +3 -4
- package/dist/index.mjs +2 -3
- package/dist/init/index.cjs +7593 -6
- package/dist/init/index.d.cts +97 -4
- package/dist/init/index.d.mts +97 -4
- package/dist/init/index.mjs +7578 -3
- package/dist/{plugins--10vxSpf.mjs → plugins-B8grmU79.mjs} +2 -3
- package/dist/{plugins-CcNpRRGq.cjs → plugins-DJUmZYvT.cjs} +5 -6
- package/dist/supabaseDatabase-DVb3-y2x.d.cts +726 -0
- package/dist/supabaseDatabase-DVb3-y2x.d.mts +726 -0
- package/package.json +9 -13
- package/dist/iac/index.cjs +0 -7680
- package/dist/iac/index.d.cts +0 -101
- package/dist/iac/index.d.mts +0 -101
- package/dist/iac/index.mjs +0 -7664
- package/dist/supabaseDatabase-C0-IshLi.d.cts +0 -436
- package/dist/supabaseDatabase-C0-IshLi.d.mts +0 -436
- package/supabase/edge-functions/runtime.docker.integration.spec.ts +0 -1301
- package/supabase/migrations.spec.ts +0 -287
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import { PGlite } from "@electric-sql/pglite";
|
|
5
|
-
import { createHotUpdater } from "@hot-updater/server";
|
|
6
|
-
import { toolingTargetOf } from "@hot-updater/server/database";
|
|
7
|
-
import {
|
|
8
|
-
createDatabasePluginApis,
|
|
9
|
-
createMigrator,
|
|
10
|
-
generateSchema,
|
|
11
|
-
} from "@hot-updater/server/db";
|
|
12
|
-
import { definePlugin, defineTable } from "@hot-updater/server/plugins";
|
|
13
|
-
import {
|
|
14
|
-
createInsightsModel,
|
|
15
|
-
insights,
|
|
16
|
-
} from "@hot-updater/server/plugins/insights";
|
|
17
|
-
import { insightsTestSuite } from "@hot-updater/server/plugins/insights/testing";
|
|
18
|
-
import {
|
|
19
|
-
setupDatabaseTestSuite,
|
|
20
|
-
startHttpTestServer,
|
|
21
|
-
} from "@hot-updater/test-utils";
|
|
22
|
-
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
23
|
-
|
|
24
|
-
import { plugins } from "../src/plugins";
|
|
25
|
-
import { supabaseDatabase } from "../src/supabaseDatabase";
|
|
26
|
-
import { toApplyStatement } from "../src/supabaseExecutor";
|
|
27
|
-
import { supabaseDatabase as supabaseToolingDatabase } from "../src/supabaseMigration";
|
|
28
|
-
import {
|
|
29
|
-
SUPABASE_APPLY_FUNCTION,
|
|
30
|
-
SUPABASE_SETTINGS_TABLE,
|
|
31
|
-
supabaseSchemaSql,
|
|
32
|
-
supabaseTableNames,
|
|
33
|
-
} from "../src/supabaseSchema";
|
|
34
|
-
|
|
35
|
-
const MIGRATIONS = path.resolve("plugins/supabase/supabase/migrations");
|
|
36
|
-
const MIGRATION = path.join(MIGRATIONS, "20260818000000_hot-updater_1.0.0.sql");
|
|
37
|
-
/** The managed server's tables: core's, and its plugins' (Insights and API keys). */
|
|
38
|
-
const managed = toolingTargetOf(plugins);
|
|
39
|
-
|
|
40
|
-
const state = vi.hoisted(() => ({ db: undefined as PGlite | undefined }));
|
|
41
|
-
|
|
42
|
-
/** Supabase's client, as PostgREST runs an RPC: one statement, as the service role. */
|
|
43
|
-
vi.mock("@supabase/supabase-js", () => ({
|
|
44
|
-
createClient: () => ({
|
|
45
|
-
rpc: async (name: string, args: { readonly p_statements: unknown }) => {
|
|
46
|
-
try {
|
|
47
|
-
const result = await state.db!.query<{ result: unknown }>(
|
|
48
|
-
`SELECT public.${name}($1::jsonb) AS result`,
|
|
49
|
-
[JSON.stringify(args.p_statements)],
|
|
50
|
-
);
|
|
51
|
-
return { data: result.rows[0]!.result, error: null };
|
|
52
|
-
} catch (error) {
|
|
53
|
-
const { code, message } = error as { code?: string; message: string };
|
|
54
|
-
return { data: null, error: { code, message } };
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
}),
|
|
58
|
-
}));
|
|
59
|
-
|
|
60
|
-
/** A Supabase-like database: its three roles, the migration, and the service role's grants. */
|
|
61
|
-
const createDatabase = async (before = "") => {
|
|
62
|
-
const db = new PGlite();
|
|
63
|
-
await db.exec(
|
|
64
|
-
"CREATE ROLE anon; CREATE ROLE authenticated; CREATE ROLE service_role BYPASSRLS;",
|
|
65
|
-
);
|
|
66
|
-
if (before) await db.exec(before);
|
|
67
|
-
await db.exec(await fs.readFile(MIGRATION, "utf8"));
|
|
68
|
-
await db.exec(
|
|
69
|
-
"GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role; SET ROLE service_role;",
|
|
70
|
-
);
|
|
71
|
-
return db;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const apply = (db: PGlite, sql: string) =>
|
|
75
|
-
db.query(`SELECT public.${SUPABASE_APPLY_FUNCTION}($1::jsonb)`, [
|
|
76
|
-
JSON.stringify([{ sql, params: [] }]),
|
|
77
|
-
]);
|
|
78
|
-
|
|
79
|
-
describe("Supabase schema", () => {
|
|
80
|
-
it("checks in exactly the generated migration as the only one", async () => {
|
|
81
|
-
if (process.env.HOT_UPDATER_UPDATE_SQL === "1") {
|
|
82
|
-
await fs.writeFile(MIGRATION, supabaseSchemaSql(managed));
|
|
83
|
-
}
|
|
84
|
-
const files = (await fs.readdir(MIGRATIONS)).filter((file) =>
|
|
85
|
-
file.endsWith(".sql"),
|
|
86
|
-
);
|
|
87
|
-
expect(files).toEqual([path.basename(MIGRATION)]);
|
|
88
|
-
// Regenerate with HOT_UPDATER_UPDATE_SQL=1 after the schema changes.
|
|
89
|
-
expect(await fs.readFile(MIGRATION, "utf8")).toBe(
|
|
90
|
-
supabaseSchemaSql(managed),
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("creates namespaced tables with row-level security beside a v0 schema it leaves alone", async () => {
|
|
95
|
-
const db = await createDatabase(`
|
|
96
|
-
CREATE TABLE public.channels (id text PRIMARY KEY, name text NOT NULL);
|
|
97
|
-
INSERT INTO public.channels VALUES ('legacy-channel', 'legacy');
|
|
98
|
-
`);
|
|
99
|
-
const tables = await db.query<{ name: string; secured: boolean }>(
|
|
100
|
-
"SELECT tablename AS name, rowsecurity AS secured FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
|
|
101
|
-
);
|
|
102
|
-
const ours = tables.rows.filter(({ name }) =>
|
|
103
|
-
name.startsWith("hot_updater_v1_"),
|
|
104
|
-
);
|
|
105
|
-
expect(ours.map(({ name }) => name)).toEqual(
|
|
106
|
-
supabaseTableNames(managed.schema).toSorted(),
|
|
107
|
-
);
|
|
108
|
-
expect(ours.every(({ secured }) => secured)).toBe(true);
|
|
109
|
-
await expect(
|
|
110
|
-
db.query("SELECT name FROM public.channels"),
|
|
111
|
-
).resolves.toMatchObject({ rows: [{ name: "legacy" }] });
|
|
112
|
-
const settings = await db.query<{ key: string }>(
|
|
113
|
-
`SELECT key FROM public."${SUPABASE_SETTINGS_TABLE}" ORDER BY key`,
|
|
114
|
-
);
|
|
115
|
-
expect(settings.rows.map(({ key }) => key)).toEqual([
|
|
116
|
-
"schema.apiKeys",
|
|
117
|
-
"schema.core",
|
|
118
|
-
"schema.engine",
|
|
119
|
-
"schema.insights",
|
|
120
|
-
]);
|
|
121
|
-
await db.close();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("lets only the service role run the apply RPC", async () => {
|
|
125
|
-
const db = await createDatabase();
|
|
126
|
-
const privilege = await db.query<Record<string, boolean>>(
|
|
127
|
-
["anon", "authenticated", "service_role"]
|
|
128
|
-
.map(
|
|
129
|
-
(role) =>
|
|
130
|
-
`has_function_privilege('${role}', 'public.${SUPABASE_APPLY_FUNCTION}(jsonb)', 'EXECUTE') AS ${role}`,
|
|
131
|
-
)
|
|
132
|
-
.join(", ")
|
|
133
|
-
.replace(/^/u, "SELECT "),
|
|
134
|
-
);
|
|
135
|
-
expect(privilege.rows).toEqual([
|
|
136
|
-
{ anon: false, authenticated: false, service_role: true },
|
|
137
|
-
]);
|
|
138
|
-
await db.close();
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("refuses anything but the SQL core's statements on Hot Updater tables", async () => {
|
|
142
|
-
const db = await createDatabase();
|
|
143
|
-
const bundles = '"hot_updater_v1_bundles"';
|
|
144
|
-
for (const sql of [
|
|
145
|
-
"SELECT * FROM pg_authid",
|
|
146
|
-
'SELECT * FROM "pg_authid"',
|
|
147
|
-
`SELECT pg_sleep(0) FROM ${bundles}`,
|
|
148
|
-
`SELECT "pg_sleep"(0) FROM ${bundles}`,
|
|
149
|
-
`SELECT 'text' FROM ${bundles}`,
|
|
150
|
-
`SELECT * FROM ${bundles} -- comment`,
|
|
151
|
-
`SELECT * FROM ${bundles}; DELETE FROM ${bundles}`,
|
|
152
|
-
`DROP TABLE ${bundles}`,
|
|
153
|
-
`SELECT * FROM ${bundles} UNION SELECT * FROM ${bundles}`,
|
|
154
|
-
]) {
|
|
155
|
-
await expect(apply(db, sql), sql).rejects.toMatchObject({
|
|
156
|
-
code: "42501",
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
const allowed = toApplyStatement({
|
|
160
|
-
sql: `SELECT * FROM ${bundles} WHERE ("id" = $1) OR ("id" = $2) ORDER BY "id" DESC LIMIT 5`,
|
|
161
|
-
params: ["a", "b"],
|
|
162
|
-
});
|
|
163
|
-
await expect(
|
|
164
|
-
db.query(`SELECT public.${SUPABASE_APPLY_FUNCTION}($1::jsonb) AS r`, [
|
|
165
|
-
JSON.stringify([allowed]),
|
|
166
|
-
]),
|
|
167
|
-
).resolves.toMatchObject({ rows: [{ r: [{ rows: [], changes: 0 }] }] });
|
|
168
|
-
await db.close();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("generates a migration that adds a server's plugin tables to the apply RPC", async () => {
|
|
172
|
-
const notes = definePlugin({
|
|
173
|
-
id: "notes",
|
|
174
|
-
schemaVersion: "1",
|
|
175
|
-
schema: {
|
|
176
|
-
notes: defineTable(
|
|
177
|
-
{ id: { type: "string" }, text: { type: "string" } },
|
|
178
|
-
{ key: ["id"] },
|
|
179
|
-
),
|
|
180
|
-
},
|
|
181
|
-
init: ({ db }) => ({
|
|
182
|
-
api: {
|
|
183
|
-
add: (id: string, text: string) =>
|
|
184
|
-
db.transaction(async (tx) => {
|
|
185
|
-
tx.create("notes", { id, text });
|
|
186
|
-
}),
|
|
187
|
-
read: (id: string) => db.findOne("notes", { id }),
|
|
188
|
-
},
|
|
189
|
-
}),
|
|
190
|
-
});
|
|
191
|
-
const hotUpdater = createHotUpdater({
|
|
192
|
-
database: supabaseToolingDatabase({
|
|
193
|
-
supabaseUrl: "https://project.supabase.co",
|
|
194
|
-
supabaseServiceRoleKey: "service-role-key",
|
|
195
|
-
}),
|
|
196
|
-
plugins: [notes],
|
|
197
|
-
clientAccess: "public",
|
|
198
|
-
});
|
|
199
|
-
// The apply RPC runs no DDL, so the schema comes from migration files.
|
|
200
|
-
expect(() => createMigrator(hotUpdater)).toThrow(
|
|
201
|
-
"run `hot-updater db generate`",
|
|
202
|
-
);
|
|
203
|
-
const migration = generateSchema(hotUpdater, "latest");
|
|
204
|
-
expect(migration.path).toMatch(
|
|
205
|
-
/^supabase\/migrations\/\d{14}_hot-updater\.sql$/u,
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
const db = await createDatabase();
|
|
209
|
-
state.db = db;
|
|
210
|
-
try {
|
|
211
|
-
await expect(hotUpdater.api.notes.read("n1")).rejects.toThrow(
|
|
212
|
-
'"schema.notes" for supabaseDatabase is missing',
|
|
213
|
-
);
|
|
214
|
-
await db.exec(
|
|
215
|
-
`RESET ROLE; ${migration.code} GRANT ALL ON ALL TABLES IN SCHEMA public TO service_role; SET ROLE service_role;`,
|
|
216
|
-
);
|
|
217
|
-
await hotUpdater.api.notes.add("n1", "hello");
|
|
218
|
-
await expect(hotUpdater.api.notes.read("n1")).resolves.toEqual({
|
|
219
|
-
id: "n1",
|
|
220
|
-
text: "hello",
|
|
221
|
-
});
|
|
222
|
-
const secured = await db.query<{ secured: boolean }>(
|
|
223
|
-
"SELECT rowsecurity AS secured FROM pg_tables WHERE tablename = 'hot_updater_v1_notes_notes'",
|
|
224
|
-
);
|
|
225
|
-
expect(secured.rows).toEqual([{ secured: true }]);
|
|
226
|
-
} finally {
|
|
227
|
-
state.db = undefined;
|
|
228
|
-
await db.close();
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
describe("toApplyStatement", () => {
|
|
234
|
-
it("reads each value from the jsonb array, typed, and writes a null inline", () => {
|
|
235
|
-
expect(
|
|
236
|
-
toApplyStatement({
|
|
237
|
-
sql: 'UPDATE "t" SET "a" = $1, "b" = $2, "c" = $3, "d" = $4::jsonb, "e" = $5 WHERE "id" = $6',
|
|
238
|
-
params: [1, 1.5, true, '{"x":1}', null, "id"],
|
|
239
|
-
}),
|
|
240
|
-
).toEqual({
|
|
241
|
-
sql: 'UPDATE "t" SET "a" = ($1->>0)::bigint, "b" = ($1->>1)::double precision, "c" = ($1->>2)::boolean, "d" = ($1->>3)::jsonb, "e" = NULL WHERE "id" = ($1->>4)',
|
|
242
|
-
params: [1, 1.5, true, '{"x":1}', "id"],
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
describe("supabaseDatabase over the apply RPC", () => {
|
|
248
|
-
beforeAll(async () => {
|
|
249
|
-
state.db = await createDatabase();
|
|
250
|
-
});
|
|
251
|
-
afterAll(async () => {
|
|
252
|
-
await state.db?.close();
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
setupDatabaseTestSuite({
|
|
256
|
-
createHttpClient: (options) =>
|
|
257
|
-
startHttpTestServer(
|
|
258
|
-
createHotUpdater({
|
|
259
|
-
...options,
|
|
260
|
-
plugins: [insights()],
|
|
261
|
-
clientAccess: "public",
|
|
262
|
-
}).handlers,
|
|
263
|
-
),
|
|
264
|
-
plugins: [
|
|
265
|
-
insightsTestSuite({
|
|
266
|
-
createModel: (database) =>
|
|
267
|
-
createInsightsModel(
|
|
268
|
-
createDatabasePluginApis(database, [insights()]).insights,
|
|
269
|
-
),
|
|
270
|
-
}),
|
|
271
|
-
],
|
|
272
|
-
name: "supabaseDatabase (PGlite, apply RPC)",
|
|
273
|
-
migrate: () => undefined,
|
|
274
|
-
createDatabase: () =>
|
|
275
|
-
supabaseDatabase({
|
|
276
|
-
supabaseUrl: "https://project.supabase.co",
|
|
277
|
-
supabaseServiceRoleKey: "service-role-key",
|
|
278
|
-
}),
|
|
279
|
-
reset: async () => {
|
|
280
|
-
const tables = supabaseTableNames(managed.schema)
|
|
281
|
-
.filter((table) => table !== SUPABASE_SETTINGS_TABLE)
|
|
282
|
-
.map((table) => `public."${table}"`);
|
|
283
|
-
await state.db!.exec(`TRUNCATE ${tables.join(", ")} CASCADE`);
|
|
284
|
-
},
|
|
285
|
-
dispose: () => undefined,
|
|
286
|
-
});
|
|
287
|
-
});
|