ugly-app 0.1.708 → 0.1.710

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.
Files changed (51) hide show
  1. package/dist/cli/buildWorkers.d.ts +1 -0
  2. package/dist/cli/buildWorkers.d.ts.map +1 -1
  3. package/dist/cli/buildWorkers.js +17 -3
  4. package/dist/cli/buildWorkers.js.map +1 -1
  5. package/dist/cli/schemaGen.d.ts.map +1 -1
  6. package/dist/cli/schemaGen.js +5 -12
  7. package/dist/cli/schemaGen.js.map +1 -1
  8. package/dist/cli/version.d.ts +1 -1
  9. package/dist/cli/version.js +1 -1
  10. package/dist/native/conformance/harness.d.ts.map +1 -1
  11. package/dist/native/conformance/harness.js +62 -0
  12. package/dist/native/conformance/harness.js.map +1 -1
  13. package/dist/native/contract-meta.d.ts.map +1 -1
  14. package/dist/native/contract-meta.js +2 -1
  15. package/dist/native/contract-meta.js.map +1 -1
  16. package/dist/native/contract.d.ts +7 -0
  17. package/dist/native/contract.d.ts.map +1 -1
  18. package/dist/native/contract.js.map +1 -1
  19. package/dist/native/proxy.d.ts.map +1 -1
  20. package/dist/native/proxy.js +26 -5
  21. package/dist/native/proxy.js.map +1 -1
  22. package/dist/server/PostgresSchema.d.ts +5 -3
  23. package/dist/server/PostgresSchema.d.ts.map +1 -1
  24. package/dist/server/PostgresSchema.js +15 -3
  25. package/dist/server/PostgresSchema.js.map +1 -1
  26. package/dist/server/SchemaCheck.d.ts.map +1 -1
  27. package/dist/server/SchemaCheck.js +7 -6
  28. package/dist/server/SchemaCheck.js.map +1 -1
  29. package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
  30. package/dist/server/adapter/workers/createWorkersApp.js +13 -0
  31. package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
  32. package/dist/server/schemaIndexes.d.ts +20 -0
  33. package/dist/server/schemaIndexes.d.ts.map +1 -0
  34. package/dist/server/schemaIndexes.js +24 -0
  35. package/dist/server/schemaIndexes.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/cli/buildWorkers.rewrite.test.ts +35 -0
  38. package/src/cli/buildWorkers.ts +17 -3
  39. package/src/cli/schemaGen.ts +7 -16
  40. package/src/cli/version.ts +1 -1
  41. package/src/native/conformance/harness.ts +62 -0
  42. package/src/native/contract-meta.browse.test.ts +11 -0
  43. package/src/native/contract-meta.ts +2 -1
  44. package/src/native/contract.ts +5 -0
  45. package/src/native/facades/browse.test.ts +64 -0
  46. package/src/native/proxy.ts +24 -5
  47. package/src/server/PostgresSchema.ts +20 -3
  48. package/src/server/SchemaCheck.ts +11 -6
  49. package/src/server/adapter/workers/createWorkersApp.ts +13 -1
  50. package/src/server/schemaIndexes.test.ts +44 -0
  51. package/src/server/schemaIndexes.ts +44 -0
@@ -222,18 +222,32 @@ function injectTitle(html: string, title: string | undefined): string {
222
222
  return html.replace(/<title>[^<]*<\/title>/, `<title>${escaped}</title>`);
223
223
  }
224
224
 
