@lobu/cli 6.1.1 → 7.0.0

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 (100) hide show
  1. package/dist/commands/_lib/apply/apply-cmd.d.ts +36 -0
  2. package/dist/commands/_lib/apply/apply-cmd.d.ts.map +1 -1
  3. package/dist/commands/_lib/apply/apply-cmd.js +548 -40
  4. package/dist/commands/_lib/apply/apply-cmd.js.map +1 -1
  5. package/dist/commands/_lib/apply/client.d.ts +179 -0
  6. package/dist/commands/_lib/apply/client.d.ts.map +1 -1
  7. package/dist/commands/_lib/apply/client.js +308 -28
  8. package/dist/commands/_lib/apply/client.js.map +1 -1
  9. package/dist/commands/_lib/apply/desired-state.d.ts +134 -3
  10. package/dist/commands/_lib/apply/desired-state.d.ts.map +1 -1
  11. package/dist/commands/_lib/apply/desired-state.js +700 -86
  12. package/dist/commands/_lib/apply/desired-state.js.map +1 -1
  13. package/dist/commands/_lib/apply/diff.d.ts +61 -3
  14. package/dist/commands/_lib/apply/diff.d.ts.map +1 -1
  15. package/dist/commands/_lib/apply/diff.js +382 -92
  16. package/dist/commands/_lib/apply/diff.js.map +1 -1
  17. package/dist/commands/_lib/apply/prompt.d.ts +6 -0
  18. package/dist/commands/_lib/apply/prompt.d.ts.map +1 -1
  19. package/dist/commands/_lib/apply/prompt.js +16 -0
  20. package/dist/commands/_lib/apply/prompt.js.map +1 -1
  21. package/dist/commands/_lib/apply/render.d.ts +9 -0
  22. package/dist/commands/_lib/apply/render.d.ts.map +1 -1
  23. package/dist/commands/_lib/apply/render.js +80 -3
  24. package/dist/commands/_lib/apply/render.js.map +1 -1
  25. package/dist/commands/chat.d.ts.map +1 -1
  26. package/dist/commands/chat.js +9 -2
  27. package/dist/commands/chat.js.map +1 -1
  28. package/dist/commands/dev.d.ts +8 -0
  29. package/dist/commands/dev.d.ts.map +1 -1
  30. package/dist/commands/dev.js +118 -5
  31. package/dist/commands/dev.js.map +1 -1
  32. package/dist/commands/eval.d.ts.map +1 -1
  33. package/dist/commands/eval.js +16 -5
  34. package/dist/commands/eval.js.map +1 -1
  35. package/dist/commands/init.d.ts +2 -0
  36. package/dist/commands/init.d.ts.map +1 -1
  37. package/dist/commands/init.js +24 -0
  38. package/dist/commands/init.js.map +1 -1
  39. package/dist/commands/memory/_lib/schema.d.ts +28 -1
  40. package/dist/commands/memory/_lib/schema.d.ts.map +1 -1
  41. package/dist/commands/memory/_lib/schema.js +120 -4
  42. package/dist/commands/memory/_lib/schema.js.map +1 -1
  43. package/dist/commands/memory/_lib/seed-cmd.d.ts.map +1 -1
  44. package/dist/commands/memory/_lib/seed-cmd.js +41 -18
  45. package/dist/commands/memory/_lib/seed-cmd.js.map +1 -1
  46. package/dist/commands/org.d.ts +4 -0
  47. package/dist/commands/org.d.ts.map +1 -1
  48. package/dist/commands/org.js +10 -0
  49. package/dist/commands/org.js.map +1 -1
  50. package/dist/commands/token.d.ts +9 -0
  51. package/dist/commands/token.d.ts.map +1 -1
  52. package/dist/commands/token.js +54 -0
  53. package/dist/commands/token.js.map +1 -1
  54. package/dist/connectors/README.md +2 -2
  55. package/dist/connectors/apple_health.ts +138 -0
  56. package/dist/connectors/apple_screen_time.ts +82 -0
  57. package/dist/connectors/browser-scraper-utils.ts +35 -3
  58. package/dist/connectors/capterra.ts +5 -1
  59. package/dist/connectors/g2.ts +5 -1
  60. package/dist/connectors/github.ts +15 -38
  61. package/dist/connectors/glassdoor.ts +5 -1
  62. package/dist/connectors/google_calendar.ts +14 -4
  63. package/dist/connectors/google_gmail.ts +6 -3
  64. package/dist/connectors/google_play.ts +10 -3
  65. package/dist/connectors/index.ts +5 -0
  66. package/dist/connectors/linkedin.ts +32 -9
  67. package/dist/connectors/local_directory.ts +91 -0
  68. package/dist/connectors/revolut.ts +572 -0
  69. package/dist/connectors/trustpilot.ts +5 -1
  70. package/dist/connectors/website.ts +1 -1
  71. package/dist/connectors/whatsapp.ts +9 -1
  72. package/dist/connectors/whatsapp_local.ts +125 -0
  73. package/dist/connectors/x.ts +17 -7
  74. package/dist/db/migrations/20260510220000_connector_required_capability.sql +47 -0
  75. package/dist/db/migrations/20260512000000_device_worker_connection_binding.sql +113 -0
  76. package/dist/db/migrations/20260512131703_connections_slug.sql +131 -0
  77. package/dist/db/migrations/20260513000000_chat_user_identities.sql +24 -0
  78. package/dist/db/migrations/20260513120000_auth_profiles_device_binding.sql +50 -0
  79. package/dist/db/migrations/20260513150000_auth_profiles_cdp_url.sql +43 -0
  80. package/dist/db/migrations/20260513200000_notifications_as_events.sql +86 -0
  81. package/dist/db/migrations/20260514000000_scheduled_jobs.sql +97 -0
  82. package/dist/db/migrations/20260514120000_auth_profiles_connector_key_nullable.sql +42 -0
  83. package/dist/eval/types.d.ts +2 -0
  84. package/dist/eval/types.d.ts.map +1 -1
  85. package/dist/index.d.ts +11 -0
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +68 -114
  88. package/dist/index.js.map +1 -1
  89. package/dist/internal/gateway-url.d.ts +14 -0
  90. package/dist/internal/gateway-url.d.ts.map +1 -1
  91. package/dist/internal/gateway-url.js +19 -0
  92. package/dist/internal/gateway-url.js.map +1 -1
  93. package/dist/internal/index.d.ts +1 -1
  94. package/dist/internal/index.d.ts.map +1 -1
  95. package/dist/internal/index.js +1 -1
  96. package/dist/internal/index.js.map +1 -1
  97. package/dist/server.bundle.mjs +32494 -30475
  98. package/dist/start-local.bundle.mjs +10840 -7912
  99. package/dist/templates/TESTING.md.tmpl +9 -9
  100. package/package.json +6 -6
