ugly-app 0.1.708 → 0.1.709

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 (45) hide show
  1. package/dist/cli/schemaGen.d.ts.map +1 -1
  2. package/dist/cli/schemaGen.js +5 -12
  3. package/dist/cli/schemaGen.js.map +1 -1
  4. package/dist/cli/version.d.ts +1 -1
  5. package/dist/cli/version.js +1 -1
  6. package/dist/native/conformance/harness.d.ts.map +1 -1
  7. package/dist/native/conformance/harness.js +62 -0
  8. package/dist/native/conformance/harness.js.map +1 -1
  9. package/dist/native/contract-meta.d.ts.map +1 -1
  10. package/dist/native/contract-meta.js +2 -1
  11. package/dist/native/contract-meta.js.map +1 -1
  12. package/dist/native/contract.d.ts +7 -0
  13. package/dist/native/contract.d.ts.map +1 -1
  14. package/dist/native/contract.js.map +1 -1
  15. package/dist/native/proxy.d.ts.map +1 -1
  16. package/dist/native/proxy.js +26 -5
  17. package/dist/native/proxy.js.map +1 -1
  18. package/dist/server/PostgresSchema.d.ts +5 -3
  19. package/dist/server/PostgresSchema.d.ts.map +1 -1
  20. package/dist/server/PostgresSchema.js +15 -3
  21. package/dist/server/PostgresSchema.js.map +1 -1
  22. package/dist/server/SchemaCheck.d.ts.map +1 -1
  23. package/dist/server/SchemaCheck.js +7 -6
  24. package/dist/server/SchemaCheck.js.map +1 -1
  25. package/dist/server/adapter/workers/createWorkersApp.d.ts.map +1 -1
  26. package/dist/server/adapter/workers/createWorkersApp.js +13 -0
  27. package/dist/server/adapter/workers/createWorkersApp.js.map +1 -1
  28. package/dist/server/schemaIndexes.d.ts +20 -0
  29. package/dist/server/schemaIndexes.d.ts.map +1 -0
  30. package/dist/server/schemaIndexes.js +24 -0
  31. package/dist/server/schemaIndexes.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/cli/schemaGen.ts +7 -16
  34. package/src/cli/version.ts +1 -1
  35. package/src/native/conformance/harness.ts +62 -0
  36. package/src/native/contract-meta.browse.test.ts +11 -0
  37. package/src/native/contract-meta.ts +2 -1
  38. package/src/native/contract.ts +5 -0
  39. package/src/native/facades/browse.test.ts +64 -0
  40. package/src/native/proxy.ts +24 -5
  41. package/src/server/PostgresSchema.ts +20 -3
  42. package/src/server/SchemaCheck.ts +11 -6
  43. package/src/server/adapter/workers/createWorkersApp.ts +13 -1
  44. package/src/server/schemaIndexes.test.ts +44 -0
  45. package/src/server/schemaIndexes.ts +44 -0
@@ -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
+ }