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.
- package/dist/cli/buildWorkers.d.ts +1 -0
- package/dist/cli/buildWorkers.d.ts.map +1 -1
- package/dist/cli/buildWorkers.js +17 -3
- package/dist/cli/buildWorkers.js.map +1 -1
- package/dist/cli/schemaGen.d.ts.map +1 -1
- package/dist/cli/schemaGen.js +5 -12
- package/dist/cli/schemaGen.js.map +1 -1
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/native/conformance/harness.d.ts.map +1 -1
- package/dist/native/conformance/harness.js +62 -0
- package/dist/native/conformance/harness.js.map +1 -1
- package/dist/native/contract-meta.d.ts.map +1 -1
- package/dist/native/contract-meta.js +2 -1
- package/dist/native/contract-meta.js.map +1 -1
- package/dist/native/contract.d.ts +7 -0
- package/dist/native/contract.d.ts.map +1 -1
- package/dist/native/contract.js.map +1 -1
- package/dist/native/proxy.d.ts.map +1 -1
- package/dist/native/proxy.js +26 -5
- package/dist/native/proxy.js.map +1 -1
- package/dist/server/PostgresSchema.d.ts +5 -3
- package/dist/server/PostgresSchema.d.ts.map +1 -1
- package/dist/server/PostgresSchema.js +15 -3
- package/dist/server/PostgresSchema.js.map +1 -1
- package/dist/server/SchemaCheck.d.ts.map +1 -1
- package/dist/server/SchemaCheck.js +7 -6
- package/dist/server/SchemaCheck.js.map +1 -1
- package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
- package/dist/server/adapter/workers/createWorkersApp.js +13 -0
- package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
- package/dist/server/schemaIndexes.d.ts +20 -0
- package/dist/server/schemaIndexes.d.ts.map +1 -0
- package/dist/server/schemaIndexes.js +24 -0
- package/dist/server/schemaIndexes.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/buildWorkers.rewrite.test.ts +35 -0
- package/src/cli/buildWorkers.ts +17 -3
- package/src/cli/schemaGen.ts +7 -16
- package/src/cli/version.ts +1 -1
- package/src/native/conformance/harness.ts +62 -0
- package/src/native/contract-meta.browse.test.ts +11 -0
- package/src/native/contract-meta.ts +2 -1
- package/src/native/contract.ts +5 -0
- package/src/native/facades/browse.test.ts +64 -0
- package/src/native/proxy.ts +24 -5
- package/src/server/PostgresSchema.ts +20 -3
- package/src/server/SchemaCheck.ts +11 -6
- package/src/server/adapter/workers/createWorkersApp.ts +13 -1
- package/src/server/schemaIndexes.test.ts +44 -0
- package/src/server/schemaIndexes.ts +44 -0
package/src/cli/buildWorkers.ts
CHANGED
|
@@ -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/` (
|
|
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(/^[^/]+\.(?:
|
|
250
|
+
restPath.match(/^[^/]+\.(?:woff2?|ttf|map)$/i)
|
|
237
251
|
) {
|
|
238
252
|
return `${attr}/${buildId}/${restPath}"`;
|
|
239
253
|
}
|
package/src/cli/schemaGen.ts
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
package/src/cli/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by prebuild — do not edit manually
|
|
2
|
-
export const CLI_VERSION = "0.1.
|
|
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: ['
|
|
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',
|
package/src/native/contract.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/native/proxy.ts
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
21
|
-
*
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|