@lunora/testing 1.0.0-alpha.4 → 1.0.0-alpha.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -141,6 +141,19 @@ type FunctionRegistry = Record<string, RegisteredAction<any, any> | RegisteredMu
141
141
  * clearly-throwing stubs for unsupported surfaces.
142
142
  */
143
143
  interface LunoraTestOptions {
144
+ /**
145
+ * Injectable `ctx.env` for every context (query / mutation / action). When
146
+ * provided, handlers that read `ctx.env.SOME_KEY` (the validated `defineEnv`
147
+ * surface) see this object. Left unset it stays `undefined` — matching the
148
+ * optional `ctx.env?` field, so graceful `ctx.env?.KEY` access still yields
149
+ * `undefined` rather than throwing. Not a throwing stub for exactly that
150
+ * reason: `env` is designed to be legitimately absent.
151
+ * @example
152
+ * ```ts
153
+ * const t = lunoraTest(schema, { env: { STRIPE_KEY: "sk_test_…" } });
154
+ * ```
155
+ */
156
+ env?: Record<string, unknown>;
144
157
  /**
145
158
  * Injectable `fetch` implementation for action contexts. When provided,
146
159
  * `ctx.fetch` in every `action` (and `withIdentity` views) resolves to this
@@ -171,6 +184,16 @@ interface LunoraTestOptions {
171
184
  * ```
172
185
  */
173
186
  functions?: FunctionRegistry;
187
+ /**
188
+ * Fixed value for `ctx.now` (epoch ms) in every context. Production captures
189
+ * `Date.now()` once per execution; in tests a fixed `now` makes time-dependent
190
+ * handlers deterministic. Defaults to the wall clock at harness creation.
191
+ * @example
192
+ * ```ts
193
+ * const t = lunoraTest(schema, { now: 1_700_000_000_000 });
194
+ * ```
195
+ */
196
+ now?: number;
174
197
  }
175
198
  /**
176
199
  * The in-memory test harness returned by {@link lunoraTest}. Mirrors the first
@@ -253,6 +276,8 @@ interface TestHarness {
253
276
  *
254
277
  * **v1 surfaces now supported:**
255
278
  *
279
+ * - `ctx.env` (all contexts): inject the validated env via `options.env`; unset it
280
+ * stays `undefined`, matching the optional `ctx.env?` field.
256
281
  * - `ctx.fetch` (actions): inject a custom `fetch` via `options.fetch`.
257
282
  * - `ctx.scheduler` (mutations + actions): fully functional fake with virtual clock;
258
283
  * control via `harness.scheduler.advance(ms)` / `runPending()` / `list()`.
package/dist/index.d.ts CHANGED
@@ -141,6 +141,19 @@ type FunctionRegistry = Record<string, RegisteredAction<any, any> | RegisteredMu
141
141
  * clearly-throwing stubs for unsupported surfaces.
142
142
  */
143
143
  interface LunoraTestOptions {
144
+ /**
145
+ * Injectable `ctx.env` for every context (query / mutation / action). When
146
+ * provided, handlers that read `ctx.env.SOME_KEY` (the validated `defineEnv`
147
+ * surface) see this object. Left unset it stays `undefined` — matching the
148
+ * optional `ctx.env?` field, so graceful `ctx.env?.KEY` access still yields
149
+ * `undefined` rather than throwing. Not a throwing stub for exactly that
150
+ * reason: `env` is designed to be legitimately absent.
151
+ * @example
152
+ * ```ts
153
+ * const t = lunoraTest(schema, { env: { STRIPE_KEY: "sk_test_…" } });
154
+ * ```
155
+ */
156
+ env?: Record<string, unknown>;
144
157
  /**
145
158
  * Injectable `fetch` implementation for action contexts. When provided,
146
159
  * `ctx.fetch` in every `action` (and `withIdentity` views) resolves to this
@@ -171,6 +184,16 @@ interface LunoraTestOptions {
171
184
  * ```
172
185
  */
173
186
  functions?: FunctionRegistry;
187
+ /**
188
+ * Fixed value for `ctx.now` (epoch ms) in every context. Production captures
189
+ * `Date.now()` once per execution; in tests a fixed `now` makes time-dependent
190
+ * handlers deterministic. Defaults to the wall clock at harness creation.
191
+ * @example
192
+ * ```ts
193
+ * const t = lunoraTest(schema, { now: 1_700_000_000_000 });
194
+ * ```
195
+ */
196
+ now?: number;
174
197
  }
