@stardeck-customer-apps/testing 0.18.0 → 0.20.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.
- package/SKILL.md +24 -20
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +253 -48
- package/dist/index.mjs +239 -24
- package/dist/setup.js +8 -0
- package/dist/setup.mjs +8 -0
- package/package.json +1 -1
package/SKILL.md
CHANGED
|
@@ -293,32 +293,32 @@ expect(app.edge.latestDisplay()?.action).toBe("show");
|
|
|
293
293
|
- `schemaSql` — inline DDL instead of a file.
|
|
294
294
|
- `seed(db)` — seed rows; re-runs on every `reset()`.
|
|
295
295
|
- `allowNetwork` — permit non-Stardeck outbound fetches (off by default).
|
|
296
|
-
- `app.reset()` —
|
|
297
|
-
Call in `beforeEach`.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
296
|
+
- `app.reset()` — restore the database to its state right after schema + seed,
|
|
297
|
+
and clear inbox/sessions/simulator state. Call in `beforeEach`.
|
|
298
|
+
Since testing SDK **0.20.0** it restores the rows the schema script inserted
|
|
299
|
+
and every sequence and identity value without firing app triggers, then
|
|
300
|
+
re-runs the seed, in about a fifth of the time of the old schema rebuild. If a
|
|
301
|
+
test ran DDL (CREATE, ALTER, DROP), the next reset rebuilds the schema
|
|
302
|
+
instead, so tests never need to choose a mode. DDL in the seed stays on the
|
|
303
|
+
fast path when it can run twice (`CREATE TABLE IF NOT EXISTS`); a seed that
|
|
304
|
+
fails on its second run makes every reset a rebuild.
|
|
305
|
+
`app.reset({ mode: "schema" })` forces the full rebuild; `mode: "data"` is an
|
|
306
|
+
older spelling of the default.
|
|
304
307
|
|
|
305
308
|
```ts
|
|
306
|
-
|
|
307
|
-
beforeEach(() => app.reset({ mode: "data" }));
|
|
309
|
+
beforeEach(() => app.reset());
|
|
308
310
|
```
|
|
309
311
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
Data mode refuses foreign keys requiring a cross-schema cascade. Database and
|
|
317
|
-
seed errors reject the reset; fix them before running the next test.
|
|
312
|
+
Reset never
|
|
313
|
+
cascades into tables outside `public`: a foreign key from another schema into
|
|
314
|
+
a `public` table rejects the reset with Postgres error `0A000`.
|
|
315
|
+
The first `createTestApp` for a schema saves a database snapshot under
|
|
316
|
+
`node_modules/.cache/stardeck-testing/`; later test files boot from it.
|
|
317
|
+
Deleting that directory is always safe.
|
|
318
318
|
|
|
319
319
|
Do not use `it.concurrent` or `test.concurrent` with a shared test app: user,
|
|
320
320
|
database, and simulator state are shared within the file. Keep file isolation
|
|
321
|
-
enabled and let Vitest parallelize files.
|
|
321
|
+
enabled and let Vitest parallelize files. Reset does not change workers
|
|
322
322
|
or the platform's test-run deadline.
|
|
323
323
|
|
|
324
324
|
- `app.asUser(partial?)` — sign in a test user; `getSession()` in app code
|
|
@@ -371,7 +371,11 @@ externalId })` helper models a trusted platform/channel link, and
|
|
|
371
371
|
back `refused` / `owned_elsewhere`. That refusal is the one a leaked bearer
|
|
372
372
|
link runs into, so test it. A profile that holds an email or phone is refused
|
|
373
373
|
at `create` with a 409 (send an `invite`); one that gains a contact before
|
|
374
|
-
the link is used completes as `refused` / `protected
|
|
374
|
+
the link is used completes as `refused` / `protected`. The same holds for a
|
|
375
|
+
profile another one was merged into (`app.identities.merge(other, target)`):
|
|
376
|
+
`create` is a 409 and an outstanding link completes as `refused` /
|
|
377
|
+
`protected`, because a bearer link hands over one visit's profile, never
|
|
378
|
+
history staff folded into it. A link still
|
|
375
379
|
outstanding when `.enableBearerLinks(false)` runs completes as `refused` /
|
|
376
380
|
`expired`. People seeded with only a `dashboard_user` link (team members) are
|
|
377
381
|
left out of `identities.list()` and `search()`, as on the platform.
|
package/dist/index.d.mts
CHANGED
|
@@ -633,10 +633,10 @@ interface TestApp {
|
|
|
633
633
|
*/
|
|
634
634
|
issueSession(user?: Partial<TestUser>): SessionTokens;
|
|
635
635
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
636
|
+
* Restore the rows and sequence values the schema script left, re-run the
|
|
637
|
+
* seed, and clear simulator/user state. Any DDL since the last reset triggers a full
|
|
638
|
+
* schema rebuild instead. `mode: "schema"` forces the rebuild; `mode: "data"`
|
|
639
|
+
* is an older spelling of the default.
|
|
640
640
|
*/
|
|
641
641
|
reset(options?: {
|
|
642
642
|
mode: "schema" | "data";
|
package/dist/index.d.ts
CHANGED
|
@@ -633,10 +633,10 @@ interface TestApp {
|
|
|
633
633
|
*/
|
|
634
634
|
issueSession(user?: Partial<TestUser>): SessionTokens;
|
|
635
635
|
/**
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
636
|
+
* Restore the rows and sequence values the schema script left, re-run the
|
|
637
|
+
* seed, and clear simulator/user state. Any DDL since the last reset triggers a full
|
|
638
|
+
* schema rebuild instead. `mode: "schema"` forces the rebuild; `mode: "data"`
|
|
639
|
+
* is an older spelling of the default.
|
|
640
640
|
*/
|
|
641
641
|
reset(options?: {
|
|
642
642
|
mode: "schema" | "data";
|
package/dist/index.js
CHANGED
|
@@ -48,9 +48,9 @@ __export(index_exports, {
|
|
|
48
48
|
module.exports = __toCommonJS(index_exports);
|
|
49
49
|
|
|
50
50
|
// src/test-app.ts
|
|
51
|
-
var
|
|
52
|
-
var
|
|
53
|
-
var
|
|
51
|
+
var import_node_crypto9 = __toESM(require("crypto"));
|
|
52
|
+
var import_node_fs2 = require("fs");
|
|
53
|
+
var import_node_path2 = require("path");
|
|
54
54
|
|
|
55
55
|
// src/pglite/database.ts
|
|
56
56
|
var import_pglite = require("@electric-sql/pglite");
|
|
@@ -58,10 +58,10 @@ var import_citext = require("@electric-sql/pglite/contrib/citext");
|
|
|
58
58
|
var import_pg_trgm = require("@electric-sql/pglite/contrib/pg_trgm");
|
|
59
59
|
var import_pgcrypto = require("@electric-sql/pglite/contrib/pgcrypto");
|
|
60
60
|
var import_uuid_ossp = require("@electric-sql/pglite/contrib/uuid_ossp");
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
});
|
|
61
|
+
var EXTENSIONS = { pg_trgm: import_pg_trgm.pg_trgm, pgcrypto: import_pgcrypto.pgcrypto, uuid_ossp: import_uuid_ossp.uuid_ossp, citext: import_citext.citext };
|
|
62
|
+
var PGLITE_EXTENSION_NAMES = Object.keys(EXTENSIONS);
|
|
63
|
+
async function createPgliteDatabase(options = {}) {
|
|
64
|
+
const db = new import_pglite.PGlite({ extensions: EXTENSIONS, ...options });
|
|
65
65
|
await db.waitReady;
|
|
66
66
|
return db;
|
|
67
67
|
}
|
|
@@ -82,10 +82,6 @@ async function listPublicTables(db) {
|
|
|
82
82
|
`);
|
|
83
83
|
return rows.map(({ name }) => name);
|
|
84
84
|
}
|
|
85
|
-
async function truncateAllTables(db) {
|
|
86
|
-
const tables = await listPublicTables(db);
|
|
87
|
-
if (tables.length) await db.exec(`TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY`);
|
|
88
|
-
}
|
|
89
85
|
var identityParsers = null;
|
|
90
86
|
function getIdentityParsers() {
|
|
91
87
|
if (!identityParsers) {
|
|
@@ -98,6 +94,198 @@ function getIdentityParsers() {
|
|
|
98
94
|
return identityParsers;
|
|
99
95
|
}
|
|
100
96
|
|
|
97
|
+
// src/pglite/baseline.ts
|
|
98
|
+
var HARNESS_SCHEMA = "stardeck_test";
|
|
99
|
+
var SCHEMA_CHANGES = `${HARNESS_SCHEMA}.schema_changes`;
|
|
100
|
+
var SCHEMA_CHANGE_TRACKING_SQL = `
|
|
101
|
+
CREATE SCHEMA IF NOT EXISTS ${HARNESS_SCHEMA};
|
|
102
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA_CHANGES} ();
|
|
103
|
+
CREATE OR REPLACE FUNCTION ${HARNESS_SCHEMA}.mark_schema_changed() RETURNS event_trigger
|
|
104
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog
|
|
105
|
+
AS $$ BEGIN INSERT INTO ${SCHEMA_CHANGES} DEFAULT VALUES; END $$;
|
|
106
|
+
DROP EVENT TRIGGER IF EXISTS stardeck_test_ddl;
|
|
107
|
+
DROP EVENT TRIGGER IF EXISTS stardeck_test_drop;
|
|
108
|
+
CREATE EVENT TRIGGER stardeck_test_ddl ON ddl_command_end
|
|
109
|
+
EXECUTE FUNCTION ${HARNESS_SCHEMA}.mark_schema_changed();
|
|
110
|
+
CREATE EVENT TRIGGER stardeck_test_drop ON sql_drop
|
|
111
|
+
EXECUTE FUNCTION ${HARNESS_SCHEMA}.mark_schema_changed();
|
|
112
|
+
ALTER EVENT TRIGGER stardeck_test_ddl ENABLE ALWAYS;
|
|
113
|
+
ALTER EVENT TRIGGER stardeck_test_drop ENABLE ALWAYS;
|
|
114
|
+
`;
|
|
115
|
+
async function schemaChangedSinceBaseline(db) {
|
|
116
|
+
const { rows } = await db.query(
|
|
117
|
+
`SELECT EXISTS (SELECT FROM ${SCHEMA_CHANGES}) AS changed`
|
|
118
|
+
);
|
|
119
|
+
return rows[0]?.changed === true;
|
|
120
|
+
}
|
|
121
|
+
async function acceptSchemaChanges(db) {
|
|
122
|
+
await db.exec(`DELETE FROM ${SCHEMA_CHANGES};`);
|
|
123
|
+
}
|
|
124
|
+
async function sequenceStates(db) {
|
|
125
|
+
const { rows: sequences } = await db.query(`
|
|
126
|
+
SELECT format('%I.%I', n.nspname, c.relname) AS name
|
|
127
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
128
|
+
WHERE n.nspname = 'public' AND c.relkind = 'S'
|
|
129
|
+
`);
|
|
130
|
+
if (!sequences.length) return null;
|
|
131
|
+
const { rows } = await db.query(
|
|
132
|
+
sequences.map(
|
|
133
|
+
({ name }) => `SELECT ${quoteLiteral(name)} AS name, last_value::text, is_called FROM ${name}`
|
|
134
|
+
).join(" UNION ALL ")
|
|
135
|
+
);
|
|
136
|
+
return `SELECT ${rows.map((s) => `setval(${quoteLiteral(s.name)}, ${s.last_value}, ${s.is_called})`).join(", ")};`;
|
|
137
|
+
}
|
|
138
|
+
function quoteLiteral(value) {
|
|
139
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
140
|
+
}
|
|
141
|
+
async function captureBaseline(db) {
|
|
142
|
+
const { rows: unsilenceable } = await db.query(`
|
|
143
|
+
SELECT FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
|
|
144
|
+
WHERE c.relnamespace = 'public'::regnamespace AND NOT t.tgisinternal
|
|
145
|
+
AND t.tgenabled IN ('A', 'R')`);
|
|
146
|
+
if (unsilenceable.length) return null;
|
|
147
|
+
const tables = await listPublicTables(db);
|
|
148
|
+
const statements = [];
|
|
149
|
+
await db.exec(`DROP SCHEMA IF EXISTS ${HARNESS_SCHEMA}_baseline CASCADE;
|
|
150
|
+
CREATE SCHEMA ${HARNESS_SCHEMA}_baseline;`);
|
|
151
|
+
if (tables.length) {
|
|
152
|
+
const { rows: populated } = await db.query(
|
|
153
|
+
tables.map(
|
|
154
|
+
(table, index) => `SELECT ${index} AS index, (
|
|
155
|
+
SELECT string_agg(quote_ident(attname), ', ' ORDER BY attnum) FROM pg_attribute
|
|
156
|
+
WHERE attrelid = ${quoteLiteral(table)}::regclass AND attnum > 0
|
|
157
|
+
AND NOT attisdropped AND attgenerated = ''
|
|
158
|
+
) AS columns WHERE EXISTS (SELECT 1 FROM ONLY ${table})`
|
|
159
|
+
).join(" UNION ALL ")
|
|
160
|
+
);
|
|
161
|
+
for (const { index, columns } of populated) {
|
|
162
|
+
const copy = `${HARNESS_SCHEMA}_baseline.t${index}`;
|
|
163
|
+
await db.exec(`CREATE TABLE ${copy} AS SELECT ${columns} FROM ONLY ${tables[index]};`);
|
|
164
|
+
statements.push(
|
|
165
|
+
`INSERT INTO ${tables[index]} (${columns}) OVERRIDING SYSTEM VALUE SELECT ${columns} FROM ${copy};`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const setvals = await sequenceStates(db);
|
|
170
|
+
if (setvals) statements.push(setvals);
|
|
171
|
+
return { restoreSql: statements.join("\n") };
|
|
172
|
+
}
|
|
173
|
+
async function restoreBaseline(db, baseline) {
|
|
174
|
+
const tables = await listPublicTables(db);
|
|
175
|
+
const truncate = tables.length ? `TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY;` : "";
|
|
176
|
+
try {
|
|
177
|
+
await db.exec(`BEGIN; SET LOCAL session_replication_role = replica;
|
|
178
|
+
${truncate}
|
|
179
|
+
SELECT setval(format('%I.%I', schemaname, sequencename), start_value, false)
|
|
180
|
+
FROM pg_sequences WHERE schemaname = 'public';
|
|
181
|
+
${baseline.restoreSql}
|
|
182
|
+
COMMIT;`);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
await db.exec("ROLLBACK;").catch(() => {
|
|
185
|
+
});
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/pglite/snapshot.ts
|
|
191
|
+
var import_node_crypto = require("crypto");
|
|
192
|
+
var import_node_fs = require("fs");
|
|
193
|
+
var import_node_path = require("path");
|
|
194
|
+
var SNAPSHOT_FORMAT = 2;
|
|
195
|
+
var MAX_SNAPSHOTS = 8;
|
|
196
|
+
function snapshotKey(schemaSql) {
|
|
197
|
+
const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
198
|
+
return (0, import_node_crypto.createHash)("sha256").update(JSON.stringify({ format: SNAPSHOT_FORMAT, extensions: PGLITE_EXTENSION_NAMES, day })).update(SCHEMA_CHANGE_TRACKING_SQL).update(schemaSql).digest("hex").slice(0, 32);
|
|
199
|
+
}
|
|
200
|
+
function snapshotPath(schemaSql) {
|
|
201
|
+
return (0, import_node_path.resolve)(
|
|
202
|
+
process.cwd(),
|
|
203
|
+
"node_modules/.cache/stardeck-testing",
|
|
204
|
+
`${snapshotKey(schemaSql)}.tar`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
var CUSTOM_SETTING = /\bset_config\s*\(\s*(?:'([^']*)'|(\$\w*\$)([\s\S]*?)\2)|\bSET\s+(?:SESSION\s+)?(?:"([^"]*)"|([a-z_][\w$]*\.[\w$.]+))/gi;
|
|
208
|
+
async function sessionSettings(db, schemaSql) {
|
|
209
|
+
const custom = [...schemaSql.matchAll(CUSTOM_SETTING)].map(
|
|
210
|
+
([, quoted, , dollarQuoted, identifier, bare]) => quoted ?? dollarQuoted ?? identifier ?? bare
|
|
211
|
+
).filter((name) => name?.includes("."));
|
|
212
|
+
const { rows } = await db.query(
|
|
213
|
+
`SELECT string_agg(name || '=' || setting, ',' ORDER BY name) AS settings FROM (
|
|
214
|
+
SELECT name, setting FROM pg_settings WHERE source = 'session'
|
|
215
|
+
UNION ALL
|
|
216
|
+
SELECT name, current_setting(name, true) FROM unnest($1::text[]) AS name
|
|
217
|
+
WHERE current_setting(name, true) IS NOT NULL
|
|
218
|
+
) settings`,
|
|
219
|
+
[custom]
|
|
220
|
+
);
|
|
221
|
+
return rows[0]?.settings ?? "";
|
|
222
|
+
}
|
|
223
|
+
async function buildSchemaDatabase(schemaSql) {
|
|
224
|
+
const db = await createPgliteDatabase();
|
|
225
|
+
try {
|
|
226
|
+
await db.exec(SCHEMA_CHANGE_TRACKING_SQL);
|
|
227
|
+
const settingsBefore = await sessionSettings(db, schemaSql);
|
|
228
|
+
await applySchema(db, schemaSql);
|
|
229
|
+
const { rows: sessionBound } = await db.query(
|
|
230
|
+
`SELECT FROM pg_class WHERE relpersistence IN ('u', 't')`
|
|
231
|
+
);
|
|
232
|
+
const cacheable = !sessionBound.length && await sessionSettings(db, schemaSql) === settingsBefore;
|
|
233
|
+
await db.exec(`COMMENT ON SCHEMA stardeck_test IS '${snapshotKey(schemaSql)}'`);
|
|
234
|
+
await db.exec("CHECKPOINT");
|
|
235
|
+
return { db, cacheable };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
await db.close().catch(() => {
|
|
238
|
+
});
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function pruneSnapshots(dir) {
|
|
243
|
+
const snapshots = (0, import_node_fs.readdirSync)(dir).filter((name) => name.endsWith(".tar")).map((name) => ({ path: (0, import_node_path.join)(dir, name), mtime: (0, import_node_fs.statSync)((0, import_node_path.join)(dir, name)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
|
|
244
|
+
for (const { path } of snapshots.slice(MAX_SNAPSHOTS)) (0, import_node_fs.rmSync)(path, { force: true });
|
|
245
|
+
}
|
|
246
|
+
async function writeSnapshot(path, db) {
|
|
247
|
+
try {
|
|
248
|
+
const data = Buffer.from(await (await db.dumpDataDir("none")).arrayBuffer());
|
|
249
|
+
(0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
|
|
250
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
251
|
+
(0, import_node_fs.writeFileSync)(tmp, data);
|
|
252
|
+
(0, import_node_fs.renameSync)(tmp, path);
|
|
253
|
+
pruneSnapshots((0, import_node_path.dirname)(path));
|
|
254
|
+
} catch (error) {
|
|
255
|
+
console.warn(`[stardeck-testing] could not cache schema snapshot at ${path}:`, error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async function loadSnapshot(path, key) {
|
|
259
|
+
let db = null;
|
|
260
|
+
try {
|
|
261
|
+
db = await createPgliteDatabase({ loadDataDir: new Blob([(0, import_node_fs.readFileSync)(path)]) });
|
|
262
|
+
const { rows } = await db.query(
|
|
263
|
+
`SELECT obj_description(to_regnamespace('stardeck_test'), 'pg_namespace') AS key`
|
|
264
|
+
);
|
|
265
|
+
if (rows[0]?.key === key) {
|
|
266
|
+
const now3 = /* @__PURE__ */ new Date();
|
|
267
|
+
(0, import_node_fs.utimesSync)(path, now3, now3);
|
|
268
|
+
return db;
|
|
269
|
+
}
|
|
270
|
+
console.warn(`[stardeck-testing] rebuilding schema snapshot ${path}: key mismatch`);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.warn(`[stardeck-testing] rebuilding unreadable schema snapshot ${path}:`, error);
|
|
273
|
+
}
|
|
274
|
+
await db?.close().catch(() => {
|
|
275
|
+
});
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
async function bootWithSchema(schemaSql) {
|
|
279
|
+
const path = snapshotPath(schemaSql);
|
|
280
|
+
if ((0, import_node_fs.existsSync)(path)) {
|
|
281
|
+
const loaded = await loadSnapshot(path, snapshotKey(schemaSql));
|
|
282
|
+
if (loaded) return loaded;
|
|
283
|
+
}
|
|
284
|
+
const { db, cacheable } = await buildSchemaDatabase(schemaSql);
|
|
285
|
+
if (cacheable) await writeSnapshot(path, db);
|
|
286
|
+
return db;
|
|
287
|
+
}
|
|
288
|
+
|
|
101
289
|
// src/global-singleton.ts
|
|
102
290
|
function globalSingleton(key, create) {
|
|
103
291
|
const symbol = /* @__PURE__ */ Symbol.for(`stardeck-testing.${key}`);
|
|
@@ -189,7 +377,7 @@ function requireDb() {
|
|
|
189
377
|
}
|
|
190
378
|
|
|
191
379
|
// src/simulator/hmac.ts
|
|
192
|
-
var
|
|
380
|
+
var import_node_crypto2 = __toESM(require("crypto"));
|
|
193
381
|
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
194
382
|
function isNonEmptyString(value) {
|
|
195
383
|
return typeof value === "string" && value.length > 0;
|
|
@@ -210,10 +398,10 @@ function verifyDeploymentAuthHeader(secret, header) {
|
|
|
210
398
|
} catch {
|
|
211
399
|
return null;
|
|
212
400
|
}
|
|
213
|
-
const expected =
|
|
401
|
+
const expected = import_node_crypto2.default.createHmac("sha256", secret).update(payloadJson).digest("hex");
|
|
214
402
|
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
215
403
|
const actualBuf = Buffer.from(signature, "utf-8");
|
|
216
|
-
if (expectedBuf.length !== actualBuf.length || !
|
|
404
|
+
if (expectedBuf.length !== actualBuf.length || !import_node_crypto2.default.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
217
405
|
return null;
|
|
218
406
|
}
|
|
219
407
|
let parsed;
|
|
@@ -630,7 +818,7 @@ async function handleNeonSql(db, request) {
|
|
|
630
818
|
}
|
|
631
819
|
|
|
632
820
|
// src/simulator/auth.ts
|
|
633
|
-
var
|
|
821
|
+
var import_node_crypto3 = __toESM(require("crypto"));
|
|
634
822
|
function handleAuthVerify(request) {
|
|
635
823
|
const authHeader = request.headers.get("Authorization");
|
|
636
824
|
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
@@ -647,7 +835,7 @@ function handleAuthRefresh(request) {
|
|
|
647
835
|
if (!user || !refreshToken) {
|
|
648
836
|
return json({ error: "invalid_grant" }, 401);
|
|
649
837
|
}
|
|
650
|
-
const accessToken = `test-access-${
|
|
838
|
+
const accessToken = `test-access-${import_node_crypto3.default.randomUUID()}`;
|
|
651
839
|
state.sessions.set(accessToken, user);
|
|
652
840
|
return json({
|
|
653
841
|
access_token: accessToken,
|
|
@@ -715,7 +903,7 @@ function createInbox() {
|
|
|
715
903
|
}
|
|
716
904
|
|
|
717
905
|
// src/simulator/identities.ts
|
|
718
|
-
var
|
|
906
|
+
var import_node_crypto4 = __toESM(require("crypto"));
|
|
719
907
|
var ATTACH_LINK_KINDS = /* @__PURE__ */ new Set(["line", "facebook", "instagram", "email", "phone"]);
|
|
720
908
|
var TRUSTED_LINK_KINDS = /* @__PURE__ */ new Set([
|
|
721
909
|
"line",
|
|
@@ -828,7 +1016,7 @@ function normalizedLinkKey(link) {
|
|
|
828
1016
|
function createPerson(displayName) {
|
|
829
1017
|
const createdAt = now();
|
|
830
1018
|
const identity = {
|
|
831
|
-
id:
|
|
1019
|
+
id: import_node_crypto4.default.randomUUID(),
|
|
832
1020
|
type: "person",
|
|
833
1021
|
parentId: null,
|
|
834
1022
|
displayName,
|
|
@@ -844,7 +1032,7 @@ function createPerson(displayName) {
|
|
|
844
1032
|
}
|
|
845
1033
|
function createUnverifiedLink(identityId, kind, externalId) {
|
|
846
1034
|
const link = {
|
|
847
|
-
id:
|
|
1035
|
+
id: import_node_crypto4.default.randomUUID(),
|
|
848
1036
|
identityId,
|
|
849
1037
|
kind,
|
|
850
1038
|
externalId,
|
|
@@ -867,6 +1055,9 @@ function isCurrentTeamMember(identityId) {
|
|
|
867
1055
|
function hasRecordedContact(identityId) {
|
|
868
1056
|
return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
|
|
869
1057
|
}
|
|
1058
|
+
function hasAbsorbedIdentity(identityId) {
|
|
1059
|
+
return [...state.identities.values()].some((identity) => identity.mergedIntoId === identityId);
|
|
1060
|
+
}
|
|
870
1061
|
function handleList(request) {
|
|
871
1062
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
872
1063
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
@@ -892,7 +1083,7 @@ async function handleCreate(request) {
|
|
|
892
1083
|
}
|
|
893
1084
|
const createdAt = now();
|
|
894
1085
|
const identity = {
|
|
895
|
-
id:
|
|
1086
|
+
id: import_node_crypto4.default.randomUUID(),
|
|
896
1087
|
type,
|
|
897
1088
|
parentId,
|
|
898
1089
|
displayName: body.displayName ?? null,
|
|
@@ -1170,8 +1361,8 @@ function claimableIdentity(identityId) {
|
|
|
1170
1361
|
return identity;
|
|
1171
1362
|
}
|
|
1172
1363
|
function mintClaim(params) {
|
|
1173
|
-
const claimId =
|
|
1174
|
-
const handle =
|
|
1364
|
+
const claimId = import_node_crypto4.default.randomUUID();
|
|
1365
|
+
const handle = import_node_crypto4.default.randomBytes(32).toString("base64url");
|
|
1175
1366
|
const claim = {
|
|
1176
1367
|
claimId,
|
|
1177
1368
|
flow: params.flow,
|
|
@@ -1217,6 +1408,9 @@ function createBearerClaim(body, returnTo) {
|
|
|
1217
1408
|
if (hasRecordedContact(identity.id)) {
|
|
1218
1409
|
return failure("identity has an email or phone on record; send an invite claim instead", 409);
|
|
1219
1410
|
}
|
|
1411
|
+
if (hasAbsorbedIdentity(identity.id)) {
|
|
1412
|
+
return failure("another profile was merged into this identity; reception must connect it", 409);
|
|
1413
|
+
}
|
|
1220
1414
|
return mintClaim({
|
|
1221
1415
|
flow: "bearer",
|
|
1222
1416
|
identityId: identity.id,
|
|
@@ -1463,7 +1657,7 @@ function completeBearerClaim(claim, clerkUserId) {
|
|
|
1463
1657
|
claim.state = "invalidated";
|
|
1464
1658
|
return claim;
|
|
1465
1659
|
}
|
|
1466
|
-
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1660
|
+
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) || hasAbsorbedIdentity(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1467
1661
|
if (refusal) {
|
|
1468
1662
|
claim.state = "refused";
|
|
1469
1663
|
claim.refusalReason = refusal;
|
|
@@ -1522,7 +1716,7 @@ function createDirectory() {
|
|
|
1522
1716
|
}
|
|
1523
1717
|
|
|
1524
1718
|
// src/simulator/payments.ts
|
|
1525
|
-
var
|
|
1719
|
+
var import_node_crypto6 = __toESM(require("crypto"));
|
|
1526
1720
|
|
|
1527
1721
|
// src/next/headers-shim.ts
|
|
1528
1722
|
var import_node_async_hooks = require("async_hooks");
|
|
@@ -1605,7 +1799,7 @@ function decodeNextRedirect(error) {
|
|
|
1605
1799
|
}
|
|
1606
1800
|
|
|
1607
1801
|
// src/simulator/webhook-signing.ts
|
|
1608
|
-
var
|
|
1802
|
+
var import_node_crypto5 = __toESM(require("crypto"));
|
|
1609
1803
|
function signEventDelivery(secret, context, rawBody) {
|
|
1610
1804
|
const payload = {
|
|
1611
1805
|
type: "deployment-request",
|
|
@@ -1613,11 +1807,11 @@ function signEventDelivery(secret, context, rawBody) {
|
|
|
1613
1807
|
projectId: context.projectId,
|
|
1614
1808
|
deploymentId: context.deploymentId,
|
|
1615
1809
|
timestamp: Math.floor(Date.now() / 1e3),
|
|
1616
|
-
nonce:
|
|
1810
|
+
nonce: import_node_crypto5.default.randomUUID()
|
|
1617
1811
|
};
|
|
1618
1812
|
const payloadJson = JSON.stringify(payload);
|
|
1619
1813
|
const payloadB64 = Buffer.from(payloadJson).toString("base64");
|
|
1620
|
-
const signature =
|
|
1814
|
+
const signature = import_node_crypto5.default.createHmac("sha256", secret).update(payloadJson).update(rawBody).digest("hex");
|
|
1621
1815
|
return `${payloadB64}.${signature}`;
|
|
1622
1816
|
}
|
|
1623
1817
|
|
|
@@ -2151,7 +2345,7 @@ function createPayments() {
|
|
|
2151
2345
|
},
|
|
2152
2346
|
async deliverStripeEvent(handler, event, options) {
|
|
2153
2347
|
const envelope = {
|
|
2154
|
-
id: `evt_test_${
|
|
2348
|
+
id: `evt_test_${import_node_crypto6.default.randomUUID()}`,
|
|
2155
2349
|
kind: "stripe_webhook",
|
|
2156
2350
|
timestamp: Date.now(),
|
|
2157
2351
|
stripeEvent: {
|
|
@@ -2164,7 +2358,7 @@ function createPayments() {
|
|
|
2164
2358
|
},
|
|
2165
2359
|
async deliverBeamEvent(handler, event, options) {
|
|
2166
2360
|
const envelope = {
|
|
2167
|
-
id: `beam_evt_test_${
|
|
2361
|
+
id: `beam_evt_test_${import_node_crypto6.default.randomUUID()}`,
|
|
2168
2362
|
kind: "beam_webhook",
|
|
2169
2363
|
timestamp: Date.now(),
|
|
2170
2364
|
beamEvent: {
|
|
@@ -2247,7 +2441,7 @@ function createPayments() {
|
|
|
2247
2441
|
}
|
|
2248
2442
|
|
|
2249
2443
|
// src/simulator/storage.ts
|
|
2250
|
-
var
|
|
2444
|
+
var import_node_crypto7 = __toESM(require("crypto"));
|
|
2251
2445
|
function storageErr(error, status = 400) {
|
|
2252
2446
|
return json({ error }, status);
|
|
2253
2447
|
}
|
|
@@ -2326,7 +2520,7 @@ async function handleStorageRequest(request, url) {
|
|
|
2326
2520
|
}
|
|
2327
2521
|
return new Response(null, {
|
|
2328
2522
|
status: 200,
|
|
2329
|
-
headers: { etag: `"${
|
|
2523
|
+
headers: { etag: `"${import_node_crypto7.default.randomUUID()}"` }
|
|
2330
2524
|
});
|
|
2331
2525
|
}
|
|
2332
2526
|
if (pathname === "/upload/presign/complete" && request.method === "POST") {
|
|
@@ -2474,7 +2668,7 @@ function createStorage() {
|
|
|
2474
2668
|
}
|
|
2475
2669
|
|
|
2476
2670
|
// src/simulator/messaging.ts
|
|
2477
|
-
var
|
|
2671
|
+
var import_node_crypto8 = __toESM(require("crypto"));
|
|
2478
2672
|
function isValidLineImageUrl(url) {
|
|
2479
2673
|
if (url.length > 2e3 || !url.startsWith("https://")) return false;
|
|
2480
2674
|
try {
|
|
@@ -2528,7 +2722,7 @@ async function handleMessagingRequest(request, channel, subPath) {
|
|
|
2528
2722
|
);
|
|
2529
2723
|
return success({
|
|
2530
2724
|
ok: true,
|
|
2531
|
-
ts: `${Date.now()}.${
|
|
2725
|
+
ts: `${Date.now()}.${import_node_crypto8.default.randomUUID().slice(0, 6)}`,
|
|
2532
2726
|
channel: slackChannel
|
|
2533
2727
|
});
|
|
2534
2728
|
}
|
|
@@ -3241,8 +3435,8 @@ function buildUser(partial) {
|
|
|
3241
3435
|
function loadSchemaSql(options) {
|
|
3242
3436
|
if (options.schemaSql !== void 0) return options.schemaSql;
|
|
3243
3437
|
if (options.schema === null) return null;
|
|
3244
|
-
const schemaPath = (0,
|
|
3245
|
-
if (!(0,
|
|
3438
|
+
const schemaPath = (0, import_node_path2.resolve)(process.cwd(), options.schema ?? DEFAULT_SCHEMA_PATH);
|
|
3439
|
+
if (!(0, import_node_fs2.existsSync)(schemaPath)) {
|
|
3246
3440
|
if (options.schema) {
|
|
3247
3441
|
throw new Error(
|
|
3248
3442
|
`[stardeck-testing] Schema snapshot not found at ${schemaPath}. Run \`npx stardeck-data-store generate-types\` to regenerate it.`
|
|
@@ -3250,7 +3444,7 @@ function loadSchemaSql(options) {
|
|
|
3250
3444
|
}
|
|
3251
3445
|
return null;
|
|
3252
3446
|
}
|
|
3253
|
-
return (0,
|
|
3447
|
+
return (0, import_node_fs2.readFileSync)(schemaPath, "utf-8");
|
|
3254
3448
|
}
|
|
3255
3449
|
async function createTestApp(options = {}) {
|
|
3256
3450
|
if (state.db) {
|
|
@@ -3261,15 +3455,23 @@ async function createTestApp(options = {}) {
|
|
|
3261
3455
|
applyTestEnv(options);
|
|
3262
3456
|
state.allowNetwork = options.allowNetwork ?? false;
|
|
3263
3457
|
installFetchRouter();
|
|
3264
|
-
const
|
|
3458
|
+
const schemaSql = loadSchemaSql(options) ?? "";
|
|
3459
|
+
const db = await bootWithSchema(schemaSql);
|
|
3265
3460
|
state.db = db;
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
if (schemaSql) await applySchema(db, schemaSql);
|
|
3461
|
+
let baseline;
|
|
3462
|
+
const seed = async () => {
|
|
3269
3463
|
if (options.seed) await options.seed(db);
|
|
3464
|
+
await acceptSchemaChanges(db);
|
|
3465
|
+
};
|
|
3466
|
+
const rebuild = async () => {
|
|
3467
|
+
await dropAllTables(db);
|
|
3468
|
+
await applySchema(db, schemaSql);
|
|
3469
|
+
baseline = await captureBaseline(db);
|
|
3470
|
+
await seed();
|
|
3270
3471
|
};
|
|
3271
3472
|
try {
|
|
3272
|
-
await
|
|
3473
|
+
baseline = await captureBaseline(db);
|
|
3474
|
+
await seed();
|
|
3273
3475
|
} catch (error) {
|
|
3274
3476
|
state.db = null;
|
|
3275
3477
|
await db.close().catch(() => {
|
|
@@ -3307,20 +3509,23 @@ async function createTestApp(options = {}) {
|
|
|
3307
3509
|
issueSession(user) {
|
|
3308
3510
|
const fullUser = buildUser(user);
|
|
3309
3511
|
const tokens = {
|
|
3310
|
-
accessToken: `test-access-${
|
|
3311
|
-
refreshToken: `test-refresh-${
|
|
3512
|
+
accessToken: `test-access-${import_node_crypto9.default.randomUUID()}`,
|
|
3513
|
+
refreshToken: `test-refresh-${import_node_crypto9.default.randomUUID()}`
|
|
3312
3514
|
};
|
|
3313
3515
|
state.sessions.set(tokens.accessToken, fullUser);
|
|
3314
3516
|
state.refreshSessions.set(tokens.refreshToken, fullUser);
|
|
3315
3517
|
return tokens;
|
|
3316
3518
|
},
|
|
3317
3519
|
async reset(resetOptions) {
|
|
3318
|
-
if (resetOptions?.mode === "
|
|
3319
|
-
await
|
|
3320
|
-
if (options.seed) await options.seed(db);
|
|
3520
|
+
if (resetOptions?.mode === "schema" || !baseline || await schemaChangedSinceBaseline(db)) {
|
|
3521
|
+
await rebuild();
|
|
3321
3522
|
} else {
|
|
3322
|
-
await
|
|
3323
|
-
|
|
3523
|
+
await restoreBaseline(db, baseline);
|
|
3524
|
+
try {
|
|
3525
|
+
await seed();
|
|
3526
|
+
} catch {
|
|
3527
|
+
await rebuild();
|
|
3528
|
+
}
|
|
3324
3529
|
}
|
|
3325
3530
|
state.currentUser = null;
|
|
3326
3531
|
state.sessions.clear();
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/test-app.ts
|
|
2
2
|
import crypto8 from "crypto";
|
|
3
|
-
import { readFileSync, existsSync } from "fs";
|
|
4
|
-
import { resolve } from "path";
|
|
3
|
+
import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
4
|
+
import { resolve as resolve2 } from "path";
|
|
5
5
|
|
|
6
6
|
// src/pglite/database.ts
|
|
7
7
|
import { PGlite } from "@electric-sql/pglite";
|
|
@@ -9,10 +9,10 @@ import { citext } from "@electric-sql/pglite/contrib/citext";
|
|
|
9
9
|
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
|
|
10
10
|
import { pgcrypto } from "@electric-sql/pglite/contrib/pgcrypto";
|
|
11
11
|
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
});
|
|
12
|
+
var EXTENSIONS = { pg_trgm, pgcrypto, uuid_ossp, citext };
|
|
13
|
+
var PGLITE_EXTENSION_NAMES = Object.keys(EXTENSIONS);
|
|
14
|
+
async function createPgliteDatabase(options = {}) {
|
|
15
|
+
const db = new PGlite({ extensions: EXTENSIONS, ...options });
|
|
16
16
|
await db.waitReady;
|
|
17
17
|
return db;
|
|
18
18
|
}
|
|
@@ -33,10 +33,6 @@ async function listPublicTables(db) {
|
|
|
33
33
|
`);
|
|
34
34
|
return rows.map(({ name }) => name);
|
|
35
35
|
}
|
|
36
|
-
async function truncateAllTables(db) {
|
|
37
|
-
const tables = await listPublicTables(db);
|
|
38
|
-
if (tables.length) await db.exec(`TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY`);
|
|
39
|
-
}
|
|
40
36
|
var identityParsers = null;
|
|
41
37
|
function getIdentityParsers() {
|
|
42
38
|
if (!identityParsers) {
|
|
@@ -49,6 +45,208 @@ function getIdentityParsers() {
|
|
|
49
45
|
return identityParsers;
|
|
50
46
|
}
|
|
51
47
|
|
|
48
|
+
// src/pglite/baseline.ts
|
|
49
|
+
var HARNESS_SCHEMA = "stardeck_test";
|
|
50
|
+
var SCHEMA_CHANGES = `${HARNESS_SCHEMA}.schema_changes`;
|
|
51
|
+
var SCHEMA_CHANGE_TRACKING_SQL = `
|
|
52
|
+
CREATE SCHEMA IF NOT EXISTS ${HARNESS_SCHEMA};
|
|
53
|
+
CREATE TABLE IF NOT EXISTS ${SCHEMA_CHANGES} ();
|
|
54
|
+
CREATE OR REPLACE FUNCTION ${HARNESS_SCHEMA}.mark_schema_changed() RETURNS event_trigger
|
|
55
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog
|
|
56
|
+
AS $$ BEGIN INSERT INTO ${SCHEMA_CHANGES} DEFAULT VALUES; END $$;
|
|
57
|
+
DROP EVENT TRIGGER IF EXISTS stardeck_test_ddl;
|
|
58
|
+
DROP EVENT TRIGGER IF EXISTS stardeck_test_drop;
|
|
59
|
+
CREATE EVENT TRIGGER stardeck_test_ddl ON ddl_command_end
|
|
60
|
+
EXECUTE FUNCTION ${HARNESS_SCHEMA}.mark_schema_changed();
|
|
61
|
+
CREATE EVENT TRIGGER stardeck_test_drop ON sql_drop
|
|
62
|
+
EXECUTE FUNCTION ${HARNESS_SCHEMA}.mark_schema_changed();
|
|
63
|
+
ALTER EVENT TRIGGER stardeck_test_ddl ENABLE ALWAYS;
|
|
64
|
+
ALTER EVENT TRIGGER stardeck_test_drop ENABLE ALWAYS;
|
|
65
|
+
`;
|
|
66
|
+
async function schemaChangedSinceBaseline(db) {
|
|
67
|
+
const { rows } = await db.query(
|
|
68
|
+
`SELECT EXISTS (SELECT FROM ${SCHEMA_CHANGES}) AS changed`
|
|
69
|
+
);
|
|
70
|
+
return rows[0]?.changed === true;
|
|
71
|
+
}
|
|
72
|
+
async function acceptSchemaChanges(db) {
|
|
73
|
+
await db.exec(`DELETE FROM ${SCHEMA_CHANGES};`);
|
|
74
|
+
}
|
|
75
|
+
async function sequenceStates(db) {
|
|
76
|
+
const { rows: sequences } = await db.query(`
|
|
77
|
+
SELECT format('%I.%I', n.nspname, c.relname) AS name
|
|
78
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
79
|
+
WHERE n.nspname = 'public' AND c.relkind = 'S'
|
|
80
|
+
`);
|
|
81
|
+
if (!sequences.length) return null;
|
|
82
|
+
const { rows } = await db.query(
|
|
83
|
+
sequences.map(
|
|
84
|
+
({ name }) => `SELECT ${quoteLiteral(name)} AS name, last_value::text, is_called FROM ${name}`
|
|
85
|
+
).join(" UNION ALL ")
|
|
86
|
+
);
|
|
87
|
+
return `SELECT ${rows.map((s) => `setval(${quoteLiteral(s.name)}, ${s.last_value}, ${s.is_called})`).join(", ")};`;
|
|
88
|
+
}
|
|
89
|
+
function quoteLiteral(value) {
|
|
90
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
91
|
+
}
|
|
92
|
+
async function captureBaseline(db) {
|
|
93
|
+
const { rows: unsilenceable } = await db.query(`
|
|
94
|
+
SELECT FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
|
|
95
|
+
WHERE c.relnamespace = 'public'::regnamespace AND NOT t.tgisinternal
|
|
96
|
+
AND t.tgenabled IN ('A', 'R')`);
|
|
97
|
+
if (unsilenceable.length) return null;
|
|
98
|
+
const tables = await listPublicTables(db);
|
|
99
|
+
const statements = [];
|
|
100
|
+
await db.exec(`DROP SCHEMA IF EXISTS ${HARNESS_SCHEMA}_baseline CASCADE;
|
|
101
|
+
CREATE SCHEMA ${HARNESS_SCHEMA}_baseline;`);
|
|
102
|
+
if (tables.length) {
|
|
103
|
+
const { rows: populated } = await db.query(
|
|
104
|
+
tables.map(
|
|
105
|
+
(table, index) => `SELECT ${index} AS index, (
|
|
106
|
+
SELECT string_agg(quote_ident(attname), ', ' ORDER BY attnum) FROM pg_attribute
|
|
107
|
+
WHERE attrelid = ${quoteLiteral(table)}::regclass AND attnum > 0
|
|
108
|
+
AND NOT attisdropped AND attgenerated = ''
|
|
109
|
+
) AS columns WHERE EXISTS (SELECT 1 FROM ONLY ${table})`
|
|
110
|
+
).join(" UNION ALL ")
|
|
111
|
+
);
|
|
112
|
+
for (const { index, columns } of populated) {
|
|
113
|
+
const copy = `${HARNESS_SCHEMA}_baseline.t${index}`;
|
|
114
|
+
await db.exec(`CREATE TABLE ${copy} AS SELECT ${columns} FROM ONLY ${tables[index]};`);
|
|
115
|
+
statements.push(
|
|
116
|
+
`INSERT INTO ${tables[index]} (${columns}) OVERRIDING SYSTEM VALUE SELECT ${columns} FROM ${copy};`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const setvals = await sequenceStates(db);
|
|
121
|
+
if (setvals) statements.push(setvals);
|
|
122
|
+
return { restoreSql: statements.join("\n") };
|
|
123
|
+
}
|
|
124
|
+
async function restoreBaseline(db, baseline) {
|
|
125
|
+
const tables = await listPublicTables(db);
|
|
126
|
+
const truncate = tables.length ? `TRUNCATE TABLE ${tables.join(", ")} RESTART IDENTITY;` : "";
|
|
127
|
+
try {
|
|
128
|
+
await db.exec(`BEGIN; SET LOCAL session_replication_role = replica;
|
|
129
|
+
${truncate}
|
|
130
|
+
SELECT setval(format('%I.%I', schemaname, sequencename), start_value, false)
|
|
131
|
+
FROM pg_sequences WHERE schemaname = 'public';
|
|
132
|
+
${baseline.restoreSql}
|
|
133
|
+
COMMIT;`);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
await db.exec("ROLLBACK;").catch(() => {
|
|
136
|
+
});
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/pglite/snapshot.ts
|
|
142
|
+
import { createHash } from "crypto";
|
|
143
|
+
import {
|
|
144
|
+
existsSync,
|
|
145
|
+
mkdirSync,
|
|
146
|
+
readdirSync,
|
|
147
|
+
readFileSync,
|
|
148
|
+
renameSync,
|
|
149
|
+
rmSync,
|
|
150
|
+
statSync,
|
|
151
|
+
utimesSync,
|
|
152
|
+
writeFileSync
|
|
153
|
+
} from "fs";
|
|
154
|
+
import { dirname, join, resolve } from "path";
|
|
155
|
+
var SNAPSHOT_FORMAT = 2;
|
|
156
|
+
var MAX_SNAPSHOTS = 8;
|
|
157
|
+
function snapshotKey(schemaSql) {
|
|
158
|
+
const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
159
|
+
return createHash("sha256").update(JSON.stringify({ format: SNAPSHOT_FORMAT, extensions: PGLITE_EXTENSION_NAMES, day })).update(SCHEMA_CHANGE_TRACKING_SQL).update(schemaSql).digest("hex").slice(0, 32);
|
|
160
|
+
}
|
|
161
|
+
function snapshotPath(schemaSql) {
|
|
162
|
+
return resolve(
|
|
163
|
+
process.cwd(),
|
|
164
|
+
"node_modules/.cache/stardeck-testing",
|
|
165
|
+
`${snapshotKey(schemaSql)}.tar`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
var CUSTOM_SETTING = /\bset_config\s*\(\s*(?:'([^']*)'|(\$\w*\$)([\s\S]*?)\2)|\bSET\s+(?:SESSION\s+)?(?:"([^"]*)"|([a-z_][\w$]*\.[\w$.]+))/gi;
|
|
169
|
+
async function sessionSettings(db, schemaSql) {
|
|
170
|
+
const custom = [...schemaSql.matchAll(CUSTOM_SETTING)].map(
|
|
171
|
+
([, quoted, , dollarQuoted, identifier, bare]) => quoted ?? dollarQuoted ?? identifier ?? bare
|
|
172
|
+
).filter((name) => name?.includes("."));
|
|
173
|
+
const { rows } = await db.query(
|
|
174
|
+
`SELECT string_agg(name || '=' || setting, ',' ORDER BY name) AS settings FROM (
|
|
175
|
+
SELECT name, setting FROM pg_settings WHERE source = 'session'
|
|
176
|
+
UNION ALL
|
|
177
|
+
SELECT name, current_setting(name, true) FROM unnest($1::text[]) AS name
|
|
178
|
+
WHERE current_setting(name, true) IS NOT NULL
|
|
179
|
+
) settings`,
|
|
180
|
+
[custom]
|
|
181
|
+
);
|
|
182
|
+
return rows[0]?.settings ?? "";
|
|
183
|
+
}
|
|
184
|
+
async function buildSchemaDatabase(schemaSql) {
|
|
185
|
+
const db = await createPgliteDatabase();
|
|
186
|
+
try {
|
|
187
|
+
await db.exec(SCHEMA_CHANGE_TRACKING_SQL);
|
|
188
|
+
const settingsBefore = await sessionSettings(db, schemaSql);
|
|
189
|
+
await applySchema(db, schemaSql);
|
|
190
|
+
const { rows: sessionBound } = await db.query(
|
|
191
|
+
`SELECT FROM pg_class WHERE relpersistence IN ('u', 't')`
|
|
192
|
+
);
|
|
193
|
+
const cacheable = !sessionBound.length && await sessionSettings(db, schemaSql) === settingsBefore;
|
|
194
|
+
await db.exec(`COMMENT ON SCHEMA stardeck_test IS '${snapshotKey(schemaSql)}'`);
|
|
195
|
+
await db.exec("CHECKPOINT");
|
|
196
|
+
return { db, cacheable };
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await db.close().catch(() => {
|
|
199
|
+
});
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function pruneSnapshots(dir) {
|
|
204
|
+
const snapshots = readdirSync(dir).filter((name) => name.endsWith(".tar")).map((name) => ({ path: join(dir, name), mtime: statSync(join(dir, name)).mtimeMs })).sort((a, b) => b.mtime - a.mtime);
|
|
205
|
+
for (const { path } of snapshots.slice(MAX_SNAPSHOTS)) rmSync(path, { force: true });
|
|
206
|
+
}
|
|
207
|
+
async function writeSnapshot(path, db) {
|
|
208
|
+
try {
|
|
209
|
+
const data = Buffer.from(await (await db.dumpDataDir("none")).arrayBuffer());
|
|
210
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
211
|
+
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
212
|
+
writeFileSync(tmp, data);
|
|
213
|
+
renameSync(tmp, path);
|
|
214
|
+
pruneSnapshots(dirname(path));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.warn(`[stardeck-testing] could not cache schema snapshot at ${path}:`, error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function loadSnapshot(path, key) {
|
|
220
|
+
let db = null;
|
|
221
|
+
try {
|
|
222
|
+
db = await createPgliteDatabase({ loadDataDir: new Blob([readFileSync(path)]) });
|
|
223
|
+
const { rows } = await db.query(
|
|
224
|
+
`SELECT obj_description(to_regnamespace('stardeck_test'), 'pg_namespace') AS key`
|
|
225
|
+
);
|
|
226
|
+
if (rows[0]?.key === key) {
|
|
227
|
+
const now3 = /* @__PURE__ */ new Date();
|
|
228
|
+
utimesSync(path, now3, now3);
|
|
229
|
+
return db;
|
|
230
|
+
}
|
|
231
|
+
console.warn(`[stardeck-testing] rebuilding schema snapshot ${path}: key mismatch`);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.warn(`[stardeck-testing] rebuilding unreadable schema snapshot ${path}:`, error);
|
|
234
|
+
}
|
|
235
|
+
await db?.close().catch(() => {
|
|
236
|
+
});
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
async function bootWithSchema(schemaSql) {
|
|
240
|
+
const path = snapshotPath(schemaSql);
|
|
241
|
+
if (existsSync(path)) {
|
|
242
|
+
const loaded = await loadSnapshot(path, snapshotKey(schemaSql));
|
|
243
|
+
if (loaded) return loaded;
|
|
244
|
+
}
|
|
245
|
+
const { db, cacheable } = await buildSchemaDatabase(schemaSql);
|
|
246
|
+
if (cacheable) await writeSnapshot(path, db);
|
|
247
|
+
return db;
|
|
248
|
+
}
|
|
249
|
+
|
|
52
250
|
// src/global-singleton.ts
|
|
53
251
|
function globalSingleton(key, create) {
|
|
54
252
|
const symbol = /* @__PURE__ */ Symbol.for(`stardeck-testing.${key}`);
|
|
@@ -818,6 +1016,9 @@ function isCurrentTeamMember(identityId) {
|
|
|
818
1016
|
function hasRecordedContact(identityId) {
|
|
819
1017
|
return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
|
|
820
1018
|
}
|
|
1019
|
+
function hasAbsorbedIdentity(identityId) {
|
|
1020
|
+
return [...state.identities.values()].some((identity) => identity.mergedIntoId === identityId);
|
|
1021
|
+
}
|
|
821
1022
|
function handleList(request) {
|
|
822
1023
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
823
1024
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
@@ -1168,6 +1369,9 @@ function createBearerClaim(body, returnTo) {
|
|
|
1168
1369
|
if (hasRecordedContact(identity.id)) {
|
|
1169
1370
|
return failure("identity has an email or phone on record; send an invite claim instead", 409);
|
|
1170
1371
|
}
|
|
1372
|
+
if (hasAbsorbedIdentity(identity.id)) {
|
|
1373
|
+
return failure("another profile was merged into this identity; reception must connect it", 409);
|
|
1374
|
+
}
|
|
1171
1375
|
return mintClaim({
|
|
1172
1376
|
flow: "bearer",
|
|
1173
1377
|
identityId: identity.id,
|
|
@@ -1414,7 +1618,7 @@ function completeBearerClaim(claim, clerkUserId) {
|
|
|
1414
1618
|
claim.state = "invalidated";
|
|
1415
1619
|
return claim;
|
|
1416
1620
|
}
|
|
1417
|
-
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1621
|
+
const refusal = !state.identityBearerLinksEnabled ? "expired" : hasRecordedContact(claim.identityId) || hasAbsorbedIdentity(claim.identityId) ? "protected" : ownedByAnotherLogin(claim.identityId, clerkUserId) ? "owned_elsewhere" : null;
|
|
1418
1622
|
if (refusal) {
|
|
1419
1623
|
claim.state = "refused";
|
|
1420
1624
|
claim.refusalReason = refusal;
|
|
@@ -3192,8 +3396,8 @@ function buildUser(partial) {
|
|
|
3192
3396
|
function loadSchemaSql(options) {
|
|
3193
3397
|
if (options.schemaSql !== void 0) return options.schemaSql;
|
|
3194
3398
|
if (options.schema === null) return null;
|
|
3195
|
-
const schemaPath =
|
|
3196
|
-
if (!
|
|
3399
|
+
const schemaPath = resolve2(process.cwd(), options.schema ?? DEFAULT_SCHEMA_PATH);
|
|
3400
|
+
if (!existsSync2(schemaPath)) {
|
|
3197
3401
|
if (options.schema) {
|
|
3198
3402
|
throw new Error(
|
|
3199
3403
|
`[stardeck-testing] Schema snapshot not found at ${schemaPath}. Run \`npx stardeck-data-store generate-types\` to regenerate it.`
|
|
@@ -3201,7 +3405,7 @@ function loadSchemaSql(options) {
|
|
|
3201
3405
|
}
|
|
3202
3406
|
return null;
|
|
3203
3407
|
}
|
|
3204
|
-
return
|
|
3408
|
+
return readFileSync2(schemaPath, "utf-8");
|
|
3205
3409
|
}
|
|
3206
3410
|
async function createTestApp(options = {}) {
|
|
3207
3411
|
if (state.db) {
|
|
@@ -3212,15 +3416,23 @@ async function createTestApp(options = {}) {
|
|
|
3212
3416
|
applyTestEnv(options);
|
|
3213
3417
|
state.allowNetwork = options.allowNetwork ?? false;
|
|
3214
3418
|
installFetchRouter();
|
|
3215
|
-
const
|
|
3419
|
+
const schemaSql = loadSchemaSql(options) ?? "";
|
|
3420
|
+
const db = await bootWithSchema(schemaSql);
|
|
3216
3421
|
state.db = db;
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
if (schemaSql) await applySchema(db, schemaSql);
|
|
3422
|
+
let baseline;
|
|
3423
|
+
const seed = async () => {
|
|
3220
3424
|
if (options.seed) await options.seed(db);
|
|
3425
|
+
await acceptSchemaChanges(db);
|
|
3426
|
+
};
|
|
3427
|
+
const rebuild = async () => {
|
|
3428
|
+
await dropAllTables(db);
|
|
3429
|
+
await applySchema(db, schemaSql);
|
|
3430
|
+
baseline = await captureBaseline(db);
|
|
3431
|
+
await seed();
|
|
3221
3432
|
};
|
|
3222
3433
|
try {
|
|
3223
|
-
await
|
|
3434
|
+
baseline = await captureBaseline(db);
|
|
3435
|
+
await seed();
|
|
3224
3436
|
} catch (error) {
|
|
3225
3437
|
state.db = null;
|
|
3226
3438
|
await db.close().catch(() => {
|
|
@@ -3266,12 +3478,15 @@ async function createTestApp(options = {}) {
|
|
|
3266
3478
|
return tokens;
|
|
3267
3479
|
},
|
|
3268
3480
|
async reset(resetOptions) {
|
|
3269
|
-
if (resetOptions?.mode === "
|
|
3270
|
-
await
|
|
3271
|
-
if (options.seed) await options.seed(db);
|
|
3481
|
+
if (resetOptions?.mode === "schema" || !baseline || await schemaChangedSinceBaseline(db)) {
|
|
3482
|
+
await rebuild();
|
|
3272
3483
|
} else {
|
|
3273
|
-
await
|
|
3274
|
-
|
|
3484
|
+
await restoreBaseline(db, baseline);
|
|
3485
|
+
try {
|
|
3486
|
+
await seed();
|
|
3487
|
+
} catch {
|
|
3488
|
+
await rebuild();
|
|
3489
|
+
}
|
|
3275
3490
|
}
|
|
3276
3491
|
state.currentUser = null;
|
|
3277
3492
|
state.sessions.clear();
|
package/dist/setup.js
CHANGED
|
@@ -504,6 +504,8 @@ var import_citext = require("@electric-sql/pglite/contrib/citext");
|
|
|
504
504
|
var import_pg_trgm = require("@electric-sql/pglite/contrib/pg_trgm");
|
|
505
505
|
var import_pgcrypto = require("@electric-sql/pglite/contrib/pgcrypto");
|
|
506
506
|
var import_uuid_ossp = require("@electric-sql/pglite/contrib/uuid_ossp");
|
|
507
|
+
var EXTENSIONS = { pg_trgm: import_pg_trgm.pg_trgm, pgcrypto: import_pgcrypto.pgcrypto, uuid_ossp: import_uuid_ossp.uuid_ossp, citext: import_citext.citext };
|
|
508
|
+
var PGLITE_EXTENSION_NAMES = Object.keys(EXTENSIONS);
|
|
507
509
|
var identityParsers = null;
|
|
508
510
|
function getIdentityParsers() {
|
|
509
511
|
if (!identityParsers) {
|
|
@@ -769,6 +771,9 @@ function isCurrentTeamMember(identityId) {
|
|
|
769
771
|
function hasRecordedContact(identityId) {
|
|
770
772
|
return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
|
|
771
773
|
}
|
|
774
|
+
function hasAbsorbedIdentity(identityId) {
|
|
775
|
+
return [...state.identities.values()].some((identity) => identity.mergedIntoId === identityId);
|
|
776
|
+
}
|
|
772
777
|
function handleList(request) {
|
|
773
778
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
774
779
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
@@ -1119,6 +1124,9 @@ function createBearerClaim(body, returnTo) {
|
|
|
1119
1124
|
if (hasRecordedContact(identity.id)) {
|
|
1120
1125
|
return failure("identity has an email or phone on record; send an invite claim instead", 409);
|
|
1121
1126
|
}
|
|
1127
|
+
if (hasAbsorbedIdentity(identity.id)) {
|
|
1128
|
+
return failure("another profile was merged into this identity; reception must connect it", 409);
|
|
1129
|
+
}
|
|
1122
1130
|
return mintClaim({
|
|
1123
1131
|
flow: "bearer",
|
|
1124
1132
|
identityId: identity.id,
|
package/dist/setup.mjs
CHANGED
|
@@ -480,6 +480,8 @@ import { citext } from "@electric-sql/pglite/contrib/citext";
|
|
|
480
480
|
import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
|
|
481
481
|
import { pgcrypto } from "@electric-sql/pglite/contrib/pgcrypto";
|
|
482
482
|
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
|
483
|
+
var EXTENSIONS = { pg_trgm, pgcrypto, uuid_ossp, citext };
|
|
484
|
+
var PGLITE_EXTENSION_NAMES = Object.keys(EXTENSIONS);
|
|
483
485
|
var identityParsers = null;
|
|
484
486
|
function getIdentityParsers() {
|
|
485
487
|
if (!identityParsers) {
|
|
@@ -745,6 +747,9 @@ function isCurrentTeamMember(identityId) {
|
|
|
745
747
|
function hasRecordedContact(identityId) {
|
|
746
748
|
return linksFor(identityId).some((link) => link.kind === "email" || link.kind === "phone");
|
|
747
749
|
}
|
|
750
|
+
function hasAbsorbedIdentity(identityId) {
|
|
751
|
+
return [...state.identities.values()].some((identity) => identity.mergedIntoId === identityId);
|
|
752
|
+
}
|
|
748
753
|
function handleList(request) {
|
|
749
754
|
const typeParam = new URL(request.url).searchParams.get("type");
|
|
750
755
|
const type = typeParam === "person" || typeParam === "account" ? typeParam : void 0;
|
|
@@ -1095,6 +1100,9 @@ function createBearerClaim(body, returnTo) {
|
|
|
1095
1100
|
if (hasRecordedContact(identity.id)) {
|
|
1096
1101
|
return failure("identity has an email or phone on record; send an invite claim instead", 409);
|
|
1097
1102
|
}
|
|
1103
|
+
if (hasAbsorbedIdentity(identity.id)) {
|
|
1104
|
+
return failure("another profile was merged into this identity; reception must connect it", 409);
|
|
1105
|
+
}
|
|
1098
1106
|
return mintClaim({
|
|
1099
1107
|
flow: "bearer",
|
|
1100
1108
|
identityId: identity.id,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stardeck-customer-apps/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Vitest test harness for Stardeck customer apps — in-process Postgres (PGlite) plus a control-plane simulator so the real Stardeck SDKs run unmodified in tests",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|