latticesql 4.3.7 → 5.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.
- package/README.md +91 -69
- package/dist/cli.js +52911 -35716
- package/dist/desktop-entry.js +52495 -35437
- package/dist/gui-assets/force-graph.mjs +1 -0
- package/dist/index.cjs +41284 -24203
- package/dist/index.d.cts +2131 -509
- package/dist/index.d.ts +2131 -509
- package/dist/index.js +41170 -24101
- package/docs/api-reference.md +33 -2
- package/docs/assistant.md +28 -3
- package/docs/bugs/2026-07-05-db-source-import-then-wipe.md +71 -0
- package/docs/bugs/2026-07-06-workspace-switch-stale-settings-takeover.md +61 -0
- package/docs/bugs/2026-07-11-chat-active-context-w-routes.md +63 -0
- package/docs/bugs/2026-07-11-connector-table-unknown-table.md +55 -0
- package/docs/bugs/2026-07-12-image-vision-byo-key-auth.md +55 -0
- package/docs/bugs/2026-07-13-desktop-bulk-ingest-js-heap-oom.md +104 -0
- package/docs/computed-tables.md +200 -0
- package/docs/connectors.md +143 -147
- package/docs/desktop.md +48 -0
- package/docs/retrieval.md +41 -0
- package/package.json +6 -6
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Computed tables
|
|
2
|
+
|
|
3
|
+
A **computed table** is a live, read-only view built from the tables you already
|
|
4
|
+
have. You describe _what each field is_ — a copied column, a calculation, an
|
|
5
|
+
AI-derived value, or a total across linked rows — and Lattice compiles that
|
|
6
|
+
description into a SQL VIEW and registers it as a queryable table. The values
|
|
7
|
+
are never copied: every read reflects the current state of the records the view
|
|
8
|
+
is built from.
|
|
9
|
+
|
|
10
|
+
Everything here is **additive and opt-in**: a workspace with no `computed:`
|
|
11
|
+
section behaves exactly as before.
|
|
12
|
+
|
|
13
|
+
## The shape of a definition
|
|
14
|
+
|
|
15
|
+
A computed table has one **base** table (a declared entity or another computed
|
|
16
|
+
table) and a set of named **fields**. The view always projects the base's
|
|
17
|
+
primary key as `id` first, then each field in declaration order:
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
computed:
|
|
21
|
+
ticket_summary:
|
|
22
|
+
base: tickets
|
|
23
|
+
fields:
|
|
24
|
+
title: { kind: alias, source: title }
|
|
25
|
+
team: { kind: alias, source: assignee.team.name }
|
|
26
|
+
is_urgent: { kind: calc, expr: 'priority >= 3', type: boolean }
|
|
27
|
+
tag_count: { kind: aggregate, via: ticket_tags.tag, fn: count }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Definitions live in the workspace config next to `entities:`. The GUI builder
|
|
31
|
+
(below) reads and writes the same section — a hand-edited config and a
|
|
32
|
+
GUI-built view are the same thing.
|
|
33
|
+
|
|
34
|
+
## The five field kinds
|
|
35
|
+
|
|
36
|
+
### `alias` — copy a field
|
|
37
|
+
|
|
38
|
+
Project a column of the base table, or follow declared `belongsTo` relations
|
|
39
|
+
with a dotted path:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
title: { kind: alias, source: title }
|
|
43
|
+
team: { kind: alias, source: assignee.team.name } # tickets → people → teams
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### `calc` — a calculation
|
|
47
|
+
|
|
48
|
+
A sandboxed expression over base columns and dotted paths. Expressions support
|
|
49
|
+
arithmetic, comparisons, `and`/`or`/`not`, `case … when`, `cast`, and a fixed
|
|
50
|
+
function set: `coalesce`, `nullif`, `lower`, `upper`, `trim`, `length`,
|
|
51
|
+
`substr`, `replace`, `abs`, `round`. Raw SQL never passes through — the
|
|
52
|
+
expression is parsed and re-emitted, so anything outside the grammar is
|
|
53
|
+
rejected at definition time.
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
is_urgent: { kind: calc, expr: 'priority >= 3', type: boolean }
|
|
57
|
+
label: { kind: calc, expr: "coalesce(nickname, name, 'unknown')" }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`type` is the display type (defaults to `text`).
|
|
61
|
+
|
|
62
|
+
### `ai_classify` — an AI-assigned category
|
|
63
|
+
|
|
64
|
+
A model assigns each row one label from a fixed set, based on one input field:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
sentiment:
|
|
68
|
+
kind: ai_classify
|
|
69
|
+
input: body
|
|
70
|
+
prompt: How does the customer feel in this message?
|
|
71
|
+
labels: [happy, neutral, frustrated]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each **distinct input value** is labeled once and the result is materialized
|
|
75
|
+
into a bookkeeping table the view joins against — the model is never re-run at
|
|
76
|
+
read time, and two rows with the same input always get the same label.
|
|
77
|
+
|
|
78
|
+
### `ai_transform` — AI-written text
|
|
79
|
+
|
|
80
|
+
A model derives a free-form value from one or more input fields (input order is
|
|
81
|
+
part of the identity — reordering inputs is a new definition):
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
summary:
|
|
85
|
+
kind: ai_transform
|
|
86
|
+
inputs: [title, body]
|
|
87
|
+
prompt: Summarize this ticket in one sentence.
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Results are cached per row, keyed on the current input values — see
|
|
91
|
+
[staleness](#refreshing-ai-values--staleness) below.
|
|
92
|
+
|
|
93
|
+
### `aggregate` — a total across links
|
|
94
|
+
|
|
95
|
+
Fold many linked rows into one value per base row, through a junction table:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
tag_count: { kind: aggregate, via: ticket_tags.tag, fn: count }
|
|
99
|
+
total_paid: { kind: aggregate, via: invoice_payments.payment, fn: sum, column: amount }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`via` is `<junctionTable>.<remoteRelation>`; `fn` is one of `count`, `sum`,
|
|
103
|
+
`avg`, `min`, `max`, `concat`; `column` names the remote column to aggregate
|
|
104
|
+
(required for every `fn` except `count`).
|
|
105
|
+
|
|
106
|
+
## Building one in the GUI
|
|
107
|
+
|
|
108
|
+
Open the **Tables** tab. The explorer's third column is **Computed Tables**;
|
|
109
|
+
its header carries a **+ New** button that opens the builder (`#/computed/new`).
|
|
110
|
+
|
|
111
|
+
1. **Name it** — lowercase letters, numbers, and underscores.
|
|
112
|
+
2. **Pick the base table** ("Built from"). Fields can come from the base and
|
|
113
|
+
anything linked to it — the pickers list every reachable column, including
|
|
114
|
+
dotted paths through linked tables and aggregate targets through junctions.
|
|
115
|
+
3. **Add fields.** Each row is a name plus a kind — _Copy a field_,
|
|
116
|
+
_Calculation_, _AI category_, _AI text_, or _Total across links_ — with the
|
|
117
|
+
inputs that kind needs.
|
|
118
|
+
4. **Preview.** A dry run compiles the definition against your live schema and
|
|
119
|
+
shows up to 20 sample rows. Nothing is created or saved by a preview. Each
|
|
120
|
+
field is stamped ✓ (compiled) or ✕ (the failing field), and the compiled
|
|
121
|
+
SELECT is available under the collapsed **Definition (SQL)** block.
|
|
122
|
+
5. **Create.** Enabled once the current definition has previewed successfully
|
|
123
|
+
(any edit re-requires a preview). Creating registers the view, persists the
|
|
124
|
+
definition to the workspace config, and lands you on the view's rows.
|
|
125
|
+
|
|
126
|
+
Editing an existing view (**Edit definition →** in a computed card's detail
|
|
127
|
+
panel, or `#/computed/<name>`) is the same form plus two more actions:
|
|
128
|
+
**Refresh values** (runs the AI fill now, streaming per-field progress) and
|
|
129
|
+
**Remove**.
|
|
130
|
+
|
|
131
|
+
The explorer also shows the projection itself: a dashed connector from the base
|
|
132
|
+
table to the computed view, and the view's detail panel lists the base under
|
|
133
|
+
_Upstream · sources_.
|
|
134
|
+
|
|
135
|
+
## Refreshing AI values + staleness
|
|
136
|
+
|
|
137
|
+
AI fields are **materialized, never computed at read time**. A background fill
|
|
138
|
+
runs when a definition with AI fields is created or changed; you can also run
|
|
139
|
+
it on demand (**Refresh values** in the builder, or **Refresh** in the Tables
|
|
140
|
+
explorer's detail panel).
|
|
141
|
+
|
|
142
|
+
Staleness is handled by construction: an AI value is cached against the exact
|
|
143
|
+
input values it was derived from. When a source row changes, its cached value
|
|
144
|
+
no longer matches, so the field reads **blank for that row until the next
|
|
145
|
+
refresh** — you may see a gap, but never a stale value. Unchanged rows keep
|
|
146
|
+
their cached values (no re-billing for what's already known), and a changed
|
|
147
|
+
prompt, label set, input list, or model invalidates exactly that field's cache.
|
|
148
|
+
|
|
149
|
+
## Read-only rows
|
|
150
|
+
|
|
151
|
+
A computed table's rows are derived, not authored. The GUI marks the view with
|
|
152
|
+
a **Computed** badge on its rows page and record pages, shows where the values
|
|
153
|
+
come from, and offers no editing affordances (no field editor, no markdown
|
|
154
|
+
editing, no per-row delete). The server enforces the same rule: any direct
|
|
155
|
+
write to a computed table is refused with a message pointing you at the source
|
|
156
|
+
tables or the definition. To change what a computed table shows, change the
|
|
157
|
+
records it is built from — or edit its definition.
|
|
158
|
+
|
|
159
|
+
The source tables are protected in the other direction too: deleting or
|
|
160
|
+
renaming a table is refused while a computed table reads from it, naming the
|
|
161
|
+
definitions that depend on it.
|
|
162
|
+
|
|
163
|
+
## Deleting + undo
|
|
164
|
+
|
|
165
|
+
Removing a computed view drops the view and its definition (and its AI-value
|
|
166
|
+
cache). Nothing about the source tables changes. The operation is recorded in
|
|
167
|
+
version history and is **revertible** — undo re-creates the view from the
|
|
168
|
+
captured definition. A computed table that other computed tables are built on
|
|
169
|
+
cannot be deleted until they are deleted or repointed.
|
|
170
|
+
|
|
171
|
+
A refresh, by contrast, only fills in AI-derived cells; it appears in history
|
|
172
|
+
as an informational entry with nothing to revert.
|
|
173
|
+
|
|
174
|
+
## HTTP API
|
|
175
|
+
|
|
176
|
+
The GUI drives computed tables through `lattice gui`'s local server. On a team
|
|
177
|
+
cloud, mutating verbs (and preview/refresh, which belong to the owner-side
|
|
178
|
+
builder) are owner-only — a scoped member gets a 403.
|
|
179
|
+
|
|
180
|
+
| Method | Endpoint | What it does |
|
|
181
|
+
| ------ | ------------------------------------ | ----------------------------------------------------------------------- |
|
|
182
|
+
| GET | `/api/computed-tables` | List definitions with per-field fill/error state |
|
|
183
|
+
| GET | `/api/computed-tables/:name` | One definition plus its compiled SELECT (`{ def, sql }`) |
|
|
184
|
+
| GET | `/api/computed-tables/fields?base=` | Field-picker candidates for a base: columns, dotted paths, aggregates |
|
|
185
|
+
| POST | `/api/computed-tables/preview` | Dry-run a definition (`{ def, limit? }`) — no DDL, nothing persisted |
|
|
186
|
+
| POST | `/api/computed-tables` | Create (`{ name, def }`) — registers the view + persists the definition |
|
|
187
|
+
| PUT | `/api/computed-tables/:name` | Update the definition (`{ def }`); dependents recompile in order |
|
|
188
|
+
| DELETE | `/api/computed-tables/:name` | Delete the view + definition (refused while dependents exist) |
|
|
189
|
+
| POST | `/api/computed-tables/:name/refresh` | Run the AI fill now; streams per-field progress as NDJSON |
|
|
190
|
+
|
|
191
|
+
The preview response is `{ columns, rows, sql, fieldTypes, pendingAi }` —
|
|
192
|
+
`pendingAi` maps each AI field to how many items a fill pass would enqueue.
|
|
193
|
+
The refresh stream emits one JSON object per line:
|
|
194
|
+
`{ phase: 'field', field, message }` when a field starts,
|
|
195
|
+
`{ phase: 'field-done', field, filled, pending, error? }` when it finishes,
|
|
196
|
+
and a final `{ done: true }` (or `{ phase: 'error', message }`).
|
|
197
|
+
|
|
198
|
+
Definition shape errors, unknown bases/columns, and dependency refusals come
|
|
199
|
+
back as `400 { error }` with the compiler's message verbatim — the same text
|
|
200
|
+
the builder shows in its error strip.
|
package/docs/connectors.md
CHANGED
|
@@ -1,166 +1,162 @@
|
|
|
1
|
-
# Connectors
|
|
1
|
+
# MCP Connectors
|
|
2
2
|
|
|
3
3
|
Connectors sync data from external systems into Lattice as **connected data
|
|
4
4
|
types** — tables whose rows are ingested from a source rather than authored
|
|
5
|
-
locally.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
5
|
+
locally. Every connector is an **MCP connection**: Lattice runs as a local
|
|
6
|
+
[Model Context Protocol](https://modelcontextprotocol.io) client and talks to
|
|
7
|
+
the server directly from your machine. There is no provider-specific connector
|
|
8
|
+
code and no broker — a provider is just another MCP server URL.
|
|
9
|
+
|
|
10
|
+
## Connecting a server (GUI)
|
|
11
|
+
|
|
12
|
+
Open **Configure → MCP Connectors**. Every connection you have lives in this
|
|
13
|
+
tab: name (the server's self-reported name from the MCP handshake), URL, status,
|
|
14
|
+
last sync, and per-server **Refresh** / **Disconnect** / **Reconnect** actions.
|
|
15
|
+
|
|
16
|
+
To add one, paste the server's URL into **Add an MCP connector** and click
|
|
17
|
+
**Connect**:
|
|
18
|
+
|
|
19
|
+
- **Open servers** (no auth) connect and sync immediately.
|
|
20
|
+
- **OAuth servers** open the provider's own sign-in in your browser. Approve it
|
|
21
|
+
and return to Lattice — the connection completes on a loopback callback and
|
|
22
|
+
the first sync runs.
|
|
23
|
+
- **Servers that require a pre-registered OAuth client** (no dynamic
|
|
24
|
+
registration and no client-ID-metadata-document support) get a clear prompt:
|
|
25
|
+
the form reveals **client ID / client secret** fields for the credentials the
|
|
26
|
+
provider issued you, then the same OAuth sign-in runs with them.
|
|
27
|
+
|
|
28
|
+
Each added server is its own connection — connect as many as you like,
|
|
29
|
+
side by side. **Disconnect** soft-deletes the synced rows (recoverable), prunes
|
|
30
|
+
their rendered context, revokes the stored tokens, and keeps the server URL so
|
|
31
|
+
**Reconnect** can re-run the sign-in without re-entering anything.
|
|
32
|
+
|
|
33
|
+
## How OAuth client identity works
|
|
34
|
+
|
|
35
|
+
Lattice identifies itself to a provider's authorization server using the first
|
|
36
|
+
mechanism the server supports, in this order:
|
|
37
|
+
|
|
38
|
+
1. **A stored client** — either from a previous dynamic registration or the
|
|
39
|
+
client ID you entered by hand.
|
|
40
|
+
2. **Client-ID metadata document (CIMD)** — the modern MCP mechanism: the
|
|
41
|
+
client_id IS a stable HTTPS URL pointing at a static JSON document that
|
|
42
|
+
describes the app (name, redirect URIs, grant types). Lattice's document is
|
|
43
|
+
hosted at `https://latticedesktop.com/oauth/client-metadata.json`.
|
|
44
|
+
3. **Dynamic client registration** (RFC 7591), for servers that offer a
|
|
45
|
+
registration endpoint.
|
|
46
|
+
|
|
47
|
+
Every flow is a PKCE (S256) authorization-code flow for a public client; tokens
|
|
48
|
+
are refreshed automatically.
|
|
49
|
+
|
|
50
|
+
### The privacy model
|
|
51
|
+
|
|
52
|
+
The hosted metadata document is **static app identity, not a data plane**:
|
|
53
|
+
|
|
54
|
+
- It is the same fixed file for every Lattice install, contains **zero user
|
|
55
|
+
data**, and is never written.
|
|
56
|
+
- Only the **provider's authorization server** fetches it (to answer "who is
|
|
57
|
+
this client asking for access?"). Your machine and your browser never touch
|
|
58
|
+
the hosting domain during the flow.
|
|
59
|
+
- MCP traffic flows **directly** between your local Lattice and the MCP server —
|
|
60
|
+
nothing is proxied through any Lattice-hosted service.
|
|
61
|
+
- OAuth tokens live only in the machine-local encrypted credential store
|
|
62
|
+
(AES-256-GCM under a machine-local master key). They never enter the
|
|
63
|
+
workspace database, API responses, or logs.
|
|
64
|
+
|
|
65
|
+
Self-hosters can point `LATTICE_MCP_CLIENT_METADATA_URL` at their own document;
|
|
66
|
+
setting it to an empty string disables the mechanism entirely (dynamic
|
|
67
|
+
registration and manual client entry still work).
|
|
68
|
+
|
|
69
|
+
## What gets synced
|
|
70
|
+
|
|
71
|
+
The connector introspects the server and pulls its readable content into one
|
|
72
|
+
connected table, **`mcp_items`**:
|
|
73
|
+
|
|
74
|
+
| column | meaning |
|
|
75
|
+
| --------- | --------------------------------------------------------------------- |
|
|
76
|
+
| `item_id` | natural key (`<tool>:<id>` for items, `resource:<uri>` for resources) |
|
|
77
|
+
| `kind` | `item` (from a read tool) or `resource` (from `resources/list`) |
|
|
78
|
+
| `tool` | the MCP tool that produced an item |
|
|
79
|
+
| `server` | the server's hostname — items from different servers stay apart |
|
|
80
|
+
| `title` | best-effort title |
|
|
81
|
+
| `summary` | best-effort description/snippet |
|
|
82
|
+
| `data` | the raw JSON payload (for resources: `{ uri, mimeType }`) |
|
|
83
|
+
|
|
84
|
+
Discovery calls the server's `tools/list`, invokes each **no-argument read
|
|
85
|
+
tool** (bounded per tool; obviously write-shaped tools are never called), and
|
|
86
|
+
then lists the server's advertised **resources** — its "available files" — via
|
|
87
|
+
the standard `resources/list`. Rows are full Lattice rows: queryable, full-text
|
|
88
|
+
searchable, rendered to context, per-member `private` by default, and stamped
|
|
89
|
+
with immutable lineage (`_source_connector_id`, `_source_model`). A re-sync
|
|
90
|
+
upserts on the natural key and soft-deletes rows that vanished from the source.
|
|
91
|
+
|
|
92
|
+
Freshness is pull-based — there is no background scheduler. Syncs run on
|
|
93
|
+
connect, on **Refresh**, and on GUI load when the last sync is older than an
|
|
94
|
+
hour.
|
|
95
|
+
|
|
96
|
+
## Library API
|
|
97
|
+
|
|
98
|
+
The same engine is a library surface:
|
|
52
99
|
|
|
53
100
|
```typescript
|
|
54
101
|
import {
|
|
55
|
-
|
|
102
|
+
genericConnector,
|
|
56
103
|
createConnector,
|
|
57
104
|
syncConnector,
|
|
58
105
|
syncIfStale,
|
|
59
106
|
disconnectConnector,
|
|
60
107
|
} from 'latticesql';
|
|
61
108
|
|
|
62
|
-
const connector =
|
|
63
|
-
|
|
64
|
-
// Validate + store the member's Atlassian credentials, returning a connection handle:
|
|
65
|
-
const { connectionId, displayName } = await connector.connect({
|
|
66
|
-
site: 'https://your-domain.atlassian.net',
|
|
67
|
-
email: 'you@example.com',
|
|
68
|
-
apiToken: process.env.JIRA_API_TOKEN!,
|
|
69
|
-
});
|
|
109
|
+
const connector = genericConnector();
|
|
70
110
|
|
|
111
|
+
// GUI-less flows drive beginConnect/completeConnect (OAuth) or connect a local
|
|
112
|
+
// stdio server; the registry + sync engine are shared with the GUI routes:
|
|
71
113
|
const connectorId = await createConnector(db, {
|
|
72
|
-
connector: '
|
|
73
|
-
toolkit: '
|
|
74
|
-
displayName:
|
|
114
|
+
connector: 'mcp',
|
|
115
|
+
toolkit: 'mcp',
|
|
116
|
+
displayName: 'My MCP server',
|
|
75
117
|
connectionRef: connectionId,
|
|
76
118
|
connectedBy: 'user-123',
|
|
77
119
|
});
|
|
78
|
-
|
|
79
|
-
await
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
## Sync model
|
|
83
|
-
|
|
84
|
-
- **`syncConnector(db, connector, connectorId)`** — full sync: paginated +
|
|
85
|
-
bounded fetch per model, idempotent upsert with lineage stamping, soft-delete
|
|
86
|
-
of rows that vanished from the source, and graph-edge derivation from FK
|
|
87
|
-
columns. Records the outcome on the connector; an external-sync failure is
|
|
88
|
-
re-thrown (never swallowed).
|
|
89
|
-
- **`syncIfStale(db, connector, connectorId, maxAgeMs?)`** — no-op if the last
|
|
90
|
-
sync is within `maxAgeMs` (default 1 hour). Call it on app load.
|
|
91
|
-
- **`syncStaleConnectors(db, connector, maxAgeMs?)`** — sync every stale
|
|
92
|
-
connector this implementation serves. The GUI calls this on load, so connected
|
|
93
|
-
data refreshes hourly **without a scheduler**. Manual refresh forces a sync.
|
|
94
|
-
|
|
95
|
-
Reads done by the sync engine are bounded and column-projected — it never scans a
|
|
96
|
-
full table to diff. Per-parent models iterate the parent's already-synced keys
|
|
97
|
-
(comments are fetched per issue; sprints per board).
|
|
98
|
-
|
|
99
|
-
## Disconnecting
|
|
100
|
-
|
|
101
|
-
```typescript
|
|
102
|
-
await disconnectConnector(db, connector, connectorId, { outputDir });
|
|
120
|
+
await syncConnector(db, connector, connectorId);
|
|
121
|
+
await syncIfStale(db, connector, connectorId); // re-sync when stale (1h)
|
|
122
|
+
await disconnectConnector(db, connector, connectorId); // soft teardown
|
|
103
123
|
```
|
|
104
124
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
| `jira_boards` | board id | edge → project |
|
|
144
|
-
| `jira_sprints` | sprint id | fetched per board; edge → board |
|
|
145
|
-
|
|
146
|
-
## Adding a connector
|
|
147
|
-
|
|
148
|
-
Implement the `Connector` SPI and point the GUI/registry at it. The SPI is small:
|
|
149
|
-
|
|
150
|
-
- `connector` / `toolkits()` / `models(toolkit)` — identity + the connected
|
|
151
|
-
`ConnectedModelDef`s (table schema, natural key, FK relations → graph edges).
|
|
152
|
-
- `listChanges(toolkit, model, ctx)` — an async iterable of normalized
|
|
153
|
-
`ExternalRecord`s for one model, **paged and bounded** (per-parent models read
|
|
154
|
-
`ctx.parentKey`).
|
|
155
|
-
- `disconnect(connectionRef)` — revoke / drop the stored connection.
|
|
156
|
-
- `authorize` / `completeAuth` — for an OAuth-redirect source. A
|
|
157
|
-
credential-based connector (like Jira) instead exposes a `connect(creds)`
|
|
158
|
-
method that validates + stores the credentials and returns a connection handle;
|
|
159
|
-
the GUI route calls it when the connector supports it.
|
|
160
|
-
|
|
161
|
-
The sync engine, graph wiring, teardown, and ACL all work from the model
|
|
162
|
-
descriptors — no further code is required.
|
|
163
|
-
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
```
|
|
125
|
+
Library consumers embedding a specific provider can use
|
|
126
|
+
`introspectiveConnector(spec)` (the same engine pre-pointed at a fixed endpoint
|
|
127
|
+
with its own table name) or `SimpleMcpConnector` (hand-typed models + per-model
|
|
128
|
+
tool bindings) — see `src/connectors/types.ts` for the SPI.
|
|
129
|
+
|
|
130
|
+
On a cloud workspace, the owner's `enableConnectorRls(db, connector, 'mcp')`
|
|
131
|
+
scopes connected rows per member (private by default).
|
|
132
|
+
|
|
133
|
+
> `@modelcontextprotocol/sdk` is an **optional dependency**. The package
|
|
134
|
+
> compiles and runs without it; it is loaded lazily and a clear error is thrown
|
|
135
|
+
> only when an MCP connector is actually used. Install it to use connectors:
|
|
136
|
+
> `npm install @modelcontextprotocol/sdk`.
|
|
137
|
+
|
|
138
|
+
## Security
|
|
139
|
+
|
|
140
|
+
- **SSRF guard**: every HTTP hop — the server URL, redirects, and each
|
|
141
|
+
OAuth-discovered endpoint — is DNS-resolved and re-validated against a
|
|
142
|
+
private/loopback/link-local/metadata-address blocklist before it is fetched.
|
|
143
|
+
- **Token storage**: machine-local encrypted file, never the shared DB.
|
|
144
|
+
- **Error hygiene**: sync errors surfaced in the GUI are sanitized so a raw DB
|
|
145
|
+
constraint error can never echo another member's data.
|
|
146
|
+
|
|
147
|
+
## Troubleshooting
|
|
148
|
+
|
|
149
|
+
- **"Incompatible auth server: does not support dynamic client registration"** —
|
|
150
|
+
the server supports neither CIMD nor dynamic registration. The GUI's add form
|
|
151
|
+
reveals client ID/secret fields; create an OAuth client in the provider's
|
|
152
|
+
admin console and paste its credentials.
|
|
153
|
+
- **The browser shows a provider error at sign-in** — the provider's
|
|
154
|
+
authorization server could not validate the client. If you overrode
|
|
155
|
+
`LATTICE_MCP_CLIENT_METADATA_URL`, make sure that URL is publicly reachable
|
|
156
|
+
and serves JSON.
|
|
157
|
+
- **"Sign-in didn't complete"** — the loopback callback never arrived (browser
|
|
158
|
+
closed mid-flow, or the sign-in was abandoned). Click Connect again.
|
|
159
|
+
- **A server connects but syncs nothing** — it may expose only tools that
|
|
160
|
+
require arguments (skipped in introspective mode) and no resources. The
|
|
161
|
+
connection is still valid; data arrives when the server offers no-argument
|
|
162
|
+
read tools or resources.
|
package/docs/desktop.md
CHANGED
|
@@ -38,6 +38,15 @@ browser tab.
|
|
|
38
38
|
while keeping the auth session local to the app.
|
|
39
39
|
- **Upgrade-on-run.** On launch the app checks the release channel via
|
|
40
40
|
`Deno.autoUpdate()` and the `latest.json` manifest published with each release.
|
|
41
|
+
- **Update-available hint while running.** A window left open for a long time would
|
|
42
|
+
otherwise miss releases that ship after launch, so the GUI also polls the same
|
|
43
|
+
`latest.json` (read-only — no download or relaunch) and shows an **"Update
|
|
44
|
+
available — Restart to update"** link next to the version chip when a newer
|
|
45
|
+
release is published. Clicking it runs the bundled updater (download + relaunch).
|
|
46
|
+
- **Disable auto-update.** Set `LATTICE_NO_AUTO_UPDATE=1` to pin the app to its
|
|
47
|
+
current version — no manifest probe, no `Deno.autoUpdate()`, no relaunch (the
|
|
48
|
+
desktop equivalent of the CLI's `lattice gui --no-auto-update`). Useful for
|
|
49
|
+
testing, air-gapped, or reproducible-demo runs.
|
|
41
50
|
- **Upgrade-on-install.** The download page always links the latest release, so a
|
|
42
51
|
fresh install is always current.
|
|
43
52
|
|
|
@@ -66,6 +75,45 @@ Releases are cut by the `Desktop Release` workflow on a `v*` tag: it builds both
|
|
|
66
75
|
OSes, generates `latest.json`, and uploads the installers + manifest to the
|
|
67
76
|
GitHub Release.
|
|
68
77
|
|
|
78
|
+
### Code signing (maintainers)
|
|
79
|
+
|
|
80
|
+
Builds are **ad-hoc (unsigned) by default** — contributors need no certificates,
|
|
81
|
+
and the unsigned `.pkg` still installs to `/Applications` (with the macOS
|
|
82
|
+
"unidentified developer" prompt on first launch). Real signing activates only
|
|
83
|
+
when credentials are supplied through the environment; no identity, team, or key
|
|
84
|
+
value is hardcoded anywhere in the repo.
|
|
85
|
+
|
|
86
|
+
To produce a signed + notarized macOS build locally, export before running
|
|
87
|
+
`npm run desktop:build:mac:pkg`:
|
|
88
|
+
|
|
89
|
+
- `SIGN_APP_IDENTITY` — the Developer ID **Application** identity (certificate
|
|
90
|
+
name or SHA-1) passed to `codesign`
|
|
91
|
+
- `SIGN_INSTALLER_IDENTITY` — the Developer ID **Installer** identity passed to
|
|
92
|
+
`pkgbuild` (optional; without it the `.pkg` itself is unsigned)
|
|
93
|
+
- notarization credentials, either:
|
|
94
|
+
- `NOTARY_PROFILE` — a `notarytool store-credentials` keychain profile name, or
|
|
95
|
+
- `NOTARY_KEY_FILE` + `NOTARY_KEY_ID` + `NOTARY_ISSUER_ID` — an App Store
|
|
96
|
+
Connect API key (`.p8` file path, key id, issuer id)
|
|
97
|
+
|
|
98
|
+
The app is signed inside-out under the hardened runtime with
|
|
99
|
+
[`scripts/lattice.entitlements`](../scripts/lattice.entitlements) (JIT
|
|
100
|
+
entitlements required by the embedded JavaScript runtime), then the `.app`,
|
|
101
|
+
`.pkg`, and `.dmg` are each notarized and stapled, hard-failing unless Apple
|
|
102
|
+
returns **Accepted**.
|
|
103
|
+
|
|
104
|
+
The `Desktop Release` workflow does the same automatically when these repository
|
|
105
|
+
secrets are configured (absent secrets — e.g. on forks — still produce working
|
|
106
|
+
unsigned artifacts):
|
|
107
|
+
|
|
108
|
+
| Secret | Contents |
|
|
109
|
+
| ---------------------------------------------------------------- | -------------------------------------------------------- |
|
|
110
|
+
| `MACOS_CERT_APP_P12` / `MACOS_CERT_APP_P12_PASSWORD` | Developer ID Application cert (base64 `.p12`) + password |
|
|
111
|
+
| `MACOS_CERT_INSTALLER_P12` / `MACOS_CERT_INSTALLER_P12_PASSWORD` | Developer ID Installer cert (base64 `.p12`) + password |
|
|
112
|
+
| `MACOS_KEYCHAIN_PASSWORD` | password for the ephemeral build keychain |
|
|
113
|
+
| `MACOS_APP_IDENTITY` / `MACOS_INSTALLER_IDENTITY` | the two signing identity names |
|
|
114
|
+
| `NOTARY_KEY_P8` | App Store Connect API key (base64 `.p8`) |
|
|
115
|
+
| `NOTARY_KEY_ID` / `NOTARY_ISSUER_ID` | the API key's id + issuer id |
|
|
116
|
+
|
|
69
117
|
## Limitations
|
|
70
118
|
|
|
71
119
|
- **Image processing (`sharp`) and `sqlite-vec` acceleration are unavailable** in
|
package/docs/retrieval.md
CHANGED
|
@@ -92,6 +92,47 @@ Opt-in per-table approximate-nearest-neighbor index built from the stored vector
|
|
|
92
92
|
`doctor` reports). Requires the extension server-side (pgvector) or loaded
|
|
93
93
|
(sqlite-vec).
|
|
94
94
|
|
|
95
|
+
Once built, the index is **kept in sync with writes** — you don't rebuild it by
|
|
96
|
+
hand after every change. On Postgres each insert/update/delete mirrors the row
|
|
97
|
+
into the index incrementally (on the same fire-and-forget path as the embedding
|
|
98
|
+
write), and `refreshEmbeddings` reconciles the index after a bulk backfill. As a
|
|
99
|
+
universal safety net across backends, `search()` **verifies the index is in sync
|
|
100
|
+
with the stored vectors before trusting it** (a cheap count-parity check); if the
|
|
101
|
+
index has drifted it transparently falls back to the exact in-process scan over
|
|
102
|
+
the source-of-truth store, so a stale index is never silently served — it only
|
|
103
|
+
ever costs a slower query, never a wrong result. (Incremental per-row maintenance
|
|
104
|
+
is currently Postgres-only; a `sqlite-vec` index falls back to the scan after a
|
|
105
|
+
write until it is rebuilt — correct either way.)
|
|
106
|
+
|
|
107
|
+
**Cloud members.** In a multi-member cloud, a scoped member has no grant on the
|
|
108
|
+
internal embeddings store or the native index, so its `search()` / `hybridSearch()`
|
|
109
|
+
reach the vectors only through a `SECURITY DEFINER` function that returns just the
|
|
110
|
+
chunks for rows the member may see (filtered by the same row-visibility rule that
|
|
111
|
+
governs every other read, keyed on the member's role) and scores them in-process.
|
|
112
|
+
The member scan is exact and has no over-fetch by which a member could infer hidden
|
|
113
|
+
rows; result rows are re-checked by row-level security on the base relation. Owners
|
|
114
|
+
and local (non-cloud) callers are unaffected — the routing is automatic.
|
|
115
|
+
|
|
116
|
+
**Tuning & operations.** The HNSW index can be tuned at build time via
|
|
117
|
+
`embeddings.index = { m, efConstruction }` and per query via `search(..., { efSearch })`
|
|
118
|
+
(`hybridSearch` too); all default to pgvector's own values, so omitting them builds
|
|
119
|
+
and queries exactly as before. `lattice index status` shows per-table index health
|
|
120
|
+
(dimension, params, build time, staleness) from an internal `__lattice_vector_index`
|
|
121
|
+
registry; `lattice reindex <table>` rebuilds one table's index, and `lattice doctor
|
|
122
|
+
--fix` rebuilds any index it reports stale. An auto-rebuild after a bulk
|
|
123
|
+
`refreshEmbeddings` reuses the recorded build params.
|
|
124
|
+
|
|
125
|
+
**Scale.** For larger corpora, `embeddings.index.quantization = 'halfvec'` (pgvector
|
|
126
|
+
≥ 0.7) stores the index at 16-bit half precision — roughly halving its memory at a
|
|
127
|
+
small recall cost — while the embeddings store stays full precision, so the exact
|
|
128
|
+
scan fallback and any later full-precision rebuild are unaffected. Lattice runs
|
|
129
|
+
against a **single** Postgres or SQLite database: there is no sharding, no
|
|
130
|
+
replication, and no distributed index, and `search()` / `hybridSearch()` keep the
|
|
131
|
+
`topK ≤ 1000` bound (`SEARCH_TOPK_MAX`) so one query can't fan out into a
|
|
132
|
+
whole-table read. These are deliberate boundaries — when you need billions of
|
|
133
|
+
vectors, horizontal scale-out, or a managed vector service, run a dedicated vector
|
|
134
|
+
database alongside Lattice and keep Lattice as the system of record.
|
|
135
|
+
|
|
95
136
|
> **v4.2 — bounded retrieval reads.** `search()` / `hybridSearch()` clamp the
|
|
96
137
|
> caller's `topK` (`clampTopK`, `SEARCH_TOPK_MAX = 1000`) **before** the indexed
|
|
97
138
|
> arm over-fetches `topK * N` candidates, so a single large `topK` can't fan out
|