175
198
  /**
176
199
  * The in-memory test harness returned by {@link lunoraTest}. Mirrors the first
@@ -253,6 +276,8 @@ interface TestHarness {
253
276
  *
254
277
  * **v1 surfaces now supported:**
255
278
  *
279
+ * - `ctx.env` (all contexts): inject the validated env via `options.env`; unset it
280
+ * stays `undefined`, matching the optional `ctx.env?` field.
256
281
  * - `ctx.fetch` (actions): inject a custom `fetch` via `options.fetch`.
257
282
  * - `ctx.scheduler` (mutations + actions): fully functional fake with virtual clock;
258
283
  * control via `harness.scheduler.advance(ms)` / `runPending()` / `list()`.
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export { lunoraTest } from './packem_shared/lunoraTest-BUg4Ebv8.mjs';
1
+ export { lunoraTest } from './packem_shared/lunoraTest-C0DZz2W9.mjs';
2
2
  export { extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
@@ -1,8 +1,9 @@
1
1
  import { runShardMigrations, createShardCtxDb } from '@lunora/do';
2
+ import { LunoraError } from '@lunora/errors';
2
3
  import { DatabaseSync } from 'node:sqlite';
3
4
 
4
- const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistry) => {
5
- let nowMs = Date.now();
5
+ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistry, now) => {
6
+ let nowMs = now;
6
7
  let nextId = 1;
7
8
  const pending = /* @__PURE__ */ new Map();
8
9
  const recordedFailures = [];
@@ -57,7 +58,12 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
57
58
  const executeDue = async (cutoff) => {
58
59
  const due = [...pending.values()].filter((j) => j.scheduledFor <= cutoff).toSorted((a, b) => a.scheduledFor - b.scheduledFor);
59
60
  const failed = [];
61
+ let executed = 0;
60
62
  for (const job of due) {
63
+ if (!pending.has(job.id)) {
64
+ continue;
65
+ }
66
+ executed += 1;
61
67
  try {
62
68
  await dispatchJob(job);
63
69
  } catch (error) {
@@ -66,7 +72,7 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
66
72
  recordedFailures.push(failure);
67
73
  }
68
74
  }
69
- return { executed: due.length, failed };
75
+ return { executed, failed };
70
76
  };
