@motujs/runtime 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,804 @@
1
+ import { MotuError } from './index.js';
2
+ import { beginCall, reachOwner, recordOutbound } from '@motujs/core';
3
+ import { MockTransport } from './mock.js';
4
+ /** Where unscoped requests accumulate for a check to read back after a run. Reset per fake instance. */
5
+ const registryKey = '__motuUnscopedRequests';
6
+ function recordUnscoped(entry) {
7
+ const g = globalThis;
8
+ (g[registryKey] ??= []).push(entry);
9
+ }
10
+ /** Read back (and optionally clear) everything recorded since the page loaded or the last read. */
11
+ export function readUnscopedRequests(clear = true) {
12
+ const g = globalThis;
13
+ const found = g[registryKey] ?? [];
14
+ if (clear)
15
+ g[registryKey] = [];
16
+ return found;
17
+ }
18
+ /**
19
+ * How many requests the fake answered at all, scoped or not — what `readUnscopedRequests().length ===
20
+ * 0` needs alongside it before it can count as a pass. A check that examined nothing has not proven
21
+ * "every request was covered"; it has proven nothing ran. Reported as `seen` so the CLI's own
22
+ * "0 examined is a skip, not an ok" rule applies here automatically, same as everywhere else.
23
+ */
24
+ const countKey = '__motuFakeFetchRequestCount';
25
+ function recordSeen() {
26
+ const g = globalThis;
27
+ g[countKey] = (g[countKey] ?? 0) + 1;
28
+ }
29
+ /**
30
+ * Requests the fake DELEGATED that then failed — an app route nobody stubbed, most often.
31
+ *
32
+ * Read alongside the unscoped list: unscoped means "the fake saw it and had nothing for it", this
33
+ * means "the fake never claimed it and the real answer was an error". Both say the island is standing
34
+ * on something that is not there; they differ only in whether `appRoutes`/`baseUrl` reached it.
35
+ */
36
+ export function readUnansweredRequests(clear = true) {
37
+ const g = globalThis;
38
+ const found = g.__motuUnansweredRequests ?? [];
39
+ if (clear)
40
+ g.__motuUnansweredRequests = [];
41
+ return found;
42
+ }
43
+ export function readFakeFetchRequestCount(clear = true) {
44
+ const g = globalThis;
45
+ const n = g[countKey] ?? 0;
46
+ if (clear)
47
+ g[countKey] = 0;
48
+ return n;
49
+ }
50
+ /**
51
+ * WHAT THIS ISLAND NEEDS FROM THE BACKEND, observed rather than declared.
52
+ *
53
+ * Mocking at the wire took something away. When each service module was stood down by name, an
54
+ * island's `ambient` said `@/lib/services/challenges` and you could read its data dependency off the
55
+ * contract. Now the service runs for real and only `@/lib/supabase/client` is stood down —
56
+ * transitively, several modules below the component — so `ambient` (which reads DIRECT imports) can
57
+ * no longer see it. The dependency did not disappear; it stopped being legible.
58
+ *
59
+ * This is the replacement, and it is strictly better than what it replaces: a table-and-RPC list is
60
+ * what the island actually needs, at the granularity assay's `.assay/operations.json` already speaks
61
+ * (tables read, tables written, functions called) — which is the seam a motu↔assay contract-drift
62
+ * check would compare across.
63
+ *
64
+ * Recorded for every request, INCLUDING ones no fixture answered: what the island reached for is the
65
+ * dependency, whether or not this lagoon happened to have data for it.
66
+ */
67
+ const reachKey = '__motuDataReach';
68
+ /**
69
+ * Who is reaching, right now.
70
+ *
71
+ * `reachOwner()` lives in @motujs/core beside the island window `traced` already uses, so a host-module
72
+ * call and a backend reach agree about who asked. Imported lazily-safe: this module is also loaded in
73
+ * plain node by the fixture tests, where no window is ever open and the answer is `unattributed`.
74
+ */
75
+ function ownerNow() {
76
+ try {
77
+ return reachOwner() ?? 'unattributed';
78
+ }
79
+ catch {
80
+ return 'unattributed';
81
+ }
82
+ }
83
+ /** One reach, as a DECLARATION spells it — the string an `ambient` or `reaches` entry is compared to. */
84
+ function reachEntry(kind, name, method) {
85
+ if (kind === 'table')
86
+ return method ? `table:${name}(${method})` : `table:${name}`;
87
+ if (kind === 'rpc')
88
+ return `rpc:${name}`;
89
+ if (kind === 'function')
90
+ return `fn:${name}`;
91
+ return `route:${method} ${name}`;
92
+ }
93
+ function recordReach(kind, name, method) {
94
+ const g = globalThis;
95
+ const reach = (g[reachKey] ??= { tables: {}, rpcs: [], functions: [], routes: [], by: {} });
96
+ // BACK-FILLED, because a reach recorded before this field existed (a page held open across a
97
+ // reload, an older bundle) would otherwise crash the split on `undefined`.
98
+ reach.by ??= {};
99
+ // AT REQUEST TIME, like `traced` reads its island: a fetch starts inside the owner's window and
100
+ // resolves long after it has closed, so asking afterwards attributes everything to nobody.
101
+ const owner = ownerNow();
102
+ const declaredForm = reachEntry(kind, name, method);
103
+ // The unified ledger gets the SAME string `data-reach` compares against declarations, so one readout
104
+ // can show a wire reach beside a contract call without translating between two vocabularies.
105
+ recordOutbound('wire', declaredForm);
106
+ const mine = (reach.by[owner] ??= []);
107
+ if (!mine.includes(declaredForm))
108
+ mine.push(declaredForm);
109
+ if (kind === 'rpc' || kind === 'function' || kind === 'route') {
110
+ const list = kind === 'rpc' ? reach.rpcs : kind === 'function' ? reach.functions : reach.routes;
111
+ const entry = kind === 'route' ? `${method} ${name}` : name;
112
+ if (!list.includes(entry))
113
+ list.push(entry);
114
+ return;
115
+ }
116
+ const methods = (reach.tables[name] ??= []);
117
+ if (method && !methods.includes(method))
118
+ methods.push(method);
119
+ }
120
+ const callsKey = '__motuWireCalls';
121
+ /**
122
+ * A ring, because a lagoon left open re-fetches forever and an unbounded log is a leak. 200 is far
123
+ * more than any one interaction produces (the largest here was 22) and small enough to render.
124
+ */
125
+ const MAX_CALLS = 200;
126
+ let callSeq = 0;
127
+ /**
128
+ * Bounded, and never the whole body.
129
+ *
130
+ * 20 kB rather than 2, because the panel EXPANDS these now: a row you can open to read the payload is
131
+ * only worth opening if the payload is there, and the largest real one here — a regenerated séance
132
+ * series — is about 21 kB. The ring above bounds the count; this bounds each entry, and the product
133
+ * is the worst case. It is a dev overlay in a page that already holds the application.
134
+ */
135
+ function summarise(value, max = 20_000) {
136
+ if (value === undefined || value === null)
137
+ return value;
138
+ try {
139
+ const json = JSON.stringify(value);
140
+ if (json === undefined)
141
+ return String(value);
142
+ return json.length <= max ? JSON.parse(json) : `${json.slice(0, max)}… (${json.length} chars)`;
143
+ }
144
+ catch {
145
+ return String(value);
146
+ }
147
+ }
148
+ /**
149
+ * Told when a call lands.
150
+ *
151
+ * WITHOUT THIS THE PANEL IS A LIE OF OMISSION. The lens repaints on STORE WRITES, and a call that
152
+ * changes no region key writes nothing — so a save whose refetch returns identical data left the
153
+ * Network list showing the state before it, and the person who just pressed the button concluded
154
+ * nothing had been sent. Which is the exact confusion this section exists to end.
155
+ */
156
+ const callListeners = new Set();
157
+ /** Notified whenever an intercepted call is recorded. Returns an unsubscribe. */
158
+ export function subscribeWireCalls(cb) {
159
+ callListeners.add(cb);
160
+ return () => callListeners.delete(cb);
161
+ }
162
+ const gestureKey = '__motuGesture';
163
+ /**
164
+ * How long after a click a request still counts as ITS request.
165
+ *
166
+ * Generous on purpose. A save is a write, a refetch and everything the refetch fans out into — on a
167
+ * real region that was 22 requests over most of a second — and cutting the tail off would split one
168
+ * action across two groups, which is worse than occasionally adopting a stray poll.
169
+ */
170
+ const GESTURE_WINDOW_MS = 2500;
171
+ /** The gesture in force, if one is recent enough to own what happens next. */
172
+ function gestureNow() {
173
+ const g = globalThis[gestureKey];
174
+ if (!g || typeof g.label !== 'string' || typeof g.at !== 'number')
175
+ return undefined;
176
+ if (Date.now() - g.at > GESTURE_WINDOW_MS)
177
+ return undefined;
178
+ return { label: g.label, id: typeof g.id === 'number' ? g.id : 0 };
179
+ }
180
+ let gestureSeq = 0;
181
+ /**
182
+ * Say what the person just did, so the calls that follow can be grouped under it.
183
+ *
184
+ * Written through a global rather than an import because the DOCK sets it and the dock is a separate
185
+ * package that this one must not depend on — the same shape `reachOwner` and the request counter
186
+ * already use. Dev-only by construction: nothing sets it unless the overlay is mounted.
187
+ */
188
+ export function noteGesture(label) {
189
+ globalThis[gestureKey] = { label, at: Date.now(), id: ++gestureSeq };
190
+ }
191
+ function recordCall(call) {
192
+ const g = globalThis;
193
+ const calls = (g[callsKey] ??= []);
194
+ const gesture = gestureNow();
195
+ calls.push({
196
+ ...call,
197
+ ...(gesture ? { gesture: gesture.label, gestureId: gesture.id } : {}),
198
+ seq: ++callSeq,
199
+ at: Date.now(),
200
+ });
201
+ if (calls.length > MAX_CALLS)
202
+ calls.splice(0, calls.length - MAX_CALLS);
203
+ // A listener that throws is a panel's bug, not this module's: one bad subscriber must not stop the
204
+ // fake from answering the request the application is waiting on.
205
+ callListeners.forEach((l) => {
206
+ try {
207
+ l();
208
+ }
209
+ catch {
210
+ /* the panel's problem */
211
+ }
212
+ });
213
+ }
214
+ /** Every intercepted call, oldest first. Does NOT clear: a panel polls it, and a flow may read it twice. */
215
+ export function readWireCalls() {
216
+ const g = globalThis;
217
+ return [...(g[callsKey] ?? [])];
218
+ }
219
+ export function clearWireCalls() {
220
+ globalThis[callsKey] = [];
221
+ }
222
+ export function readDataReach(clear = true) {
223
+ const g = globalThis;
224
+ const found = g[reachKey] ?? { tables: {}, rpcs: [], functions: [], routes: [], by: {} };
225
+ if (clear)
226
+ g[reachKey] = { tables: {}, rpcs: [], functions: [], routes: [], by: {} };
227
+ return found;
228
+ }
229
+ function parseFilterValue(raw) {
230
+ const dot = raw.indexOf('.');
231
+ if (dot === -1)
232
+ return null;
233
+ return { op: raw.slice(0, dot), value: raw.slice(dot + 1) };
234
+ }
235
+ /** `col=in.(a,b,c)` → `['a','b','c']`; PostgREST's own list syntax, not JSON. */
236
+ function parseInList(value) {
237
+ const inner = value.startsWith('(') && value.endsWith(')') ? value.slice(1, -1) : value;
238
+ return inner.length ? inner.split(',') : [];
239
+ }
240
+ function coerce(value) {
241
+ if (value === 'null')
242
+ return null;
243
+ if (value === 'true')
244
+ return true;
245
+ if (value === 'false')
246
+ return false;
247
+ return value;
248
+ }
249
+ function matchesOp(rowValue, op, rawValue) {
250
+ switch (op) {
251
+ case 'eq':
252
+ return rowValue === coerce(rawValue);
253
+ case 'is':
254
+ return rowValue === coerce(rawValue);
255
+ case 'neq':
256
+ return rowValue !== coerce(rawValue);
257
+ case 'gte':
258
+ return typeof rowValue === 'string' || typeof rowValue === 'number' ? rowValue >= coerceOrdered(rowValue, rawValue) : false;
259
+ case 'gt':
260
+ return typeof rowValue === 'string' || typeof rowValue === 'number' ? rowValue > coerceOrdered(rowValue, rawValue) : false;
261
+ case 'lte':
262
+ return typeof rowValue === 'string' || typeof rowValue === 'number' ? rowValue <= coerceOrdered(rowValue, rawValue) : false;
263
+ case 'lt':
264
+ return typeof rowValue === 'string' || typeof rowValue === 'number' ? rowValue < coerceOrdered(rowValue, rawValue) : false;
265
+ case 'in':
266
+ return parseInList(rawValue).some((v) => rowValue === coerce(v));
267
+ default:
268
+ return false;
269
+ }
270
+ }
271
+ /** Compare like-to-like: a numeric row value against a numeric literal, else lexical string compare. */
272
+ function coerceOrdered(rowValue, rawValue) {
273
+ return typeof rowValue === 'number' ? Number(rawValue) : rawValue;
274
+ }
275
+ /**
276
+ * `not.<op>.<value>` — only the shape actually used (`not.is.null`) is supported; anything else is
277
+ * left unrecognized so the caller can report it rather than silently mis-evaluating.
278
+ */
279
+ function parseNot(raw) {
280
+ if (raw === 'is.null')
281
+ return 'not.is.null';
282
+ return null;
283
+ }
284
+ /** One clause of `or=(a.is.null,b.gt.5)` — reuses the same operator vocabulary as a plain filter. */
285
+ function evalOrClause(row, clause) {
286
+ const [column, ...rest] = clause.split('.');
287
+ const opValue = rest.join('.');
288
+ const parsed = parseFilterValue(opValue);
289
+ if (!parsed || !column)
290
+ return null;
291
+ return matchesOp(row[column], parsed.op, parsed.value);
292
+ }
293
+ const KNOWN_PARAMS = new Set(['select', 'order', 'limit', 'offset', 'or']);
294
+ /**
295
+ * Apply every recognized filter param to a table's rows. Returns `null` (not an error) the moment an
296
+ * UNRECOGNIZED filter shows up, so the caller can report the request as unscoped instead of returning
297
+ * a silently-wrong subset — a filter this fake does not understand is exactly the "nothing was scoped
298
+ * for this" case the whole design exists to surface.
299
+ */
300
+ function applyFilters(rows, params) {
301
+ let result = rows;
302
+ for (const [key, raw] of params.entries()) {
303
+ if (KNOWN_PARAMS.has(key))
304
+ continue;
305
+ if (key === 'not')
306
+ continue; // handled per-column below via the `col=not.is.null` form instead
307
+ if (raw.startsWith('not.')) {
308
+ const notOp = parseNot(raw.slice(4));
309
+ if (!notOp)
310
+ return null;
311
+ result = result.filter((r) => r[key] !== null);
312
+ continue;
313
+ }
314
+ const parsed = parseFilterValue(raw);
315
+ if (!parsed)
316
+ return null;
317
+ if (!['eq', 'is', 'neq', 'gte', 'gt', 'lte', 'lt', 'in'].includes(parsed.op))
318
+ return null;
319
+ result = result.filter((r) => matchesOp(r[key], parsed.op, parsed.value));
320
+ }
321
+ const or = params.get('or');
322
+ if (or !== null) {
323
+ const inner = or.startsWith('(') && or.endsWith(')') ? or.slice(1, -1) : or;
324
+ const clauses = inner.split(',');
325
+ const evaluated = clauses.map((c) => evalOrClause({}, c)); // shape check
326
+ if (evaluated.some((v) => v === null))
327
+ return null;
328
+ result = result.filter((r) => clauses.some((c) => evalOrClause(r, c) === true));
329
+ }
330
+ return result;
331
+ }
332
+ function applyOrder(rows, order) {
333
+ if (!order)
334
+ return rows;
335
+ const parts = order.split(',').map((p) => {
336
+ const [column, direction] = p.split('.');
337
+ return { column, desc: direction === 'desc' };
338
+ });
339
+ return [...rows].sort((a, b) => {
340
+ for (const { column, desc } of parts) {
341
+ const av = a[column];
342
+ const bv = b[column];
343
+ if (av === bv)
344
+ continue;
345
+ const cmp = av == null ? -1 : bv == null ? 1 : av < bv ? -1 : 1;
346
+ return desc ? -cmp : cmp;
347
+ }
348
+ return 0;
349
+ });
350
+ }
351
+ /** `select=` as this fake understands it: flat columns, `*`, or an embed passed through verbatim —
352
+ * it does not JOIN, it trusts the fixture row already carries the embedded shape if one is selected. */
353
+ function applySelect(rows, select) {
354
+ if (!select || select === '*')
355
+ return rows;
356
+ const cols = select.split(',').map((c) => c.trim());
357
+ if (cols.some((c) => c.includes('(')))
358
+ return rows; // an embed: trust the fixture's own shape
359
+ return rows.map((r) => Object.fromEntries(cols.map((c) => [c, r[c]])));
360
+ }
361
+ /** Where a RELATIVE url resolves from. A browser has `location`; node (the `--fast` lane, and this
362
+ * module's own tests) does not, and `new Request('/api/x')` throws there rather than defaulting. */
363
+ function originBase() {
364
+ return typeof location !== 'undefined' && location.href ? location.href : 'http://lagoon.local';
365
+ }
366
+ /**
367
+ * SYNCHRONOUS, and that is load-bearing rather than tidiness.
368
+ *
369
+ * A reach is attributed to whoever's window is open when the request is MADE (see `ownerNow`). This
370
+ * function does no awaiting — it never did — but being `async` meant the caller's `await` yielded a
371
+ * microtask before any `recordReach` ran, by which time `runWithIsland`/`runWithSource` had already
372
+ * restored the previous owner and every reach recorded as `unattributed`. Keep the whole path from
373
+ * the fake's entry to `recordReach` synchronous.
374
+ */
375
+ function normalize(input, init) {
376
+ // Resolved against the origin BEFORE constructing the Request, because an app route is called with
377
+ // a relative path (`fetch('/api/…')`) and that is exactly the case this fake was extended for.
378
+ const absolute = input instanceof Request ? input : new URL(String(input), originBase()).href;
379
+ const req = absolute instanceof Request ? absolute : new Request(absolute, init);
380
+ const url = new URL(req.url);
381
+ let body = undefined;
382
+ if (init?.body !== undefined) {
383
+ try {
384
+ body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
385
+ }
386
+ catch {
387
+ body = init.body;
388
+ }
389
+ }
390
+ return { method: (init?.method ?? req.method ?? 'GET').toUpperCase(), path: url.pathname, url, headers: req.headers, body };
391
+ }
392
+ function jsonResponse(body, status, headers = {}) {
393
+ return new Response(body === undefined ? null : JSON.stringify(body), {
394
+ status,
395
+ headers: { 'Content-Type': 'application/json', ...headers },
396
+ });
397
+ }
398
+ function postgrestError(status, message) {
399
+ return jsonResponse({ message, details: null, hint: null, code: String(status) }, status);
400
+ }
401
+ /**
402
+ * Build a `fetch`-compatible function backed entirely by declared tables and fixtures. Anything it
403
+ * cannot resolve is recorded via `readUnscopedRequests()` and answered with a real-shaped 404 — the
404
+ * app's own error handling runs for real against it, same as it would against an actual gap in a real
405
+ * backend, rather than the harness hanging or throwing somewhere the app never catches.
406
+ */
407
+ /**
408
+ * The target a request is ABOUT, spelled the way a declaration spells it.
409
+ *
410
+ * Derived from the path rather than reported by the handler, so the log records a call even when the
411
+ * handler never ran — an origin outside `baseUrl`, an unrecognised path. Those are exactly the
412
+ * requests worth seeing: they are the ones that silently answered 404.
413
+ */
414
+ function targetOf(req, appRoutes) {
415
+ if (appRoutes.some((p) => req.path.startsWith(p)))
416
+ return `route:${req.method} ${req.path}`;
417
+ if (req.path.startsWith('/auth/v1/'))
418
+ return `auth:${req.path.slice('/auth/v1/'.length)}`;
419
+ if (req.path.startsWith('/functions/v1/'))
420
+ return `fn:${req.path.slice('/functions/v1/'.length)}`;
421
+ if (req.path.startsWith('/rest/v1/rpc/'))
422
+ return `rpc:${req.path.slice('/rest/v1/rpc/'.length)}`;
423
+ if (req.path.startsWith('/rest/v1/'))
424
+ return `table:${req.path.slice('/rest/v1/'.length).split('?')[0]}`;
425
+ return 'unparsed';
426
+ }
427
+ /** What was SENT: an RPC's arguments or a write's row, else the query that scoped a read. */
428
+ function requestOf(req) {
429
+ if (req.body !== undefined && req.body !== null)
430
+ return req.body;
431
+ const query = req.url.search.replace(/^\?/, '');
432
+ return query === '' ? undefined : query;
433
+ }
434
+ /**
435
+ * What came BACK, as one line. Never the body: a read can answer with a whole table, and a log that
436
+ * holds every row it ever saw is a memory leak wearing a diagnostic's clothes.
437
+ */
438
+ async function responseOf(res) {
439
+ if (res.status === 204)
440
+ return 'no content';
441
+ try {
442
+ const text = await res.clone().text();
443
+ if (text === '')
444
+ return undefined;
445
+ const parsed = JSON.parse(text);
446
+ if (Array.isArray(parsed))
447
+ return `${parsed.length} row(s)`;
448
+ if (parsed && typeof parsed === 'object') {
449
+ const obj = parsed;
450
+ // PostgREST spells an error `{ message }`; the app's own RPCs answer `{ success, error }`.
451
+ if (typeof obj.message === 'string')
452
+ return obj.message;
453
+ if (obj.success === false)
454
+ return `refused: ${String(obj.error ?? 'unknown')}`;
455
+ return Object.keys(obj).slice(0, 6).join(', ') || 'object';
456
+ }
457
+ return String(parsed);
458
+ }
459
+ catch {
460
+ return undefined;
461
+ }
462
+ }
463
+ export function createPostgrestFetch(options = {}) {
464
+ const tables = options.tables ?? {};
465
+ const transport = new MockTransport(options.fixtures ?? []);
466
+ const motuFakeFetch = async function motuFakeFetch(input, init) {
467
+ const req = normalize(input, init);
468
+ recordSeen();
469
+ // LOGGED HERE, not in each handler, and for the same reason the handlers do their own
470
+ // `recordReach`: this is the one place that sees both the request and the response it produced.
471
+ // Attribution is read BEFORE awaiting — a fetch starts inside an island's window and resolves
472
+ // long after it has closed, so asking afterwards credits everything to nobody.
473
+ const by = ownerNow();
474
+ const target = targetOf(req, options.appRoutes ?? []);
475
+ const url = req.url.pathname + req.url.search;
476
+ // BEFORE the await, like everything else attributed here: the owner window is open now and shut
477
+ // by the time the answer comes back.
478
+ const landed = beginCall({ via: 'wire', url, method: req.method, label: target });
479
+ const res = await dispatch(req);
480
+ const response = await responseOf(res);
481
+ landed({ status: res.status, ok: res.ok, ...(res.ok ? {} : { error: response ?? '' }), ...(declaredFailures.has(res) ? { declared: true } : {}) });
482
+ recordCall({
483
+ target,
484
+ method: req.method,
485
+ url,
486
+ by,
487
+ request: summarise(requestOf(req)),
488
+ status: res.status,
489
+ response,
490
+ });
491
+ return res;
492
+ };
493
+ const dispatch = async (req) => {
494
+ // BEFORE the baseUrl guard, deliberately: an application route is SAME-ORIGIN, so it never
495
+ // matches the database origin and would otherwise be rejected as "outside baseUrl".
496
+ if ((options.appRoutes ?? []).some((p) => req.path.startsWith(p)))
497
+ return handleAppRoute(req, transport);
498
+ if (options.baseUrl && !req.url.href.startsWith(options.baseUrl)) {
499
+ recordUnscoped({ method: req.method, path: req.path, target: 'unparsed', reason: `origin outside baseUrl (${req.url.origin})` });
500
+ return postgrestError(404, `motu: fake fetch has no baseUrl match for ${req.url.origin}`);
501
+ }
502
+ if (req.path.startsWith('/auth/v1/'))
503
+ return handleAuth(req, options.auth);
504
+ if (req.path.startsWith('/functions/v1/'))
505
+ return handleFunction(req, transport);
506
+ if (req.path.startsWith('/rest/v1/rpc/'))
507
+ return handleRpc(req, transport);
508
+ if (req.path.startsWith('/rest/v1/'))
509
+ return handleRest(req, tables, transport);
510
+ recordUnscoped({ method: req.method, path: req.path, target: 'unparsed', reason: 'path matches no known PostgREST/auth route' });
511
+ return postgrestError(404, `motu: fake fetch does not recognize path ${req.path}`);
512
+ };
513
+ // STAMPED WITH WHAT IT CLAIMS, so `installFakeFetch(fake)` does not have to be told again.
514
+ //
515
+ // The two took `appRoutes` separately and a caller wrote it twice — and a divergence was silent in
516
+ // both directions: a route the installer claimed but the fake did not fell into the `baseUrl` guard
517
+ // and 404'd as unscoped; a route the fake claimed but the installer did not never reached it at all,
518
+ // went to the dev server, and landed in `readUnansweredRequests()`. The fake is the one that KNOWS,
519
+ // so it is the one that says.
520
+ motuFakeFetch[WIRE_CLAIMS] = {
521
+ appRoutes: options.appRoutes,
522
+ baseUrl: options.baseUrl,
523
+ };
524
+ return motuFakeFetch;
525
+ }
526
+ const WIRE_CLAIMS = Symbol.for('motu.wire.claims');
527
+ /** What `createPostgrestFetch` stamped on a fake — its own claim, not a second copy of it. */
528
+ export function fakeFetchClaims(fake) {
529
+ return fake[WIRE_CLAIMS];
530
+ }
531
+ async function handleAuth(req, auth) {
532
+ if (req.path === '/auth/v1/user' && req.method === 'GET') {
533
+ if (auth?.user !== undefined)
534
+ return jsonResponse(auth.user, 200);
535
+ recordUnscoped({ method: req.method, path: req.path, target: 'auth:getUser', reason: 'no `auth.user` declared' });
536
+ return postgrestError(401, 'motu: fake fetch has no `auth.user` declared');
537
+ }
538
+ if (req.path === '/auth/v1/session' && req.method === 'GET') {
539
+ if (auth?.session !== undefined)
540
+ return jsonResponse(auth.session, 200);
541
+ recordUnscoped({ method: req.method, path: req.path, target: 'auth:getSession', reason: 'no `auth.session` declared' });
542
+ return postgrestError(401, 'motu: fake fetch has no `auth.session` declared');
543
+ }
544
+ recordUnscoped({ method: req.method, path: req.path, target: 'unparsed', reason: 'unrecognized auth route' });
545
+ return postgrestError(404, `motu: fake fetch does not recognize auth route ${req.path}`);
546
+ }
547
+ /**
548
+ * An EDGE FUNCTION (`supabase.functions.invoke`), which reaches here for free: the client hands its
549
+ * `customFetch` to the functions client too, so an invoke is already flowing through this fake — it
550
+ * was simply landing on an unrecognized path and being recorded as unscoped. A different protocol
551
+ * from PostgREST (an opaque POST with a JSON body), and a different one in the ledger's vocabulary,
552
+ * so it gets its own `data-reach` bucket rather than being folded in with `rpc`.
553
+ */
554
+ async function handleFunction(req, transport) {
555
+ const fn = req.path.slice('/functions/v1/'.length);
556
+ recordReach('function', fn);
557
+ try {
558
+ return jsonResponse(await transport.call(fn, 'invoke', [req.body]), 200);
559
+ }
560
+ catch (err) {
561
+ return fromTransportError(err, { method: req.method, path: req.path, target: `function:${fn}` });
562
+ }
563
+ }
564
+ /**
565
+ * A SAME-ORIGIN APPLICATION ROUTE — a Next `app/api/**` handler and the like, called with a relative
566
+ * url by ordinary `fetch`, never by the database client.
567
+ *
568
+ * This closes a gap `network-sealed` structurally cannot see: a relative request goes to the lagoon's
569
+ * OWN origin, so it is loopback, so it is not an escape — it just 404s against the dev server, the
570
+ * caller catches it, and an empty state renders. acme's announcement banner did exactly that. Routed
571
+ * through the fixture layer, the same miss is an unscoped request and `fixture-coverage` says so.
572
+ */
573
+ async function handleAppRoute(req, transport) {
574
+ recordReach('route', req.path, req.method);
575
+ try {
576
+ const result = await transport.call(req.path, req.method, [req.body, Object.fromEntries(req.url.searchParams)]);
577
+ return jsonResponse(result, 200);
578
+ }
579
+ catch (err) {
580
+ return fromTransportError(err, { method: req.method, path: req.path, target: `route:${req.path}` });
581
+ }
582
+ }
583
+ async function handleRpc(req, transport) {
584
+ const fn = req.path.slice('/rest/v1/rpc/'.length);
585
+ recordReach('rpc', fn);
586
+ try {
587
+ const result = await transport.call(fn, 'rpc', [req.body]);
588
+ return jsonResponse(result, 200);
589
+ }
590
+ catch (err) {
591
+ return fromTransportError(err, { method: req.method, path: req.path, target: `rpc:${fn}` });
592
+ }
593
+ }
594
+ async function handleRest(req, tables, transport) {
595
+ const table = req.path.slice('/rest/v1/'.length).split('?')[0];
596
+ const singular = (req.headers.get('accept') ?? '').includes('vnd.pgrst.object');
597
+ const wantsCount = (req.headers.get('prefer') ?? '').includes('count=');
598
+ // HEAD IS A READ WITHOUT ITS BODY. supabase-js sends one for every `{ count: 'exact', head: true }`
599
+ // — "how many unread notifications" — and reads the answer from Content-Range alone. Refusing it
600
+ // with a 405 made every badge count in a lagoon an error the app's own code had to swallow.
601
+ const head = req.method === 'HEAD';
602
+ const answer = (res) => (head ? new Response(null, { status: res.status, headers: res.headers }) : res);
603
+ const range = (offset, n, total) =>
604
+ // PostgREST says `*/0` for an empty range; `0--1/0` is not a range at all.
605
+ ({ 'Content-Range': n > 0 ? `${offset}-${offset + n - 1}/${total}` : `*/${total}` });
606
+ if (req.method === 'GET' || head) {
607
+ recordReach('table', table, 'select');
608
+ // A read failure can be declared as a fixture (`{service: table, method: 'select', status,
609
+ // after}`) WITHOUT needing a matching table row shape — checked first, same call-counting `after`
610
+ // documented on `MockTransport`.
611
+ try {
612
+ const overridden = await transport.call(table, 'select', [Object.fromEntries(req.url.searchParams)]);
613
+ if (overridden !== undefined) {
614
+ const n = Array.isArray(overridden) ? overridden.length : 1;
615
+ return answer(jsonResponse(overridden, 200, wantsCount ? range(0, n, n) : {}));
616
+ }
617
+ }
618
+ catch (err) {
619
+ if (!isNoFixture(err))
620
+ return fromTransportError(err, { method: req.method, path: req.path, target: `table:${table}` });
621
+ // no fixture override declared — fall through to the generic table engine below
622
+ }
623
+ const declared = tables[table];
624
+ if (!declared) {
625
+ recordUnscoped({ method: req.method, path: req.path, target: `table:${table}`, reason: 'no table declared and no read fixture' });
626
+ return postgrestError(404, `motu: fake fetch has no table "${table}" declared`);
627
+ }
628
+ const rows = typeof declared.rows === 'function' ? declared.rows() : declared.rows;
629
+ const filtered = applyFilters(rows, req.url.searchParams);
630
+ if (filtered === null) {
631
+ recordUnscoped({ method: req.method, path: req.path, target: `table:${table}`, reason: `unrecognized filter in ${req.url.search}` });
632
+ return postgrestError(404, `motu: fake fetch does not understand a filter in ${req.url.search}`);
633
+ }
634
+ const ordered = applyOrder(filtered, req.url.searchParams.get('order'));
635
+ const total = ordered.length;
636
+ const limit = req.url.searchParams.has('limit') ? Number(req.url.searchParams.get('limit')) : undefined;
637
+ const offset = Number(req.url.searchParams.get('offset') ?? '0');
638
+ const paged = limit !== undefined ? ordered.slice(offset, offset + limit) : ordered.slice(offset);
639
+ const projected = applySelect(paged, req.url.searchParams.get('select'));
640
+ if (singular) {
641
+ if (projected.length === 0)
642
+ return answer(jsonResponse(null, 200));
643
+ return answer(jsonResponse(projected[0], 200));
644
+ }
645
+ const headers = wantsCount ? range(offset, projected.length, total) : {};
646
+ return answer(jsonResponse(projected, 200, headers));
647
+ }
648
+ // Writes: insert (POST), update (PATCH), delete (DELETE), upsert (POST + Prefer: resolution=...).
649
+ const prefer = req.headers.get('prefer') ?? '';
650
+ const isUpsert = req.method === 'POST' && prefer.includes('resolution=');
651
+ const method = isUpsert ? 'upsert' : req.method === 'POST' ? 'insert' : req.method === 'PATCH' ? 'update' : req.method === 'DELETE' ? 'delete' : null;
652
+ if (!method) {
653
+ recordUnscoped({ method: req.method, path: req.path, target: `table:${table}`, reason: `unsupported HTTP method ${req.method}` });
654
+ return postgrestError(405, `motu: fake fetch does not support ${req.method} on ${req.path}`);
655
+ }
656
+ // The semantic verb, not the HTTP one — `insert`/`update`/`upsert`/`delete` is the vocabulary
657
+ // assay's ledger uses for a write, and the whole point of this record is to be comparable to it.
658
+ recordReach('table', table, method);
659
+ try {
660
+ const result = await transport.call(table, method, [req.body, Object.fromEntries(req.url.searchParams)]);
661
+ if (prefer.includes('return=minimal'))
662
+ return new Response(null, { status: 204 });
663
+ return jsonResponse(result, method === 'insert' ? 201 : 200);
664
+ }
665
+ catch (err) {
666
+ return fromTransportError(err, { method: req.method, path: req.path, target: `table:${table}` });
667
+ }
668
+ }
669
+ function isNoFixture(err) {
670
+ return err instanceof MotuError && err.status === 404 && err.message.startsWith('mock: no fixture for');
671
+ }
672
+ /**
673
+ * Responses a fixture asked to fail. Remembered BESIDE the response rather than written into it: a
674
+ * header would be one more thing the application could see that the real backend never sends.
675
+ */
676
+ const declaredFailures = new WeakSet();
677
+ function fromTransportError(err, ctx) {
678
+ if (err instanceof MotuError) {
679
+ if (isNoFixture(err))
680
+ recordUnscoped({ ...ctx, reason: err.message });
681
+ const res = postgrestError(err.status, err.message);
682
+ if (err.declared)
683
+ declaredFailures.add(res);
684
+ return res;
685
+ }
686
+ recordUnscoped({ ...ctx, reason: err instanceof Error ? err.message : String(err) });
687
+ return postgrestError(500, 'motu: fake fetch fixture threw an unexpected error');
688
+ }
689
+ /**
690
+ * Patch `globalThis.fetch` so requests the app makes DIRECTLY — not through the database client —
691
+ * reach the fake too.
692
+ *
693
+ * Injecting `global: { fetch }` into `createClient` covers everything that client issues, which is
694
+ * most of an app's backend traffic and all of its PostgREST and edge-function traffic. It does not
695
+ * cover a service that calls `fetch('/api/…')` itself, because that is `window.fetch` and the client
696
+ * was never involved. Those requests are same-origin, so they are invisible to `network-sealed` as
697
+ * well: they 404 against the dev server, the caller catches it, an empty state renders, and every
698
+ * check stays green.
699
+ *
700
+ * DELEGATES BY DEFAULT. Only paths the fake actually claims are intercepted; everything else — the
701
+ * dev server's modules, HMR, assets, source maps — goes to the original `fetch` untouched. A lagoon
702
+ * that cannot load its own bundle would be a far worse failure than an unanswered route, so the
703
+ * narrow rule is the safe one.
704
+ *
705
+ * ONE PATCH, MANY FAKES. This used to guard on a global boolean, so the FIRST caller won and every
706
+ * later one was a silent no-op — fine while a project had one region with a wire, and wrong the moment
707
+ * it had two: the second region's routes went to the dev server, 404'd, its islands rendered empty and
708
+ * nothing said why. Fakes now REGISTER, and the single patch asks each in turn what it claims.
709
+ *
710
+ * Idempotent per fake: installing the same one twice leaves one entry, so a hot reload does not build
711
+ * a chain of patches or a chain of registrations.
712
+ */
713
+ export function installFakeFetch(fake, options) {
714
+ // The fake's OWN stamp is the default, so the routes are declared once, where they are implemented.
715
+ // An explicit `options` still wins — a caller wrapping someone else's fake has nowhere else to say.
716
+ const claims = options ?? fakeFetchClaims(fake) ?? {};
717
+ const registry = fakeRegistry();
718
+ if (registry.some((entry) => entry.fake === fake))
719
+ return;
720
+ registry.push({ fake, claims });
721
+ armFakeFetch();
722
+ }
723
+ function fakeRegistry() {
724
+ const g = globalThis;
725
+ return (g.__motuFakeFetches ??= []);
726
+ }
727
+ /** Every fake currently installed, in registration order — what a check reads to say who claimed what. */
728
+ export function installedFakeFetches() {
729
+ return fakeRegistry().map((entry) => entry.claims);
730
+ }
731
+ /** Forget every installed fake and leave the patch in place. For a test, or a lagoon remount. */
732
+ export function resetFakeFetches() {
733
+ fakeRegistry().length = 0;
734
+ }
735
+ function claimedBy(url) {
736
+ for (const { fake, claims } of fakeRegistry()) {
737
+ if (claims.baseUrl && url.href.startsWith(claims.baseUrl))
738
+ return fake;
739
+ if ((claims.appRoutes ?? []).some((prefix) => url.pathname.startsWith(prefix)))
740
+ return fake;
741
+ }
742
+ return undefined;
743
+ }
744
+ /**
745
+ * Patch `globalThis.fetch` NOW, with whatever is registered — and whatever registers later.
746
+ *
747
+ * Worth being separate from `installFakeFetch` for one reason: a client that captures `globalThis.fetch`
748
+ * at IMPORT time (`createClient(url, key, { global: { fetch } })` evaluated at module scope) keeps
749
+ * whatever `fetch` was when its module ran. If the fakes are declared rather than installed by a
750
+ * top-level side effect, that capture can happen first and the client talks past every fake, silently.
751
+ * Calling this at the top of the lagoon entry — before any application module is imported — closes it:
752
+ * the patch is in place from the start and the registry fills in behind it.
753
+ */
754
+ export function armFakeFetch() {
755
+ const g = globalThis;
756
+ if (g.__motuFakeFetchArmed)
757
+ return;
758
+ const original = globalThis.fetch.bind(globalThis);
759
+ // MOTU'S OWN REQUESTS GO AROUND THE APPLICATION'S FAKE. An app that claims `/api/` (Peps does)
760
+ // also claimed the host's `/api/coverage`, so the lens asked the app's mock for the corpus, got a
761
+ // 404, and never showed production coverage even where the host had it.
762
+ g.__motuNativeFetch = original;
763
+ globalThis.fetch = async (input, init) => {
764
+ let url;
765
+ try {
766
+ const raw = input instanceof Request ? input.url : String(input);
767
+ url = new URL(raw, typeof location !== 'undefined' ? location.href : 'http://localhost');
768
+ }
769
+ catch {
770
+ return original(input, init);
771
+ }
772
+ const fake = claimedBy(url);
773
+ if (fake)
774
+ return fake(input, init);
775
+ // DELEGATED, AND THEN WATCHED. Everything no fake claims goes to the real fetch, which in a lagoon
776
+ // is the dev server — and that is right for the dev server's own traffic. It is also where an
777
+ // unstubbed APP route goes: it 404s, the caller catches it, an empty state renders, and nothing
778
+ // says so. `network-sealed` cannot see it either, because it only counts requests that left for a
779
+ // non-loopback host.
780
+ //
781
+ // The signal is not "a same-origin 404" — favicons and source maps 404 all day. It is a request
782
+ // the app made, that NO STUB CLAIMED, whose real answer was an error. Recorded, not blocked: the
783
+ // page still gets the response it would have got.
784
+ const res = await original(input, init);
785
+ // NOT JUST `!res.ok`. A Vite dev server answers an unknown path with its SPA fallback — 200, and
786
+ // `index.html` — so an unstubbed API route comes back "successful" and the caller fails later
787
+ // trying to read JSON out of a web page. That was this gap's real shape: the first attempt looked
788
+ // for a 404 and found none. An HTML answer to a request the app made with `fetch` is the
789
+ // signature, because the dev server's own traffic (modules, css, source maps) is never HTML.
790
+ const html = (res.headers.get('content-type') ?? '').includes('text/html');
791
+ if (!res.ok || html) {
792
+ const g2 = globalThis;
793
+ (g2.__motuUnansweredRequests ??= []).push({
794
+ method: (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(),
795
+ url: url.pathname + url.search,
796
+ status: res.status,
797
+ why: html ? 'answered with the dev server\'s HTML fallback, not data' : `HTTP ${res.status}`,
798
+ });
799
+ }
800
+ return res;
801
+ };
802
+ g.__motuFakeFetchArmed = true;
803
+ }
804
+ //# sourceMappingURL=postgrest-fetch.js.map