@@ -0,0 +1,125 @@
1
+ /**
2
+ * WhatsApp (local) Connector — Lobu for Mac only.
3
+ *
4
+ * Reads messages directly from the WhatsApp Desktop app's local SQLite store
5
+ * at `~/Library/Group Containers/group.net.whatsapp.WhatsApp.shared/
6
+ * ChatStorage.sqlite`. Lobu for Mac snapshots the DB read-only, walks new
7
+ * rows since the last `Z_PK` checkpoint, and emits events that share the
8
+ * `whatsapp` connector's metadata shape so downstream entity links work
9
+ * identically.
10
+ *
11
+ * Differences from the QR-paired `whatsapp` connector:
12
+ * - No Baileys, no socket, no phone-offline auto-unlink (WA Desktop itself
13
+ * is the linked device).
14
+ * - Ciphertext never leaves the Mac.
15
+ * - Bound to one specific Mac; requires WhatsApp Desktop installed.
16
+ */
17
+
18
+ import {
19
+ type ActionResult,
20
+ type ConnectorDefinition,
21
+ ConnectorRuntime,
22
+ IDENTITY,
23
+ type SyncContext,
24
+ type SyncResult,
25
+ } from '@lobu/connector-sdk';
26
+
27
+ const BRIDGE_ONLY =
28
+ 'WhatsApp (local) runs only on a worker advertising capability "whatsapp_local" (Lobu for Mac with WhatsApp Desktop installed).';
29
+
30
+ export default class WhatsAppLocalConnector extends ConnectorRuntime {
31
+ readonly definition: ConnectorDefinition = {
32
+ key: 'whatsapp.local',
33
+ name: 'WhatsApp (this Mac)',
34
+ description:
35
+ "Reads messages from the WhatsApp Desktop app's local archive on this Mac. No QR pairing, no phone-offline auto-unlink — the desktop app is itself the linked device.",
36
+ version: '0.1.0',
37
+ faviconDomain: 'whatsapp.com',
38
+ requiredCapability: 'whatsapp_local',
39
+ runtime: { platforms: ['macos'] },
40
+ authSchema: { methods: [{ type: 'none' }] },
41
+ feeds: {
42
+ messages: {
43
+ key: 'messages',
44
+ name: 'Messages',
45
+ description:
46
+ 'Personal WhatsApp messages from 1:1 and group chats, sourced from WhatsApp Desktop.',
47
+ configSchema: {
48
+ type: 'object',
49
+ properties: {
50
+ chat_filter: {
51
+ type: 'string',
52
+ enum: ['all', 'individual', 'group'],
53
+ default: 'all',
54
+ description: 'Which chats to include.',
55
+ },
56
+ max_messages_per_sync: {
57
+ type: 'integer',
58
+ minimum: 1,
59
+ maximum: 500000,
60
+ default: 5000,
61
+ description:
62
+ 'Safety cap on messages collected per sync. The first sync drains all messages up to this cap; subsequent syncs ingest only new messages, so the cap rarely binds.',
63
+ },
64
+ },
65
+ },
66
+ eventKinds: {
67
+ message: {
68
+ description: 'A WhatsApp message (text, caption, or system).',
69
+ metadataSchema: {
70
+ type: 'object',
71
+ properties: {
72
+ source: { type: 'string', const: 'whatsapp_local' },
73
+ chat_jid: { type: 'string' },
74
+ is_group: { type: 'boolean' },
75
+ from_me: { type: 'boolean' },
76
+ participant: { type: 'string' },
77
+ sender_jid: { type: 'string' },
78
+ sender_phone: { type: 'string' },
79
+ push_name: { type: 'string' },
80
+ media_type: { type: 'string' },
81
+ quoted_id: { type: 'string' },
82
+ is_forwarded: { type: 'boolean' },
83
+ is_starred: { type: 'boolean' },
84
+ is_system_event: { type: 'boolean' },
85
+ voice_note_skipped: {
86
+ type: 'string',
87
+ enum: ['not_downloaded', 'too_large', 'empty', 'read_error', 'invalid_path'],
88
+ },
89
+ },
90
+ },
91
+ entityLinks: [
92
+ {
93
+ entityType: 'person',
94
+ autoCreate: true,
95
+ titlePath: 'metadata.push_name',
96
+ identities: [
97
+ { namespace: IDENTITY.WA_JID, eventPath: 'metadata.sender_jid' },
98
+ { namespace: IDENTITY.PHONE, eventPath: 'metadata.sender_phone' },
99
+ ],
100
+ traits: {
101
+ push_name: {
102
+ eventPath: 'metadata.push_name',
103
+ behavior: 'prefer_non_empty',
104
+ },
105
+ last_seen_at: {
106
+ eventPath: 'occurred_at',
107
+ behavior: 'overwrite',
108
+ },
109
+ },
110
+ },
111
+ ],
112
+ },
113
+ },
114
+ },
115
+ },
116
+ };
117
+
118
+ async sync(_ctx: SyncContext): Promise<SyncResult> {
119
+ throw new Error(BRIDGE_ONLY);
120
+ }
121
+
122
+ async execute(): Promise<ActionResult> {
123
+ throw new Error(BRIDGE_ONLY);
124
+ }
125
+ }
@@ -17,7 +17,12 @@ import {
17
17
  type SyncContext,
18
18
  type SyncResult,
19
19
  } from '@lobu/connector-sdk';