71
77
  const runSweep = async (cutoff, options) => {
72
78
  const { executed, failed } = await executeDue(cutoff);
@@ -100,7 +106,7 @@ const createSqlExec = () => {
100
106
  return {
101
107
  one() {
102
108
  if (rows.length !== 1) {
103
- throw new Error(`expected exactly one row, received ${String(rows.length)}`);
109
+ throw new LunoraError("INTERNAL", `expected exactly one row, received ${String(rows.length)}`);
104
110
  }
105
111
  const [only] = rows;
106
112
  return only;
@@ -138,7 +144,7 @@ const registeredFunctionKind = (value) => {
138
144
  };
139
145
  const registeredFunctionVisibility = (value) => typeof value === "object" && value !== null && value.visibility === "internal" ? "internal" : "public";
140
146
  const unavailable = (surface) => {
141
- throw new Error(`ctx.${surface} is not available in the in-memory @lunora/testing harness (v1)`);
147
+ throw new LunoraError("INTERNAL", `ctx.${surface} is not available in the in-memory @lunora/testing harness (v1)`);
142
148
  };
143
149
  const stubProxy = (surface) => /* @__PURE__ */ new Proxy((..._args) => unavailable(surface), {
144
150
  apply: () => unavailable(surface),
@@ -154,8 +160,9 @@ const noopLog = {
154
160
  const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
155
161
  const factory = (referenceOrInline, args) => {
156
162
  let done = false;
157
- let pendingResolve;
163
+ const pendingWaiters = [];
158
164
  let pendingResult;
165
+ let pendingError;
159
166
  let latestSeq = 0;
160
167
  let appliedSeq = 0;
161
168
  const runQuery = () => {
@@ -170,25 +177,46 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
170
177
  }
171
178
  appliedSeq = seq;
172
179
  const iterResult = { done: false, value };
173
- if (pendingResolve === void 0) {
180
+ if (pendingWaiters.length === 0) {
174
181
  pendingResult = iterResult;
182
+ pendingError = void 0;
175
183
  } else {
176
- const resolve = pendingResolve;
177
- pendingResolve = void 0;
178
184
  pendingResult = void 0;
179
- resolve(iterResult);
185
+ pendingError = void 0;
186
+ for (const waiter of pendingWaiters.splice(0)) {
187
+ waiter.resolve(iterResult);
188
+ }
189
+ }
190
+ };
191
+ const emitError = (seq, error) => {
192
+ if (seq < appliedSeq) {
193
+ return;
194
+ }
195
+ appliedSeq = seq;
196
+ if (pendingWaiters.length === 0) {
197
+ pendingError = { error };
198
+ pendingResult = void 0;
199
+ } else {
200
+ pendingResult = void 0;
201
+ pendingError = void 0;
202
+ for (const waiter of pendingWaiters.splice(0)) {
203
+ waiter.reject(error);
204
+ }
180
205
  }
181
206
  };
182
207
  const emitAt = (seq) => (value) => {
183
208
  emit(seq, value);
184
209
  };
210
+ const emitErrorAt = (seq) => (error) => {
211
+ emitError(seq, error);
212
+ };
185
213
  const listener = () => {
186
214
  if (done) {
187
215
  return;
188
216
  }
189
217
  latestSeq += 1;
190
218
  const seq = latestSeq;
191
- runQuery().then(emitAt(seq)).catch(() => void 0);
219
+ runQuery().then(emitAt(seq)).catch(emitErrorAt(seq));
192
220
  };
193
221
  mutationListeners.add(listener);
194
222
  const iterator = {
@@ -199,17 +227,29 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
199
227
  if (done) {
200
228
  return Promise.resolve({ done: true, value: void 0 });
201
229
  }
202
- if (pendingResult !== void 0 && appliedSeq === latestSeq) {
203
- const result = pendingResult;
204
- pendingResult = void 0;
205
- return Promise.resolve(result);
230
+ if (appliedSeq === latestSeq) {
231
+ if (pendingError !== void 0) {
232
+ const { error } = pendingError;
233
+ pendingError = void 0;
234
+ return Promise.reject(error);
235
+ }
236
+ if (pendingResult !== void 0) {
237
+ const result = pendingResult;
238
+ pendingResult = void 0;
239
+ return Promise.resolve(result);
240
+ }
206
241
  }
207
242
  if (appliedSeq < latestSeq) {
208
- return new Promise((resolve) => {
209
- pendingResolve = resolve;
243
+ return new Promise((resolve, reject) => {
244
+ pendingWaiters.push({ reject, resolve });
210
245
  });
211
246
  }
212
247
  return runQuery().then((value) => {
248
+ if (pendingError !== void 0) {
249
+ const { error } = pendingError;
250
+ pendingError = void 0;
251
+ throw error;
252
+ }
213
253
  if (pendingResult !== void 0) {
214
254
  const result = pendingResult;
215
255
  pendingResult = void 0;
@@ -221,15 +261,13 @@ const buildSubscribe = (runRegistered, queryContext, mutationListeners) => {
221
261
  return: () => {
222
262
  done = true;
223
263
  mutationListeners.delete(listener);
224
- if (pendingResolve !== void 0) {
225
- const resolve = pendingResolve;
226
- pendingResolve = void 0;
227
- resolve({ done: true, value: void 0 });
264
+ for (const waiter of pendingWaiters.splice(0)) {
265
+ waiter.resolve({ done: true, value: void 0 });
228
266
  }
229
267
  return Promise.resolve({ done: true, value: void 0 });
230
268
  }
231
269
  };
232
- runQuery().then(emitAt(0)).catch(() => void 0);
270
+ runQuery().then(emitAt(0)).catch(emitErrorAt(0));
233
271
  return iterator;
234
272
  };
235
273
  return factory;
@@ -243,26 +281,28 @@ const lunoraTest = (schema, options) => {
243
281
  const runner = sql.exec;
244
282
  runner.call(sql, statement);
245
283
  };
246
- let transactionDepth = 0;
247
- const runInMutationTransaction = async (function_) => {
248
- if (transactionDepth > 0) {
249
- return function_();
250
- }
251
- transactionDepth = 1;
252
- execStatement("BEGIN");
253
- try {
254
- const result = await function_();
255
- execStatement("COMMIT");
256
- return result;
257
- } catch (error) {
284
+ let mutationQueue = Promise.resolve();
285
+ const runInMutationTransaction = (function_) => {
286
+ const runTransaction = async () => {
287
+ execStatement("BEGIN");
258
288
  try {
259
- execStatement("ROLLBACK");
260
- } catch {
289
+ const result2 = await function_();
290
+ execStatement("COMMIT");
291
+ return result2;
292
+ } catch (error) {
293
+ try {
294
+ execStatement("ROLLBACK");
295
+ } catch {
296
+ }
297
+ throw error;
261
298
  }
262
- throw error;
263
- } finally {
264
- transactionDepth = 0;
265
- }
299
+ };
300
+ const result = mutationQueue.then(runTransaction);
301
+ mutationQueue = result.then(
302
+ () => void 0,
303
+ () => void 0
304
+ );
305
+ return result;
266
306
  };
267
307
  let closed = false;
268
308
  const closeDatabase = () => {
@@ -283,20 +323,28 @@ const lunoraTest = (schema, options) => {
283
323
  };
284
324
  let scheduledDispatchRef;
285
325
  let mutationContextRef;
326
+ const harnessNow = options?.now ?? Date.now();
286
327
  const { controls: schedulerControls, scheduler: fakeScheduler } = createFakeScheduler(
287
328
  () => {
288
329
  if (scheduledDispatchRef === void 0) {
289
- throw new Error("[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed");
330
+ throw new LunoraError(
331
+ "INTERNAL",
332
+ "[fake-scheduler] dispatch not yet available — scheduler.advance called before harness construction completed"
333
+ );
290
334
  }
291
335
  return scheduledDispatchRef;
292
336
  },
293
337
  () => {
294
338
  if (mutationContextRef === void 0) {
295
- throw new Error("[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed");
339
+ throw new LunoraError(
340
+ "INTERNAL",
341
+ "[fake-scheduler] mutationContext not yet available — scheduler.advance called before harness construction completed"
342
+ );
296
343
  }
297
344
  return mutationContextRef;
298
345
  },
299
- () => functionRegistryMap
346
+ () => functionRegistryMap,
347
+ harnessNow
300
348
  );
301
349
  const makeHarness = (identity) => {
302
350
  const auth = {
@@ -308,21 +356,27 @@ const lunoraTest = (schema, options) => {
308
356
  const queryContext = {
309
357
  auth,
310
358
  db: database,
359
+ env: options?.env,
311
360
  log: noopLog,
361
+ now: harnessNow,
312
362
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runQuery, after construction completes
313
363
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
364
+ secrets: stubProxy("secrets"),
314
365
  storage: stubProxy("storage"),
315
366
  vectors: stubProxy("vectors")
316
367
  };
317
368
  const mutationContext = {
318
369
  auth,
319
370
  db: database,
371
+ env: options?.env,
320
372
  log: noopLog,
373
+ now: harnessNow,
321
374
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runMutation, after construction completes
322
375
  runMutation: (reference, args) => runInternal("mutation", reference, mutationContext, args),
323
376
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runQuery, after construction completes
324
377
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
325
378
  scheduler: fakeScheduler,
379
+ secrets: stubProxy("secrets"),
326
380
  storage: stubProxy("storage"),
327
381
  vectors: stubProxy("vectors"),
328
382
  workflows: stubProxy("workflows")
@@ -331,9 +385,11 @@ const lunoraTest = (schema, options) => {
331
385
  const actionContext = {
332
386
  auth,
333
387
  db: database,
388
+ env: options?.env,
334
389
  // Use the injected fetch when provided; fall back to the v1 stub otherwise.
335
390
  fetch: options?.fetch ?? stubProxy("fetch"),
336
391
  log: noopLog,
392
+ now: harnessNow,
337
393
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runAction, after construction completes
338
394
  runAction: (reference, args) => runInternal("action", reference, actionContext, args),
339
395
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runMutation, after construction completes
@@ -341,6 +397,7 @@ const lunoraTest = (schema, options) => {
341
397
  // eslint-disable-next-line @typescript-eslint/no-use-before-define -- lazy closure: `runInternal` is invoked only when a handler calls ctx.runQuery, after construction completes
342
398
  runQuery: (reference, args) => runInternal("query", reference, queryContext, args),
343
399
  scheduler: fakeScheduler,
400
+ secrets: stubProxy("secrets"),
344
401
  storage: stubProxy("storage"),
345
402
  vectors: stubProxy("vectors"),
346
403
  workflows: stubProxy("workflows")
@@ -348,11 +405,12 @@ const lunoraTest = (schema, options) => {
348
405
  const runRegistered = (expected, reference, context, args, allowInternal) => {
349
406
  const kind = registeredFunctionKind(reference);
350
407
  if (kind !== expected) {
351
- throw new Error(`expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
408
+ throw new LunoraError("INTERNAL", `expected a registered ${expected}, received a ${kind ?? "non-function"} reference`);
352
409
  }
353
410
  if (!allowInternal && registeredFunctionVisibility(reference) === "internal") {
354
- throw new Error(
355
- `"${expected}" is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${expected.charAt(0).toUpperCase()}${expected.slice(1)} from another function instead.`
411
+ throw new LunoraError(
412
+ "INTERNAL",
413
+ `This ${expected} is an internal function — it is unreachable from the external RPC boundary in production. Call it through ctx.run${expected.charAt(0).toUpperCase()}${expected.slice(1)} from another function instead.`
356
414
  );
357
415
  }
358
416
  return Promise.resolve(reference.handler(context, args ?? {}));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/testing",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.40",
4
4
  "description": "Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -25,7 +25,7 @@
25
25
  "directory": "packages/testing"
26
26
  },
27
27
  "files": [
28
- "dist",
28
+ "./dist",
29
29
  "README.md",
30
30
  "LICENSE.md",
31
31
  "__assets__"
@@ -46,9 +46,10 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/do": "1.0.0-alpha.4",
50
- "@lunora/mail": "1.0.0-alpha.1",
51
- "@lunora/server": "1.0.0-alpha.1"
49
+ "@lunora/do": "1.0.0-alpha.28",
50
+ "@lunora/errors": "1.0.0-alpha.4",
51
+ "@lunora/mail": "1.0.0-alpha.14",
52
+ "@lunora/server": "1.0.0-alpha.23"
52
53
  },
53
54
  "engines": {
54
55
  "node": "^22.15.0 || >=24.11.0"