@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 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()` — wipe + re-apply schema + re-seed + clear inbox/sessions.
297
- Call in `beforeEach`.
298
- - `app.reset({ mode: "data" })` — available in testing SDK **0.14.0+**. Faster
299
- opt-in for stable schemas: truncate ordinary tables in `public`, restart their
300
- owned serial/identity sequences, re-run the data-only seed, and clear the same
301
- simulator/user/session state as the default. It preserves tables and other
302
- schema objects instead of rebuilding them. The default remains full schema
303
- reset; `app.reset({ mode: "schema" })` explicitly selects that default.
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
- // Only after verifying this suite keeps its schema fixed and uses a data-only seed:
307
- beforeEach(() => app.reset({ mode: "data" }));
309
+ beforeEach(() => app.reset());
308
310
  ```
309
311
 
310
- Keep `app.reset()` for tests that change schema or depend on INSERTs in the
311
- schema script. Also keep full reset for independent sequences, materialized
312
- views, partitioned/foreign tables, temporary tables, and custom triggers or
313
- cross-schema dependencies. Data mode does not restore those states or detect
314
- eligibility; inspect both the tests and seed before opting in. All required
315
- baseline rows must come from the data-only seed callback, not schema SQL.
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. This option does not change workers
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`, and a link still
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
- * Rebuild the schema (default), re-run the seed, and clear simulator/user state.
637
- * `mode: "data"` instead truncates ordinary public tables and restarts owned
638
- * sequences. Use only with a stable schema and data-only seed; it does not
639
- * restore DDL changes, schema-script data, or independent sequences.
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
- * Rebuild the schema (default), re-run the seed, and clear simulator/user state.
637
- * `mode: "data"` instead truncates ordinary public tables and restarts owned
638
- * sequences. Use only with a stable schema and data-only seed; it does not
639
- * restore DDL changes, schema-script data, or independent sequences.
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 import_node_crypto8 = __toESM(require("crypto"));
52
- var import_node_fs = require("fs");
53
- var import_node_path = require("path");
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
- async function createPgliteDatabase() {
62
- const db = new import_pglite.PGlite({
63
- extensions: { pg_trgm: import_pg_trgm.pg_trgm, pgcrypto: import_pgcrypto.pgcrypto, uuid_ossp: import_uuid_ossp.uuid_ossp, citext: import_citext.citext }
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 import_node_crypto = __toESM(require("crypto"));
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 = import_node_crypto.default.createHmac("sha256", secret).update(payloadJson).digest("hex");
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 || !import_node_crypto.default.timingSafeEqual(expectedBuf, actualBuf)) {
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 import_node_crypto2 = __toESM(require("crypto"));
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-${import_node_crypto2.default.randomUUID()}`;
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 import_node_crypto3 = __toESM(require("crypto"));
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: import_node_crypto3.default.randomUUID(),
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: import_node_crypto3.default.randomUUID(),
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: import_node_crypto3.default.randomUUID(),
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 = import_node_crypto3.default.randomUUID();
1174
- const handle = import_node_crypto3.default.randomBytes(32).toString("base64url");
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 import_node_crypto5 = __toESM(require("crypto"));
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 import_node_crypto4 = __toESM(require("crypto"));
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: import_node_crypto4.default.randomUUID()
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 = import_node_crypto4.default.createHmac("sha256", secret).update(payloadJson).update(rawBody).digest("hex");
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_${import_node_crypto5.default.randomUUID()}`,
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_${import_node_crypto5.default.randomUUID()}`,
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 import_node_crypto6 = __toESM(require("crypto"));
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: `"${import_node_crypto6.default.randomUUID()}"` }
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 import_node_crypto7 = __toESM(require("crypto"));
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()}.${import_node_crypto7.default.randomUUID().slice(0, 6)}`,
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, import_node_path.resolve)(process.cwd(), options.schema ?? DEFAULT_SCHEMA_PATH);
3245
- if (!(0, import_node_fs.existsSync)(schemaPath)) {
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, import_node_fs.readFileSync)(schemaPath, "utf-8");
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 db = await createPgliteDatabase();
3458
+ const schemaSql = loadSchemaSql(options) ?? "";
3459
+ const db = await bootWithSchema(schemaSql);
3265
3460
  state.db = db;
3266
- const initialize = async () => {
3267
- const schemaSql = loadSchemaSql(options);
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 initialize();
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-${import_node_crypto8.default.randomUUID()}`,
3311
- refreshToken: `test-refresh-${import_node_crypto8.default.randomUUID()}`
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 === "data") {
3319
- await truncateAllTables(db);
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 dropAllTables(db);
3323
- await initialize();
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
- async function createPgliteDatabase() {
13
- const db = new PGlite({
14
- extensions: { pg_trgm, pgcrypto, uuid_ossp, citext }
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 = resolve(process.cwd(), options.schema ?? DEFAULT_SCHEMA_PATH);
3196
- if (!existsSync(schemaPath)) {
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 readFileSync(schemaPath, "utf-8");
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 db = await createPgliteDatabase();
3419
+ const schemaSql = loadSchemaSql(options) ?? "";
3420
+ const db = await bootWithSchema(schemaSql);
3216
3421
  state.db = db;
3217
- const initialize = async () => {
3218
- const schemaSql = loadSchemaSql(options);
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 initialize();
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 === "data") {
3270
- await truncateAllTables(db);
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 dropAllTables(db);
3274
- await initialize();
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.18.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",