20
- import { getBrowserCookies, validateCookieNotExpired } from './browser-scraper-utils';
20
+ import {
21
+ getBrowserCdpUrl,
22
+ getBrowserCookies,
23
+ getBrowserUserDataDir,
24
+ validateCookieNotExpired,
25
+ } from './browser-scraper-utils';
21
26
 
22
27
  interface XCheckpoint {
23
28
  last_tweet_id?: string;
@@ -358,12 +363,16 @@ async function syncViaBrowser(
358
363
  const searchFilter = (config.search_filter as string) ?? 'live';
359
364
  const searchUrl = `https://x.com/search?q=${encodeURIComponent(searchQuery)}&src=typed_query&f=${searchFilter}`;
360
365
 
366
+ const userDataDir = getBrowserUserDataDir(ctx.sessionState);
367
+ const cdpUrl = getBrowserCdpUrl(ctx.sessionState) ?? 'auto';
361
368
  let cookies: any[] = [];
362
- try {
363
- cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'x');
364
- validateCookieNotExpired(cookies, 'auth_token', 'x');
365
- } catch {
366
- // No stored cookies — CDP will be the only path
369
+ if (!userDataDir) {
370
+ try {
371
+ cookies = getBrowserCookies(ctx.checkpoint as any, ctx.sessionState as any, 'x');
372
+ validateCookieNotExpired(cookies, 'auth_token', 'x');
373
+ } catch {
374
+ // No stored cookies — CDP will be the only path
375
+ }
367
376
  }
368
377
 
369
378
  const result = await browserNetworkSync<XTweet>({
@@ -376,8 +385,9 @@ async function syncViaBrowser(
376
385
  navigationTimeoutMs: 15000,
377
386
  },
378
387
  url: searchUrl,
379
- cdpUrl: 'auto',
388
+ cdpUrl,
380
389
  cookies,
390
+ userDataDir,
381
391
  parseResponse: parseBrowserSearchResponse,
382
392
  checkAuth: async (page) => {
383
393
  const url = page.url();
@@ -0,0 +1,47 @@
1
+ -- migrate:up
2
+
3
+ -- Add a per-connector capability gate for worker dispatch. Workers advertise
4
+ -- their capabilities on poll; the runs scheduler only assigns connector runs
5
+ -- to workers whose capabilities include the connector's required_capability.
6
+ -- NULL means "no special capability required" (the default for API/browser
7
+ -- connectors that the existing fleet can run).
8
+ --
9
+ -- `runtime` carries platform metadata for device-bound connectors (e.g.
10
+ -- `{"platforms": ["macos"]}` for apple.screen_time / local.directory, which
11
+ -- only run inside Lobu for Mac — that data is unreachable from a server-side
12
+ -- worker). NULL = cloud connector.
13
+ --
14
+ -- Initial use case: apple.screen_time and local.directory, served by Lobu for
15
+ -- Mac polling /api/workers/* as a user-scoped device worker.
16
+
17
+ ALTER TABLE public.connector_definitions
18
+ ADD COLUMN IF NOT EXISTS required_capability text,
19
+ ADD COLUMN IF NOT EXISTS runtime jsonb;
20
+
21
+ CREATE INDEX IF NOT EXISTS connector_definitions_required_capability_idx
22
+ ON public.connector_definitions (required_capability)
23
+ WHERE required_capability IS NOT NULL;
24
+
25
+ CREATE TABLE IF NOT EXISTS public.device_workers (
26
+ user_id text NOT NULL,
27
+ worker_id text NOT NULL,
28
+ platform text,
29
+ app_version text,
30
+ capabilities jsonb NOT NULL DEFAULT '[]'::jsonb,
31
+ label text,
32
+ first_seen_at timestamptz NOT NULL DEFAULT now(),
33
+ last_seen_at timestamptz NOT NULL DEFAULT now(),
34
+ PRIMARY KEY (user_id, worker_id)
35
+ );
36
+
37
+ CREATE INDEX IF NOT EXISTS device_workers_user_id_idx
38
+ ON public.device_workers (user_id);
39
+
40
+ -- migrate:down
41
+
42
+ DROP INDEX IF EXISTS public.device_workers_user_id_idx;
43
+ DROP TABLE IF EXISTS public.device_workers;
44
+ DROP INDEX IF EXISTS public.connector_definitions_required_capability_idx;
45
+ ALTER TABLE public.connector_definitions
46
+ DROP COLUMN IF EXISTS runtime,
47
+ DROP COLUMN IF EXISTS required_capability;
@@ -0,0 +1,113 @@
1
+ -- migrate:up
2
+
3
+ -- Make a connection's execution target explicit, and give every device worker
4
+ -- a home organization.
5
+ --
6
+ -- connections.device_worker_id (nullable) is the binding:
7
+ -- NULL -> runs on the cloud connector-worker pool (today's behavior)
8
+ -- set -> runs are pinned to that device worker
9
+ -- For device-type connectors the binding is mandatory; for cloud connectors
10
+ -- it's an optional override. A connection can only be pinned to a device that
11
+ -- is attached to that connection's organization.
12
+ --
13
+ -- device_workers.organization_id is the device's home org — chosen at setup,
14
+ -- defaulting to the owner's personal workspace. The device's connectors live
15
+ -- there; re-attaching the device to a different org (a member of which the
16
+ -- owner must be) is the only knob. There is no per-connection device→org grant.
17
+
18
+ -- Surrogate key for device_workers so connections / UI can reference a device
19
+ -- by a single stable id. The (user_id, worker_id) primary key stays.
20
+ ALTER TABLE public.device_workers
21
+ ADD COLUMN IF NOT EXISTS id uuid NOT NULL DEFAULT gen_random_uuid(),
22
+ ADD COLUMN IF NOT EXISTS organization_id text;
23
+
24
+ CREATE UNIQUE INDEX IF NOT EXISTS device_workers_id_key
25
+ ON public.device_workers (id);
26
+
27
+ CREATE INDEX IF NOT EXISTS idx_device_workers_organization_id
28
+ ON public.device_workers (organization_id)
29
+ WHERE organization_id IS NOT NULL;
30
+
31
+ ALTER TABLE public.connections
32
+ ADD COLUMN IF NOT EXISTS device_worker_id uuid;
33
+
34
+ DO $$
35
+ BEGIN
36
+ IF NOT EXISTS (
37
+ SELECT 1 FROM pg_constraint WHERE conname = 'connections_device_worker_id_fkey'
38
+ ) THEN
39
+ ALTER TABLE public.connections
40
+ ADD CONSTRAINT connections_device_worker_id_fkey
41
+ FOREIGN KEY (device_worker_id)
42
+ REFERENCES public.device_workers (id)
43
+ ON DELETE SET NULL;
44
+ END IF;
45
+ END$$;
46
+
47
+ CREATE INDEX IF NOT EXISTS idx_connections_device_worker_id
48
+ ON public.connections (device_worker_id)
49
+ WHERE device_worker_id IS NOT NULL;
50
+
51
+ -- Attach existing devices to their owner's personal workspace (no-op on a
52
+ -- fresh database — there are no users yet; the device heartbeat sets this for
53
+ -- new devices either way).
54
+ UPDATE public.device_workers dw
55
+ SET organization_id = (
56
+ SELECT o.id FROM public.organization o
57
+ WHERE (o.metadata::jsonb)->>'personal_org_for_user_id' = dw.user_id
58
+ LIMIT 1
59
+ )
60
+ WHERE dw.organization_id IS NULL;
61
+
62
+ -- Backfill: existing auto-wired personal-org device connections (created_by
63
+ -- set, no auth profile) whose owner has exactly one device get pinned to that
64
+ -- device — but at most one per (org, connector_key, owner) so the unique index
65
+ -- created below can never be violated. Ambiguous ones stay NULL and the UI
66
+ -- prompts for a device.
67
+ UPDATE public.connections c
68
+ SET device_worker_id = dw.id
69
+ FROM (
70
+ -- Users with exactly one device worker (no min(uuid) needed — and Postgres
71
+ -- has no aggregate for uuid anyway).
72
+ SELECT dw1.user_id, dw1.id
73
+ FROM public.device_workers dw1
74
+ WHERE NOT EXISTS (
75
+ SELECT 1 FROM public.device_workers dw2
76
+ WHERE dw2.user_id = dw1.user_id AND dw2.id <> dw1.id
77
+ )
78
+ ) dw
79
+ WHERE c.created_by = dw.user_id
80
+ AND c.device_worker_id IS NULL
81
+ AND c.deleted_at IS NULL
82
+ AND c.auth_profile_id IS NULL
83
+ AND c.connector_key IN (
84
+ SELECT key FROM public.connector_definitions WHERE required_capability IS NOT NULL
85
+ )
86
+ AND c.id = (
87
+ SELECT min(c2.id) FROM public.connections c2
88
+ WHERE c2.organization_id = c.organization_id
89
+ AND c2.connector_key = c.connector_key
90
+ AND c2.created_by = c.created_by
91
+ AND c2.deleted_at IS NULL
92
+ );
93
+
94
+ -- One active connection per (org, connector, device). A second device backing
95
+ -- the same connector is a second connection. Doubles as DB-level idempotency
96
+ -- for the create-vs-auto-wire race. Created AFTER the backfill above.
97
+ DROP INDEX IF EXISTS public.idx_connections_org_connector_device_live;
98
+ CREATE UNIQUE INDEX idx_connections_org_connector_device_live
99
+ ON public.connections (organization_id, connector_key, device_worker_id)
100
+ WHERE deleted_at IS NULL AND device_worker_id IS NOT NULL;
101
+
102
+ -- migrate:down
103
+
104
+ DROP INDEX IF EXISTS public.idx_connections_org_connector_device_live;
105
+ DROP INDEX IF EXISTS public.idx_connections_device_worker_id;
106
+ ALTER TABLE public.connections
107
+ DROP CONSTRAINT IF EXISTS connections_device_worker_id_fkey,
108
+ DROP COLUMN IF EXISTS device_worker_id;
109
+ DROP INDEX IF EXISTS public.device_workers_id_key;
110
+ DROP INDEX IF EXISTS public.idx_device_workers_organization_id;
111
+ ALTER TABLE public.device_workers
112
+ DROP COLUMN IF EXISTS id,
113
+ DROP COLUMN IF EXISTS organization_id;
@@ -0,0 +1,131 @@
1
+ -- migrate:up
2
+
3
+ -- Add a stable `slug` identity to `connections` so `lobu apply` can diff
4
+ -- connections by an immutable key instead of the mutable `display_name`.
5
+ --
6
+ -- Mirrors the existing `auth_profiles.slug` design: text slug, unique per org
7
+ -- among live rows (partial index on `deleted_at IS NULL`), generated from the
8
+ -- display name when not supplied explicitly.
9
+ --
10
+ -- Backfill MUST produce exactly what `ensureUniqueConnectionSlug` /
11
+ -- `slugifyConnectionName` in packages/server/src/utils/connections.ts would
12
+ -- generate (that file is the source of truth):
13
+ -- 1. base = slugify(display_name); if empty, slugify(connector_key); if still
14
+ -- empty, the literal 'connection'. slugify = lowercase, every run of
15
+ -- non-alphanumerics -> '-', trim leading/trailing '-'.
16
+ -- 2. Collisions per (organization_id) among live (`deleted_at IS NULL`) rows
17
+ -- are resolved with a deterministic numeric suffix loop: base, base-2,
18
+ -- base-3, ... assigned in ascending id order. Soft-deleted rows do not
19
+ -- participate in the unique index, so they keep their base slug freely.
20
+
21
+ ALTER TABLE public.connections
22
+ ADD COLUMN IF NOT EXISTS slug text;
23
+
24
+ -- slugify(display_name) -> slugify(connector_key) -> 'connection'
25
+ WITH base AS (
26
+ SELECT
27
+ c.id,
28
+ coalesce(
29
+ NULLIF(
30
+ regexp_replace(
31
+ regexp_replace(lower(coalesce(c.display_name, '')), '[^a-z0-9]+', '-', 'g'),
32
+ '(^-+|-+$)', '', 'g'
33
+ ),
34
+ ''
35
+ ),
36
+ NULLIF(
37
+ regexp_replace(
38
+ regexp_replace(lower(coalesce(c.connector_key, '')), '[^a-z0-9]+', '-', 'g'),
39
+ '(^-+|-+$)', '', 'g'
40
+ ),
41
+ ''
42
+ ),
43
+ 'connection'
44
+ ) AS base_slug
45
+ FROM public.connections c
46
+ )
47
+ UPDATE public.connections c
48
+ SET slug = b.base_slug
49
+ FROM base b
50
+ WHERE b.id = c.id
51
+ AND c.slug IS NULL;
52
+
53
+ -- Resolve collisions to base / base-2 / base-3 / ... in ascending id order.
54
+ -- Loops until no live (deleted_at IS NULL) duplicates remain — a re-assigned
55
+ -- `base-N` could itself collide with another row whose base slug is already
56
+ -- `base-N`, so a single pass is not enough.
57
+ --
58
+ -- This produces a deterministic, collision-free assignment with the same
59
+ -- semantics as the runtime (slugified connector_key fallback, numeric `-N`
60
+ -- suffixing). It is NOT guaranteed to be byte-identical to what
61
+ -- `ensureUniqueConnectionSlug` would pick for pathological mixed-name sets
62
+ -- (the runtime resolves in row-creation order against live DB state, which
63
+ -- pure SQL can't replay) — `packages/server/src/utils/connections.ts` is the
64
+ -- source of truth for new rows.
65
+ DO $$
66
+ DECLARE
67
+ v_changed integer;
68
+ BEGIN
69
+ LOOP
70
+ WITH ranked AS (
71
+ SELECT
72
+ id,
73
+ organization_id,
74
+ slug,
75
+ -- strip any suffix we may have appended on a prior pass so the
76
+ -- base groups stay stable across iterations
77
+ regexp_replace(slug, '-[0-9]+$', '') AS base_slug,
78
+ row_number() OVER (
79
+ PARTITION BY organization_id, regexp_replace(slug, '-[0-9]+$', '')
80
+ ORDER BY id
81
+ ) AS rn
82
+ FROM public.connections
83
+ WHERE deleted_at IS NULL
84
+ ),
85
+ target AS (
86
+ SELECT
87
+ id,
88
+ CASE WHEN rn = 1 THEN base_slug ELSE base_slug || '-' || rn::text END AS desired_slug
89
+ FROM ranked
90
+ )
91
+ UPDATE public.connections c
92
+ SET slug = t.desired_slug
93
+ FROM target t
94
+ WHERE t.id = c.id
95
+ AND c.slug IS DISTINCT FROM t.desired_slug;
96
+
97
+ GET DIAGNOSTICS v_changed = ROW_COUNT;
98
+ EXIT WHEN v_changed = 0;
99
+ END LOOP;
100
+ END $$;
101
+
102
+ -- Guard: there must be no live-slug duplicate per org before the unique index.
103
+ DO $$
104
+ DECLARE
105
+ v_dups integer;
106
+ BEGIN
107
+ SELECT count(*) INTO v_dups
108
+ FROM (
109
+ SELECT organization_id, slug
110
+ FROM public.connections
111
+ WHERE deleted_at IS NULL
112
+ GROUP BY organization_id, slug
113
+ HAVING count(*) > 1
114
+ ) d;
115
+ IF v_dups > 0 THEN
116
+ RAISE EXCEPTION 'connections.slug backfill left % duplicate (organization_id, slug) group(s) among live rows', v_dups;
117
+ END IF;
118
+ END $$;
119
+
120
+ ALTER TABLE public.connections
121
+ ALTER COLUMN slug SET NOT NULL;
122
+
123
+ CREATE UNIQUE INDEX IF NOT EXISTS connections_org_slug_unique
124
+ ON public.connections (organization_id, slug)
125
+ WHERE deleted_at IS NULL;
126
+
127
+ -- migrate:down
128
+
129
+ DROP INDEX IF EXISTS public.connections_org_slug_unique;
130
+ ALTER TABLE public.connections
131
+ DROP COLUMN IF EXISTS slug;
@@ -0,0 +1,24 @@
1
+ -- migrate:up
2
+
3
+ -- Maps a chat-platform user (Slack `U…`, …) to a Lobu account. Recorded as a
4
+ -- side effect of `/lobu link <code>` — the code is minted by an authenticated
5
+ -- `lobu run`, so `oauth_states.payload.createdBy` is the Lobu user. Once a user
6
+ -- is linked here, they can re-bind any chat to an agent they can manage via
7
+ -- `/lobu link <agentId>` without minting a fresh code.
8
+
9
+ CREATE TABLE IF NOT EXISTS public.chat_user_identities (
10
+ platform text NOT NULL,
11
+ team_id text NOT NULL DEFAULT '', -- workspace id; '' for platforms without one
12
+ platform_user_id text NOT NULL,
13
+ lobu_user_id text NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
14
+ created_at timestamptz NOT NULL DEFAULT now(),
15
+ updated_at timestamptz NOT NULL DEFAULT now(),
16
+ PRIMARY KEY (platform, team_id, platform_user_id)
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS chat_user_identities_lobu_user_idx
20
+ ON public.chat_user_identities (lobu_user_id);
21
+
22
+ -- migrate:down
23
+
24
+ DROP TABLE IF EXISTS public.chat_user_identities;
@@ -0,0 +1,50 @@
1
+ -- migrate:up
2
+
3
+ -- Let an auth_profile of kind 'browser_session' live on a specific device worker
4
+ -- instead of holding cookies in auth_data. When device_worker_id is set, cookies
5
+ -- live on disk inside the Mac app's managed --user-data-dir at user_data_dir;
6
+ -- the server never sees them. Cloud/fleet path (device_worker_id NULL,
7
+ -- auth_data populated) is unchanged.
8
+
9
+ ALTER TABLE public.auth_profiles
10
+ ADD COLUMN IF NOT EXISTS device_worker_id uuid,
11
+ ADD COLUMN IF NOT EXISTS browser_kind text,
12
+ ADD COLUMN IF NOT EXISTS user_data_dir text;
13
+
14
+ DO $$
15
+ BEGIN
16
+ IF NOT EXISTS (
17
+ SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_device_worker_id_fkey'
18
+ ) THEN
19
+ ALTER TABLE public.auth_profiles
20
+ ADD CONSTRAINT auth_profiles_device_worker_id_fkey
21
+ FOREIGN KEY (device_worker_id)
22
+ REFERENCES public.device_workers (id)
23
+ ON DELETE CASCADE;
24
+ END IF;
25
+ END$$;
26
+
27
+ DO $$
28
+ BEGIN
29
+ IF NOT EXISTS (
30
+ SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_browser_kind_check'
31
+ ) THEN
32
+ ALTER TABLE public.auth_profiles
33
+ ADD CONSTRAINT auth_profiles_browser_kind_check
34
+ CHECK (browser_kind IS NULL OR browser_kind = ANY (ARRAY['chrome','brave','arc','edge']));
35
+ END IF;
36
+ END$$;
37
+
38
+ CREATE INDEX IF NOT EXISTS auth_profiles_device_worker_idx
39
+ ON public.auth_profiles (device_worker_id)
40
+ WHERE device_worker_id IS NOT NULL;
41
+
42
+ -- migrate:down
43
+
44
+ DROP INDEX IF EXISTS public.auth_profiles_device_worker_idx;
45
+ ALTER TABLE public.auth_profiles
46
+ DROP CONSTRAINT IF EXISTS auth_profiles_browser_kind_check,
47
+ DROP CONSTRAINT IF EXISTS auth_profiles_device_worker_id_fkey,
48
+ DROP COLUMN IF EXISTS user_data_dir,
49
+ DROP COLUMN IF EXISTS browser_kind,
50
+ DROP COLUMN IF EXISTS device_worker_id;
@@ -0,0 +1,43 @@
1
+ -- migrate:up
2
+
3
+ -- Add `cdp_url` to auth_profiles. For a device-bound `browser_session`
4
+ -- profile, exactly one of {user_data_dir, cdp_url} should be set:
5
+ -- user_data_dir → managed Chrome with isolated cookies (default)
6
+ -- cdp_url → attach to a running Chrome via remote-debugging-port
7
+ -- The application enforces this invariant; we don't add a CHECK constraint
8
+ -- because the OR-on-NULL semantics are awkward to express and the column
9
+ -- is harmless when both are NULL (legacy fleet path with cookies in
10
+ -- auth_data jsonb).
11
+
12
+ ALTER TABLE public.auth_profiles
13
+ ADD COLUMN IF NOT EXISTS cdp_url text;
14
+
15
+ -- A device-bound browser_session profile MUST set exactly one of
16
+ -- (user_data_dir, cdp_url). Other profile kinds — and non-device-bound
17
+ -- browser_session profiles (cookies in auth_data, fleet-served) — are
18
+ -- exempt. Enforcing this at the DB stops a buggy admin tool or a bad
19
+ -- merge from setting both and then having the connector silently prefer
20
+ -- whichever code path it sees first.
21
+ DO $$
22
+ BEGIN
23
+ IF NOT EXISTS (
24
+ SELECT 1 FROM pg_constraint WHERE conname = 'auth_profiles_device_browser_path_xor'
25
+ ) THEN
26
+ ALTER TABLE public.auth_profiles
27
+ ADD CONSTRAINT auth_profiles_device_browser_path_xor
28
+ CHECK (
29
+ device_worker_id IS NULL
30
+ OR profile_kind <> 'browser_session'
31
+ OR (
32
+ (user_data_dir IS NOT NULL AND cdp_url IS NULL)
33
+ OR (user_data_dir IS NULL AND cdp_url IS NOT NULL)
34
+ )
35
+ );
36
+ END IF;
37
+ END$$;
38
+
39
+ -- migrate:down
40
+
41
+ ALTER TABLE public.auth_profiles
42
+ DROP CONSTRAINT IF EXISTS auth_profiles_device_browser_path_xor,
43
+ DROP COLUMN IF EXISTS cdp_url;
@@ -0,0 +1,86 @@
1
+ -- migrate:up
2
+
3
+ -- Unify notifications with events.
4
+ --
5
+ -- A notification was a (org, user, title, body, type, resource_url) row in
6
+ -- its own table with per-user `is_read`. Conceptually it's an event with a
7
+ -- particular kind + per-user delivery / read-state. This migration turns
8
+ -- every notification into:
9
+ -- 1. an event with semantic_type='notification' (org-wide visibility in
10
+ -- the events stream — searchable, addressable, links into knowledge);
11
+ -- 2. a notification_targets row (event_id, user_id, delivered_at, read_at)
12
+ -- so the inbox still scopes to the targeted user.
13
+ --
14
+ -- After this, "send to admins" inserts one event + N targets; "mark read"
15
+ -- updates a target row; "unread count" counts target rows without read_at.
16
+ -- Search across events naturally includes notifications, but a user's
17
+ -- inbox is still private to them.
18
+
19
+ CREATE TABLE public.notification_targets (
20
+ event_id bigint NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
21
+ user_id text NOT NULL,
22
+ delivered_at timestamp with time zone NOT NULL DEFAULT now(),
23
+ read_at timestamp with time zone,
24
+ PRIMARY KEY (event_id, user_id)
25
+ );
26
+
27
+ -- Fast inbox lookups: list a user's unread notifications, newest first.
28
+ CREATE INDEX idx_notification_targets_user_unread
29
+ ON public.notification_targets (user_id, delivered_at DESC)
30
+ WHERE read_at IS NULL;
31
+
32
+ -- All of a user's notifications, newest first (for read-list pagination).
33
+ CREATE INDEX idx_notification_targets_user_all
34
+ ON public.notification_targets (user_id, delivered_at DESC);
35
+
36
+ -- Backfill existing notifications. We keep 1:1 row mapping (one event per
37
+ -- legacy notification) for safety — at scale the right model is "one event,
38
+ -- many targets" but the old schema didn't capture that and we can't
39
+ -- retroactively coalesce without an oracle.
40
+ WITH legacy AS (
41
+ SELECT id, organization_id, user_id, type, title, body,
42
+ resource_type, resource_id, resource_url, is_read, created_at
43
+ FROM public.notifications
44
+ ORDER BY id ASC
45
+ ),
46
+ inserted AS (
47
+ INSERT INTO public.events
48
+ (organization_id, title, payload_text, payload_type, semantic_type,
49
+ occurred_at, created_at, metadata, origin_id)
50
+ SELECT
51
+ l.organization_id,
52
+ l.title,
53
+ l.body,
54
+ 'text',
55
+ 'notification',
56
+ l.created_at,
57
+ l.created_at,
58
+ jsonb_build_object(
59
+ 'notification_type', l.type,
60
+ 'resource_type', l.resource_type,
61
+ 'resource_id', l.resource_id,
62
+ 'resource_url', l.resource_url,
63
+ 'legacy_notification_id', l.id
64
+ ),
65
+ 'notification:legacy:' || l.id::text
66
+ FROM legacy l
67
+ RETURNING id AS event_id, (metadata->>'legacy_notification_id')::bigint AS legacy_id
68
+ )
69
+ INSERT INTO public.notification_targets (event_id, user_id, delivered_at, read_at)
70
+ SELECT
71
+ i.event_id,
72
+ l.user_id,
73
+ l.created_at,
74
+ CASE WHEN l.is_read THEN l.created_at ELSE NULL END
75
+ FROM inserted i
76
+ JOIN public.notifications l ON l.id = i.legacy_id;
77
+
78
+ -- Drop the legacy table. All readers/writers go through the new service.
79
+ DROP TABLE public.notifications;
80
+
81
+ -- migrate:down
82
+
83
+ -- One-way migration. Recovery is from backup; events created here stay
84
+ -- (deleting them would also wipe their notification_targets via CASCADE).
85
+ -- If you really need to roll back: re-create the table, copy notifications
86
+ -- back out of events + notification_targets, drop the event rows.