225
- function rewriteHtmlForBuildId(html: string, buildId: string): string {
225
+ export function rewriteHtmlForBuildId(html: string, buildId: string): string {
226
226
  // Match `src="/path"` / `href="/path"` whose path starts with the
227
227
  // vite-emitted prefixes — `/assets/`, plus root-level static files
228
- // emitted by vite to `dist/client/` (icon.png, favicon.ico, etc.).
228
+ // emitted by vite to `dist/client/` (fonts, maps, ).
229
229
  // Replacing only these patterns avoids accidentally clobbering
230
230
  // hrefs to `/api/...` or to external URLs.
231
231
  return html.replace(
232
232
  /(\s(?:src|href)=")\/([^"]+)"/g,
233
233
  (full, attr: string, restPath: string) => {
234
+ // App icons (favicon / apple-touch-icon / root images) are captured and
235
+ // PERSISTED by other surfaces — the mobile & desktop dock, PWA installs,
236
+ // the ugly.bot app-store listing. They must stay at a stable, long-lived
237
+ // URL, so leave them at the root path. wrapWithAssets serves root images
238
+ // from the current build (revalidated by etag), so the icon stays fresh
239
+ // without ever rotting. Moving them under the per-deploy `/{buildId}/`
240
+ // prefix is what made a pinned `/{oldBuildId}/icon.png` 404→SPA-HTML once
241
+ // the app redeployed.
242
+ if (restPath.match(/^[^/]+\.(?:png|ico|svg|jpg|jpeg|gif|webp)$/i)) {
243
+ return full;
244
+ }
245
+ // Vite chunks + non-captured root assets (fonts, source maps) are
246
+ // content-/build-addressed and never referenced externally, so pin them
247
+ // under the buildId for immutable hard-caching.
234
248
  if (
235
249
  restPath.startsWith('assets/') ||
236
- restPath.match(/^[^/]+\.(?:png|ico|svg|jpg|jpeg|gif|webp|woff2?|ttf|map)$/i)
250
+ restPath.match(/^[^/]+\.(?:woff2?|ttf|map)$/i)
237
251
  ) {
238
252
  return `${attr}/${buildId}/${restPath}"`;
239
253
  }
@@ -4,6 +4,7 @@ import type { SchemaChange } from '../shared/SchemaDiff.js';
4
4
  import type { IndexDef } from '../shared/DB.js';
5
5
  import { disposeImportTs, importTs } from './uglyappConfig.js';
6
6
  import { checkSchemas, ensureSchemaSnapshotsTable } from '../server/SchemaCheck.js';
7
+ import { fieldIndexStatements } from '../server/schemaIndexes.js';
7
8
  import { closePg, initPg } from '../server/NodePool.js';
8
9
  import { serializeSchema } from '../shared/SchemaSerializer.js';
9
10
  import type { z } from 'zod';
@@ -142,22 +143,12 @@ export function generateMigrationCodeForNewCollection(
142
143
  collection: string,
143
144
  indexes?: IndexDef[],
144
145
  ): string {
145
- const indexStatements: string[] = [];
146
- for (const idx of indexes ?? []) {
147
- const fields = Object.keys(idx.fields);
148
- if (idx.unique && fields.length === 1) {
149
- const field = fields[0];
150
- indexStatements.push(
151
- ` await query(\`CREATE UNIQUE INDEX IF NOT EXISTS "idx_${collection}_${field}_unique" ON "${collection}" ((data->>'${field}'))\`);`,
152
- );
153
- }
154
- if (idx.ttl !== undefined) {
155
- const field = fields[0];
156
- indexStatements.push(
157
- ` await query(\`CREATE INDEX IF NOT EXISTS "idx_${collection}_${field}" ON "${collection}" ((data->>'${field}'))\`);`,
158
- );
159
- }
160
- }
146
+ // Emit a `((data->>'field'))` statement for every declared single-field index
147
+ // — plain *and* unique. (The previous hand-rolled loop only handled unique/ttl
148
+ // indexes, so a plain `{ fields: { userId: 1 } }` produced no index at all.)
149
+ const indexStatements = fieldIndexStatements(collection, indexes).map(
150
+ (sql) => ` await query(\`${sql}\`);`,
151
+ );
161
152
 
162
153
  return `import type { query as pgQuery } from 'ugly-app/server';
163
154
 
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.708";
2
+ export const CLI_VERSION = "0.1.710";
@@ -346,6 +346,68 @@ const PROBES: Probe[] = [
346
346
  assert(typeof page.content === 'string', 'browse.extract gave no content');
347
347
  },
348
348
  },
349
+ {
350
+ id: 'browse.session.roundtrip',
351
+ category: 'stream',
352
+ async run(n) {
353
+ const s = (await n.invoke('browse.open', {
354
+ url: 'https://example.com/',
355
+ opts: { waitForSelector: 'h1' },
356
+ })) as { id: string; url: string; title: string };
357
+ assert(s && typeof s.id === 'string', 'browse.open gave no session id');
358
+ try {
359
+ const content = (await n.invoke('browse.content', { id: s.id, opts: { format: 'text' } })) as {
360
+ content: string;
361
+ };
362
+ assert(content.content.length > 0, 'browse.content empty');
363
+ const links = (await n.invoke('browse.links', { id: s.id })) as unknown[];
364
+ assert(Array.isArray(links), 'browse.links not an array');
365
+ const shot = (await n.invoke('browse.screenshot', { id: s.id })) as string;
366
+ assert(typeof shot === 'string' && shot.length > 100, 'browse.screenshot returned no image');
367
+ const info = (await n.invoke('browse.info', { id: s.id })) as { url: string };
368
+ assert(/example\.com/.test(info.url), `browse.info url was '${info.url}'`);
369
+ } finally {
370
+ await n.invoke('browse.close', { id: s.id }).catch(() => {});
371
+ }
372
+ },
373
+ },
374
+ {
375
+ id: 'browse.interact',
376
+ category: 'stream',
377
+ async run(n) {
378
+ const s = (await n.invoke('browse.open', { url: 'https://example.com/' })) as { id: string };
379
+ try {
380
+ // Inject a deterministic input + button so we don't depend on a third-party form.
381
+ await n.invoke('browse.eval', {
382
+ id: s.id,
383
+ expression: `(function(){
384
+ var i = document.createElement('input'); i.id = 'cf-in';
385
+ var b = document.createElement('button'); b.id = 'cf-btn';
386
+ b.onclick = function(){ window.__cf = 'clicked'; };
387
+ document.body.appendChild(i); document.body.appendChild(b);
388
+ return true;
389
+ })()`,
390
+ });
391
+ const found = (await n.invoke('browse.waitForSelector', {
392
+ id: s.id,
393
+ selector: '#cf-in',
394
+ timeoutMs: 2000,
395
+ })) as boolean;
396
+ assert(found === true, 'waitForSelector did not find injected input');
397
+ await n.invoke('browse.type', { id: s.id, selector: '#cf-in', text: 'hello' });
398
+ const typed = (await n.invoke('browse.eval', {
399
+ id: s.id,
400
+ expression: `document.querySelector('#cf-in').value`,
401
+ })) as string;
402
+ assert(typed === 'hello', `type did not set value, got '${typed}'`);
403
+ await n.invoke('browse.click', { id: s.id, selector: '#cf-btn' });
404
+ const clicked = (await n.invoke('browse.eval', { id: s.id, expression: `window.__cf || ''` })) as string;
405
+ assert(clicked === 'clicked', `click handler did not fire, got '${clicked}'`);
406
+ } finally {
407
+ await n.invoke('browse.close', { id: s.id }).catch(() => {});
408
+ }
409
+ },
410
+ },
349
411
 
350
412
  // ----- event-emitting -----
351
413
  {
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fallbackFor } from './contract-meta.js';
3
+
4
+ describe('browse namespace fallback profile', () => {
5
+ it('is native on ios, android, and desktop; throw on web', () => {
6
+ expect(fallbackFor('browse.extract', 'ios')).toBe('native');
7
+ expect(fallbackFor('browse.screenshot', 'android')).toBe('native');
8
+ expect(fallbackFor('browse.open', 'desktop')).toBe('native');
9
+ expect(fallbackFor('browse.open', 'web')).toBe('throw');
10
+ });
11
+ });
@@ -56,7 +56,7 @@ const NS_DEFAULT: Record<Namespace, [Fallback, Fallback, Fallback, Fallback]> =
56
56
  sandbox: ['throw', 'throw', 'native', 'throw'], // desktop-only (Ugly Studio daemon)
57
57
  permissions: ['native', 'native', 'native', 'native'],
58
58
  browser: ['throw', 'throw', 'native', 'throw'],
59
- browse: ['throw', 'throw', 'native', 'throw'], // desktop-only (Ugly Browser)
59
+ browse: ['native', 'native', 'native', 'throw'], // headless webview on all shells; web has none
60
60
  mcp: ['throw', 'throw', 'native', 'throw'], // desktop-only (Studio MCP registry)
61
61
  terminal: ['throw', 'throw', 'native', 'throw'], // desktop-only (Studio PTY terminal)
62
62
  task: ['native', 'native', 'native', 'throw'], // desktop-cap; mobile/web tunnel via Ugly Proxy
@@ -143,6 +143,7 @@ const CHANNELS: Channel[] = [
143
143
  'task.stop',
144
144
  'proxy.signFingerprint',
145
145
  'proxy.verifyFingerprint',
146
+ 'proxy.self',
146
147
  'proxy.pickHost',
147
148
  'proxy.setStatus',
148
149
  'uglybot.request',
@@ -421,6 +421,11 @@ export interface NativeContract {
421
421
  // never leaves OS secure storage — only domain-separated handshake tags are returned.
422
422
  'proxy.signFingerprint': { payload: { role: 'client' | 'host'; fingerprint: string }; result: { tag: string } };
423
423
  'proxy.verifyFingerprint': { payload: { role: 'client' | 'host'; fingerprint: string; tag: string }; result: { ok: boolean } };
424
+ // This device's own stable proxy identity (the deviceId/label it registers as
425
+ // a host under). Returns null off the native desktop shell (e.g. plain web),
426
+ // where there's no local filesystem to host. Used to stamp synced recent
427
+ // projects with the desktop that physically holds the files.
428
+ 'proxy.self': { payload: void; result: { deviceId: string; deviceLabel: string } | null };
424
429
  // Host-picker UI — drawn by the native shell (Ugly Studio), driven by the app's
425
430
  // proxy bootstrap. pickHost shows the permission sheet; setStatus updates the drawer
426
431
  // badge + the paused/blurred disconnect overlay.
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createBrowseFacade } from './browse.js';
3
+
4
+ function mock() {
5
+ const calls: Array<{ channel: string; payload: unknown }> = [];
6
+ let ensureCount = 0;
7
+ const invoke = async (channel: string, payload: unknown): Promise<unknown> => {
8
+ calls.push({ channel, payload });
9
+ if (channel === 'browse.open' || channel === 'browse.navigate' || channel === 'browse.info')
10
+ return { id: 's1', url: 'u', title: 't' };
11
+ if (channel === 'browse.extract' || channel === 'browse.content')
12
+ return { url: 'u', title: 't', format: 'readability', content: '', length: 0 };
13
+ if (channel === 'browse.links') return [];
14
+ if (channel === 'browse.waitForSelector') return true;
15
+ if (channel === 'browse.screenshot') return 'data:image/png;base64,AAAA';
16
+ return undefined;
17
+ };
18
+ const ensure = async (): Promise<void> => {
19
+ ensureCount += 1;
20
+ };
21
+ return { calls, facade: createBrowseFacade(invoke, ensure), ensureCount: () => ensureCount };
22
+ }
23
+
24
+ describe('browse facade → channel mapping', () => {
25
+ it('maps every method to its channel + payload and gates each on ensure()', async () => {
26
+ const m = mock();
27
+ const { facade, calls } = m;
28
+ await facade.extract('https://x/', { format: 'html' });
29
+ await facade.open('https://x/', { waitMs: 10 });
30
+ await facade.navigate('s1', 'https://y/');
31
+ await facade.click('s1', '#a');
32
+ await facade.type('s1', '#a', 'hi', { clear: true, submit: true });
33
+ await facade.waitForSelector('s1', '#a', 500);
34
+ await facade.content('s1', { format: 'text' });
35
+ await facade.links('s1');
36
+ await facade.eval('s1', '1+1');
37
+ await facade.screenshot('s1');
38
+ await facade.info('s1');
39
+ await facade.close('s1');
40
+
41
+ expect(calls.map((c) => c.channel)).toEqual([
42
+ 'browse.extract',
43
+ 'browse.open',
44
+ 'browse.navigate',
45
+ 'browse.click',
46
+ 'browse.type',
47
+ 'browse.waitForSelector',
48
+ 'browse.content',
49
+ 'browse.links',
50
+ 'browse.eval',
51
+ 'browse.screenshot',
52
+ 'browse.info',
53
+ 'browse.close',
54
+ ]);
55
+ expect(calls[0].payload).toEqual({ url: 'https://x/', opts: { format: 'html' } });
56
+ expect(calls[2].payload).toEqual({ id: 's1', url: 'https://y/', opts: undefined });
57
+ expect(calls[3].payload).toEqual({ id: 's1', selector: '#a' });
58
+ expect(calls[4].payload).toEqual({ id: 's1', selector: '#a', text: 'hi', opts: { clear: true, submit: true } });
59
+ expect(calls[5].payload).toEqual({ id: 's1', selector: '#a', timeoutMs: 500 });
60
+ expect(calls[8].payload).toEqual({ id: 's1', expression: '1+1' });
61
+ // ensure() fires once per call (12 calls).
62
+ expect(m.ensureCount()).toBe(12);
63
+ });
64
+ });
@@ -79,6 +79,10 @@ export function enableUglyProxy(opts: {
79
79
  let connecting: Promise<boolean> | null = null;
80
80
  let lastLabel: string | undefined;
81
81
  let forceRepick = false;
82
+ // When set, the next connect targets this specific host (bypassing the picker).
83
+ // Driven by the 'proxy:connect' event — e.g. opening a recent project that's
84
+ // stamped with the desktop it lives on, so the phone reaches the right host.
85
+ let preferredHost: string | null = null;
82
86
 
83
87
  const setStatus = (state: string, hostLabel?: string): void => {
84
88
  void native.invoke('proxy.setStatus' as never, { state, hostLabel } as never).catch(() => {});
@@ -89,14 +93,16 @@ export function enableUglyProxy(opts: {
89
93
  if (connecting) return connecting;
90
94
  connecting = (async () => {
91
95
  try {
92
- const existing = forceRepick ? null : client.hostDeviceId();
93
- console.error('[uglyproxy] ensureConnected: start', { existing, forceRepick });
96
+ // A pinned host (preferredHost) wins over the cached one and the picker.
97
+ const existing = preferredHost ?? (forceRepick ? null : client.hostDeviceId());
98
+ console.error('[uglyproxy] ensureConnected: start', { existing, preferredHost, forceRepick });
94
99
  if (existing) {
95
- // Silent reconnect to the previously chosen host.
100
+ // Direct (re)connect to the cached or pinned host — no picker.
96
101
  setStatus('reconnecting', lastLabel);
97
102
  await client.connect(existing);
98
103
  setStatus('connected', lastLabel);
99
- console.error('[uglyproxy] ensureConnected: reconnected', { existing });
104
+ preferredHost = null;
105
+ console.error('[uglyproxy] ensureConnected: connected', { existing });
100
106
  return true;
101
107
  }
102
108
  forceRepick = false;
@@ -125,13 +131,26 @@ export function enableUglyProxy(opts: {
125
131
  }
126
132
 
127
133
  // The native drawer's "switch desktop" pill emits this — drop the current host and
128
- // re-run the picker.
134
+ // re-run the picker. Clear any pin so the user's repick isn't overridden.
129
135
  native.subscribe('proxy:switch' as never, (() => {
130
136
  forceRepick = true;
137
+ preferredHost = null;
131
138
  client.close();
132
139
  void ensureConnected();
133
140
  }) as never);
134
141
 
142
+ // Pin to a specific host (e.g. opening a recent project stamped with its
143
+ // desktop). If we're connected elsewhere, drop it so the pinned target wins.
144
+ native.subscribe('proxy:connect' as never, ((data: unknown) => {
145
+ const d = data as { deviceId?: string; label?: string } | undefined;
146
+ if (!d?.deviceId) return;
147
+ preferredHost = d.deviceId;
148
+ if (d.label) lastLabel = d.label;
149
+ forceRepick = false;
150
+ if (client.isOpen() && client.hostDeviceId() !== d.deviceId) client.close();
151
+ void ensureConnected();
152
+ }) as never);
153
+
135
154
  // task.event:<id> subscriptions need a host-side fanout handshake (task.listen) — unlike
136
155
  // process/net streams, whose fanout is registered by the spawning invoke. Ref-count per
137
156
  // task id so N local subscribers share one host subscription (no duplicate event delivery).
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { getPool } from './Pg.js';
10
+ import { fieldIndexStatements } from './schemaIndexes.js';
11
+ import type { IndexDef } from '../shared/DB.js';
10
12
 
11
13
  const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
12
14
 
@@ -17,10 +19,14 @@ function assertSafeName(name: string): void {
17
19
  }
18
20
 
19
21
  /**
20
- * Create the collection table + its default JSONB GIN index if they
21
- * don't exist. Idempotent.
22
+ * Create the collection table, its default JSONB GIN index, and the btree
23
+ * expression indexes the collection declares (so `WHERE data->>'field' = $1`
24
+ * lookups stay index-served instead of seq-scanning). All idempotent.
22
25
  */
23
- export async function ensureTable(collection: string): Promise<void> {
26
+ export async function ensureTable(
27
+ collection: string,
28
+ indexes?: IndexDef[],
29
+ ): Promise<void> {
24
30
  assertSafeName(collection);
25
31
  const pool = getPool();
26
32
  await pool.query(
@@ -36,6 +42,17 @@ export async function ensureTable(collection: string): Promise<void> {
36
42
  `CREATE INDEX IF NOT EXISTS "idx_${collection}_data"
37
43
  ON "${collection}" USING GIN (data)`,
38
44
  );
45
+ // Declared field indexes — isolate each so a single failure (e.g. a UNIQUE
46
+ // index over pre-existing duplicate data) never aborts the rest of the sync.
47
+ for (const sql of fieldIndexStatements(collection, indexes)) {
48
+ try {
49
+ await pool.query(sql);
50
+ } catch (err) {
51
+ console.warn(
52
+ `[PostgresSchema] field index skipped for "${collection}": ${(err as Error).message}`,
53
+ );
54
+ }
55
+ }
39
56
  }
40
57
 
41
58
  export async function tableExists(collection: string): Promise<boolean> {
@@ -40,11 +40,15 @@ async function loadSnapshots(): Promise<Record<string, SchemaDescriptor>> {
40
40
  return snapshots;
41
41
  }
42
42
 
43
- async function upsertSnapshot(name: string, descriptor: SchemaDescriptor): Promise<void> {
43
+ async function upsertSnapshot(
44
+ name: string,
45
+ descriptor: SchemaDescriptor,
46
+ indexes?: IndexDef[],
47
+ ): Promise<void> {
44
48
  // Invariant: a stored snapshot must imply the table exists. ensureTable is
45
49
  // idempotent (CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS) on
46
50
  // the data-proxy side, so this is safe to call on every snapshot write.
47
- await ensureTable(name);
51
+ await ensureTable(name, indexes);
48
52
  await Postgres.query(
49
53
  `INSERT INTO _schema_snapshots (collection, schema_json, updated_at)
50
54
  VALUES ($1, $2, NOW())
@@ -60,11 +64,12 @@ export async function updateAllSnapshots(
60
64
  if (!def.schema) continue;
61
65
  // Getter-backed collections have no local table — skip schema snapshots.
62
66
  if (def.meta?.getter) continue;
67
+ const indexes = (def as { indexes?: IndexDef[] }).indexes;
63
68
  const descriptor = serializeSchema(
64
69
  def.schema as z.ZodObject<z.ZodRawShape>,
65
- (def as { indexes?: IndexDef[] }).indexes,
70
+ indexes,
66
71
  );
67
- await upsertSnapshot(name, descriptor);
72
+ await upsertSnapshot(name, descriptor, indexes);
68
73
  }
69
74
  }
70
75
 
@@ -175,8 +180,8 @@ export async function autoMigrateNewCollections(
175
180
  `);
176
181
 
177
182
  for (const name of newCollections) {
178
- // 1. Create the table via data proxy
179
- await ensureTable(name);
183
+ // 1. Create the table via data proxy (+ its declared field indexes)
184
+ await ensureTable(name, (defs[name] as { indexes?: IndexDef[] })?.indexes);
180
185
 
181
186
  // 2. Skip writing a new file if a schema migration for this collection already exists.
182
187
  // Auto-baseline can be triggered whenever the _schema_snapshots row is missing
@@ -65,7 +65,8 @@ import type {
65
65
  WorkerHandlers,
66
66
  WorkerRegistry,
67
67
  } from '../../../shared/Api.js';
68
- import type { CollectionDefRegistry } from '../../../shared/DB.js';
68
+ import type { CollectionDefRegistry, IndexDef } from '../../../shared/DB.js';
69
+ import { fieldIndexStatements } from '../../schemaIndexes.js';
69
70
  import { getUglyBotUrl } from '../../../shared/uglyBotUrl.js';
70
71
  import type { DbAdapter, PubSubMessage, ServerAdapter } from '../types.js';
71
72
  import type { QueueMessage, WorkersEnv } from './cf-types.js';
@@ -1600,6 +1601,17 @@ export function createWorkersApp<R extends AppRegistryBase>(
1600
1601
  await db.query(
1601
1602
  `CREATE INDEX IF NOT EXISTS "idx_${name}_data" ON "${name}" USING GIN (data)`,
1602
1603
  );
1604
+ // Declared btree expression indexes (`((data->>'field'))`) — without
1605
+ // these, `WHERE data->>'field' = $1` reads seq-scan and hang at scale.
1606
+ // Isolate each so one bad index never fails the whole schema sync.
1607
+ const indexes = (ctx.collections?.[name] as { indexes?: IndexDef[] } | undefined)?.indexes;
1608
+ for (const sql of fieldIndexStatements(name, indexes)) {
1609
+ try {
1610
+ await db.query(sql);
1611
+ } catch (err) {
1612
+ console.warn(`[/_init] field index skipped for "${name}": ${(err as Error).message}`);
1613
+ }
1614
+ }
1603
1615
  created.push(name);
1604
1616
  }
1605
1617
  return c.json({ ok: true, ensured: created });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fieldIndexStatements } from './schemaIndexes';
3
+
4
+ describe('fieldIndexStatements', () => {
5
+ // Regression: the dock-app prod hang. A plain `{ fields: { userId: 1 } }` index
6
+ // declaration produced NO `((data->>'userId'))` btree index (schemaGen only
7
+ // handled unique/ttl), so `WHERE data->>'userId' = $1` seq-scanned and hung.
8
+ it('emits a btree expression index for a plain single-field index', () => {
9
+ const sql = fieldIndexStatements('dockApp', [{ fields: { userId: 1 } }]);
10
+ expect(sql).toEqual([
11
+ `CREATE INDEX IF NOT EXISTS "idx_dockApp_userId" ON "dockApp" ((data->>'userId'))`,
12
+ ]);
13
+ });
14
+
15
+ it('emits a UNIQUE index (with _unique suffix) for a unique index', () => {
16
+ const sql = fieldIndexStatements('emailAccount', [{ fields: { email: 1 }, unique: true }]);
17
+ expect(sql).toEqual([
18
+ `CREATE UNIQUE INDEX IF NOT EXISTS "idx_emailAccount_email_unique" ON "emailAccount" ((data->>'email'))`,
19
+ ]);
20
+ });
21
+
22
+ it('emits one statement per declared index', () => {
23
+ const sql = fieldIndexStatements('voice', [
24
+ { fields: { userId: 1 } },
25
+ { fields: { provider: 1 } },
26
+ ]);
27
+ expect(sql).toHaveLength(2);
28
+ expect(sql[1]).toContain(`((data->>'provider'))`);
29
+ });
30
+
31
+ it('skips composite (multi-field) indexes — single-field only', () => {
32
+ expect(fieldIndexStatements('x', [{ fields: { a: 1, b: 1 } }])).toEqual([]);
33
+ });
34
+
35
+ it('returns [] for no/empty indexes', () => {
36
+ expect(fieldIndexStatements('x')).toEqual([]);
37
+ expect(fieldIndexStatements('x', [])).toEqual([]);
38
+ });
39
+
40
+ it('refuses unsafe collection/field names (no SQL injection surface)', () => {
41
+ expect(fieldIndexStatements('bad-name', [{ fields: { userId: 1 } }])).toEqual([]);
42
+ expect(fieldIndexStatements('ok', [{ fields: { 'a; DROP TABLE x': 1 } }])).toEqual([]);
43
+ });
44
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Single source of truth for the btree expression indexes a collection declares
3
+ * via `indexes: [{ fields: { <field>: 1 } }]`.
4
+ *
5
+ * The default `idx_<c>_data` GIN index covers JSONB containment (`data @> …`),
6
+ * but it CANNOT serve the scalar lookups the query layer actually emits
7
+ * (`translateFilter` builds `WHERE data->>'field' = $1`). Without a matching
8
+ * `((data->>'field'))` btree index, every filtered read on the collection
9
+ * sequential-scans — invisible on a small table, a hang once it grows past the
10
+ * 30s statement_timeout.
11
+ *
12
+ * Pure string-builder (no pg/pool imports) so it's safe to share across the Node
13
+ * schema sync (`PostgresSchema.ensureTable`), the Workers `/_init` route, and the
14
+ * migration generator (`schemaGen`). Statements are idempotent (IF NOT EXISTS) so
15
+ * every call site can run them on every deploy.
16
+ */
17
+ import type { IndexDef } from '../shared/DB.js';
18
+
19
+ const VALID_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
20
+
21
+ /** Raw `CREATE [UNIQUE] INDEX` SQL for each declared single-field index. */
22
+ export function fieldIndexStatements(
23
+ collection: string,
24
+ indexes?: IndexDef[],
25
+ ): string[] {
26
+ if (!VALID_NAME.test(collection)) return [];
27
+ const out: string[] = [];
28
+ for (const idx of indexes ?? []) {
29
+ const fields = Object.keys(idx.fields);
30
+ // Single-field expression indexes only — matches the query layer, which
31
+ // filters one JSONB field at a time. (Composite indexes aren't emitted.)
32
+ if (fields.length !== 1) continue;
33
+ const field = fields[0];
34
+ if (!VALID_NAME.test(field)) continue;
35
+ const unique = idx.unique ? 'UNIQUE ' : '';
36
+ const name = idx.unique
37
+ ? `idx_${collection}_${field}_unique`
38
+ : `idx_${collection}_${field}`;
39
+ out.push(
40
+ `CREATE ${unique}INDEX IF NOT EXISTS "${name}" ON "${collection}" ((data->>'${field}'))`,
41
+ );
42
+ }
43
+ return out;
44
+ }