neon 8.4.2 → 8.4.3
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 +29 -0
- package/dist/commands/subscribe.js +155 -39
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -306,6 +306,35 @@ regression + TAP tests.
|
|
|
306
306
|
|
|
307
307
|
- **TLS cipher is runtime-dependent.** The negotiated TLS 1.3 ciphersuite is chosen by the host runtime's TLS library from an offer byte-identical to libpq's. Under Node (OpenSSL) that is `TLS_AES_256_GCM_SHA384`, matching vanilla psql; under Bun (BoringSSL) it is `TLS_AES_128_GCM_SHA256`. Both are TLS 1.3 AEAD suites with no practical security difference, and neither runtime exposes a client-side knob to steer the selection.
|
|
308
308
|
|
|
309
|
+
## Subscribe to a Realtime query
|
|
310
|
+
|
|
311
|
+
`neon subscribe` streams a live SQL query until interrupted:
|
|
312
|
+
|
|
313
|
+
```bash
|
|
314
|
+
neon subscribe "SELECT id, title, completed FROM realtime_todos ORDER BY id"
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The default terminal output is append-only. It identifies the selected project,
|
|
318
|
+
branch, and database; prints the initial result once; reports connection-state
|
|
319
|
+
changes; and then prints each published batch with its transaction IDs, a compact
|
|
320
|
+
`+insert ~update -delete` summary, and the affected rows. It does not take over the
|
|
321
|
+
terminal or require keyboard navigation. Pass `--changes` to display the raw reset
|
|
322
|
+
and batch events instead of the materialized view.
|
|
323
|
+
|
|
324
|
+
For scripts and agents, JSON output is a JSON Lines stream with one complete event
|
|
325
|
+
per line. YAML output uses one document per event:
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
neon subscribe --changes --output json "SELECT * FROM realtime_todos" |
|
|
329
|
+
jq --unbuffered 'select(.type == "batch")'
|
|
330
|
+
|
|
331
|
+
neon subscribe --output yaml "SELECT * FROM realtime_todos"
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Machine-readable output retains the event stream: without `--changes`, each event is
|
|
335
|
+
a complete `snapshot`; with `--changes`, events are raw `reset` and `batch` objects.
|
|
336
|
+
Lifecycle labels and the target header are terminal-only.
|
|
337
|
+
|
|
309
338
|
## Configure autocompletion
|
|
310
339
|
|
|
311
340
|
The Neon CLI supports autocompletion, which you can configure in a few easy steps. See [Neon CLI commands — completion](https://neon.com/docs/cli/completion) for instructions.
|
|
@@ -26,11 +26,11 @@ const builder = (argv) => argv.usage("$0 subscribe \"<query>\" [options]").posit
|
|
|
26
26
|
type: "string"
|
|
27
27
|
},
|
|
28
28
|
changes: {
|
|
29
|
-
describe: "Show raw resets and transaction batches
|
|
29
|
+
describe: "Show raw resets and transaction batches",
|
|
30
30
|
type: "boolean",
|
|
31
31
|
default: false
|
|
32
32
|
}
|
|
33
|
-
}).example("$0 subscribe \"SELECT * FROM items\"", "Print the
|
|
33
|
+
}).example("$0 subscribe \"SELECT * FROM items\"", "Print the initial result followed by a readable transaction log").example("$0 subscribe --changes \"SELECT * FROM items\"", "Print raw reset and delta events").epilogue("The subscription runs until interrupted. Terminal output is an append-only snapshot and transaction log; JSON output is newline-delimited; YAML output uses one document per event.").middleware(fillSingleProject).strict();
|
|
34
34
|
const handler = async (props) => {
|
|
35
35
|
const controller = new AbortController();
|
|
36
36
|
const stop = (exitCode) => {
|
|
@@ -56,6 +56,7 @@ const subscribe = async (props, dependencies = {}) => {
|
|
|
56
56
|
const resolveBranchId = dependencies.resolveBranchId ?? branchIdFromProps;
|
|
57
57
|
const resolveEnv = dependencies.resolveEnv ?? resolveNeonEnvVars;
|
|
58
58
|
const connect = dependencies.connect ?? connectRealtime;
|
|
59
|
+
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
59
60
|
const branchId = await resolveBranchId(props);
|
|
60
61
|
const { vars } = await resolveEnv({
|
|
61
62
|
cwd: props.cwd ?? process.cwd(),
|
|
@@ -67,12 +68,20 @@ const subscribe = async (props, dependencies = {}) => {
|
|
|
67
68
|
services: ["postgres", "realtime"],
|
|
68
69
|
...props.runtimeApi ? { api: props.runtimeApi } : {}
|
|
69
70
|
});
|
|
71
|
+
const url = requiredEnv(vars, "NEON_REALTIME_URL");
|
|
72
|
+
const secret = requiredEnv(vars, "NEON_REALTIME_SECRET");
|
|
73
|
+
const database = await databaseName(requiredEnv(vars, "DATABASE_URL"));
|
|
70
74
|
const realtime = await connect({
|
|
71
|
-
url
|
|
72
|
-
secret
|
|
73
|
-
database
|
|
75
|
+
url,
|
|
76
|
+
secret,
|
|
77
|
+
database
|
|
74
78
|
});
|
|
75
|
-
const render = createRenderer(props
|
|
79
|
+
const render = createRenderer(props, {
|
|
80
|
+
project: props.projectId,
|
|
81
|
+
branch: branchDisplayName(props, branchId),
|
|
82
|
+
database,
|
|
83
|
+
query: props.query
|
|
84
|
+
}, now);
|
|
76
85
|
let subscription;
|
|
77
86
|
try {
|
|
78
87
|
if (props.changes) {
|
|
@@ -81,7 +90,8 @@ const subscribe = async (props, dependencies = {}) => {
|
|
|
81
90
|
} else {
|
|
82
91
|
const materialized = await realtime.subscribeMaterialized(props.query);
|
|
83
92
|
subscription = materialized;
|
|
84
|
-
await
|
|
93
|
+
if (props.output === "table") await consumeChanges(materialized, render, props.signal);
|
|
94
|
+
else await consumeSnapshots(materialized, render, props.signal);
|
|
85
95
|
}
|
|
86
96
|
} finally {
|
|
87
97
|
subscription?.unsubscribe();
|
|
@@ -123,10 +133,23 @@ const databaseName = async (connectionString) => {
|
|
|
123
133
|
if (database) return database;
|
|
124
134
|
throw new Error("DATABASE_URL must include a database name.");
|
|
125
135
|
};
|
|
126
|
-
const createRenderer = (props) => {
|
|
136
|
+
const createRenderer = (props, context, now) => {
|
|
127
137
|
const out = props.out ?? process.stdout;
|
|
128
|
-
let
|
|
138
|
+
let hasBaseline = false;
|
|
139
|
+
let lastStatus;
|
|
140
|
+
const rowsById = /* @__PURE__ */ new Map();
|
|
141
|
+
if (props.output === "table") renderHumanHeader(out, context, props.changes);
|
|
129
142
|
return (event) => {
|
|
143
|
+
if (event.type === "status") {
|
|
144
|
+
if (props.output === "table") {
|
|
145
|
+
const status = humanStatus(event.state);
|
|
146
|
+
if (status !== lastStatus) {
|
|
147
|
+
lastStatus = status;
|
|
148
|
+
out.write(`${timestamp(now)} ${status}\n`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
130
153
|
if (props.output === "json") {
|
|
131
154
|
out.write(`${JSON.stringify(event)}\n`);
|
|
132
155
|
return;
|
|
@@ -135,22 +158,25 @@ const createRenderer = (props) => {
|
|
|
135
158
|
out.write(`---\n${YAML.stringify(event)}`);
|
|
136
159
|
return;
|
|
137
160
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
out.write("\n");
|
|
162
|
+
if (event.type === "reset" && !props.changes) {
|
|
163
|
+
const label = hasBaseline ? "Reset" : "Initial snapshot";
|
|
164
|
+
hasBaseline = true;
|
|
165
|
+
rowsById.clear();
|
|
166
|
+
for (const { rowId, row } of event.rows) rowsById.set(rowId, row);
|
|
167
|
+
renderMaterializedRows(out, event.rows.map(({ row }) => row), `${timestamp(now)} ${label}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (event.type === "batch" && !props.changes) {
|
|
171
|
+
renderHumanBatch(out, event, rowsById, now);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
renderTableEvent(out, event, now);
|
|
141
175
|
};
|
|
142
176
|
};
|
|
143
|
-
const renderTableEvent = (out, event) => {
|
|
177
|
+
const renderTableEvent = (out, event, now) => {
|
|
144
178
|
if (event.type === "snapshot") {
|
|
145
|
-
|
|
146
|
-
writer({
|
|
147
|
-
output: "table",
|
|
148
|
-
out
|
|
149
|
-
}).end(event.rows, {
|
|
150
|
-
fields,
|
|
151
|
-
humanTitle: "Query result",
|
|
152
|
-
emptyMessage: "Query returned no rows."
|
|
153
|
-
});
|
|
179
|
+
renderMaterializedRows(out, event.rows, `${timestamp(now)} Snapshot`);
|
|
154
180
|
return;
|
|
155
181
|
}
|
|
156
182
|
if (event.type === "reset") {
|
|
@@ -158,14 +184,7 @@ const renderTableEvent = (out, event) => {
|
|
|
158
184
|
row_id: rowId,
|
|
159
185
|
row
|
|
160
186
|
}));
|
|
161
|
-
|
|
162
|
-
output: "table",
|
|
163
|
-
out
|
|
164
|
-
}).end(rows, {
|
|
165
|
-
fields: ["row_id", "row"],
|
|
166
|
-
humanTitle: "Reset",
|
|
167
|
-
emptyMessage: "Reset contains no rows."
|
|
168
|
-
});
|
|
187
|
+
renderRows(out, rows, ["row_id", "row"], `${timestamp(now)} Reset · ${rowCount(rows.length)}`, "Reset contains no rows.");
|
|
169
188
|
return;
|
|
170
189
|
}
|
|
171
190
|
const rows = event.changes.map((change) => ({
|
|
@@ -173,18 +192,110 @@ const renderTableEvent = (out, event) => {
|
|
|
173
192
|
row_id: change.rowId,
|
|
174
193
|
row: change.type === "upsert" ? change.row : void 0
|
|
175
194
|
}));
|
|
195
|
+
renderRows(out, rows, [
|
|
196
|
+
"type",
|
|
197
|
+
"row_id",
|
|
198
|
+
"row"
|
|
199
|
+
], `${timestamp(now)} ${transactionLabel(event.txids)} · ${rawChangeSummary(event.changes)}`, "Batch contains no row changes.");
|
|
200
|
+
};
|
|
201
|
+
const renderHumanHeader = (out, context, rawChanges) => {
|
|
202
|
+
const target = {
|
|
203
|
+
project: context.project,
|
|
204
|
+
branch: context.branch,
|
|
205
|
+
database: context.database,
|
|
206
|
+
mode: rawChanges ? "Raw changes" : "Materialized result",
|
|
207
|
+
query: context.query
|
|
208
|
+
};
|
|
176
209
|
writer({
|
|
177
210
|
output: "table",
|
|
178
211
|
out
|
|
179
|
-
}).end(
|
|
212
|
+
}).end(target, {
|
|
180
213
|
fields: [
|
|
181
|
-
"
|
|
182
|
-
"
|
|
183
|
-
"
|
|
214
|
+
"project",
|
|
215
|
+
"branch",
|
|
216
|
+
"database",
|
|
217
|
+
"mode",
|
|
218
|
+
"query"
|
|
184
219
|
],
|
|
185
|
-
humanTitle:
|
|
186
|
-
|
|
220
|
+
humanTitle: "Live query"
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
const renderMaterializedRows = (out, rows, title) => {
|
|
224
|
+
const humanTitle = `${title} · ${rowCount(rows.length)}`;
|
|
225
|
+
renderRows(out, rows, rowFields(rows), humanTitle, "Query returned no rows.");
|
|
226
|
+
};
|
|
227
|
+
const renderHumanBatch = (out, event, rowsById, now) => {
|
|
228
|
+
const changes = classifyChanges(event.changes, rowsById);
|
|
229
|
+
const inserted = changes.filter((change) => change.operation === "+ insert");
|
|
230
|
+
const updated = changes.filter((change) => change.operation === "~ update");
|
|
231
|
+
const deleted = changes.filter((change) => change.operation === "- delete");
|
|
232
|
+
const rowFieldsInBatch = rowFields(changes.flatMap((change) => change.row ? [change.row] : []));
|
|
233
|
+
const operationField = uniqueOperationField(rowFieldsInBatch);
|
|
234
|
+
const rows = changes.map((change) => ({
|
|
235
|
+
...change.row ?? {},
|
|
236
|
+
[operationField]: change.operation
|
|
237
|
+
}));
|
|
238
|
+
renderRows(out, rows, [operationField, ...rowFieldsInBatch], `${timestamp(now)} ${transactionLabel(event.txids)} · +${inserted.length} ~${updated.length} -${deleted.length}`, "Transaction contains no row changes.");
|
|
239
|
+
};
|
|
240
|
+
const renderRows = (out, rows, fields, title, emptyMessage) => {
|
|
241
|
+
writer({
|
|
242
|
+
output: "table",
|
|
243
|
+
out
|
|
244
|
+
}).end(rows, {
|
|
245
|
+
fields,
|
|
246
|
+
humanTitle: title
|
|
187
247
|
});
|
|
248
|
+
if (rows.length === 0) out.write(`${emptyMessage}\n`);
|
|
249
|
+
};
|
|
250
|
+
const classifyChanges = (changes, rowsById) => changes.map((change) => {
|
|
251
|
+
if (change.type === "upsert") {
|
|
252
|
+
const operation = rowsById.has(change.rowId) ? "~ update" : "+ insert";
|
|
253
|
+
rowsById.set(change.rowId, change.row);
|
|
254
|
+
return {
|
|
255
|
+
operation,
|
|
256
|
+
row: change.row
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const row = rowsById.get(change.rowId);
|
|
260
|
+
rowsById.delete(change.rowId);
|
|
261
|
+
return {
|
|
262
|
+
operation: "- delete",
|
|
263
|
+
...row ? { row } : {}
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
const uniqueOperationField = (fields) => {
|
|
267
|
+
for (const candidate of [
|
|
268
|
+
"change",
|
|
269
|
+
"operation",
|
|
270
|
+
"event"
|
|
271
|
+
]) if (!fields.includes(candidate)) return candidate;
|
|
272
|
+
let suffix = 1;
|
|
273
|
+
while (fields.includes(`change_${suffix}`)) suffix += 1;
|
|
274
|
+
return `change_${suffix}`;
|
|
275
|
+
};
|
|
276
|
+
const transactionLabel = (txids) => {
|
|
277
|
+
if (txids.length === 0) return "Update (no transaction ID)";
|
|
278
|
+
return `${txids.length === 1 ? "Transaction" : "Transactions"} ${txids.join(", ")}`;
|
|
279
|
+
};
|
|
280
|
+
const rawChangeSummary = (changes) => {
|
|
281
|
+
const upserts = changes.filter((change) => change.type === "upsert").length;
|
|
282
|
+
const removals = changes.length - upserts;
|
|
283
|
+
return `${upserts} upsert${upserts === 1 ? "" : "s"}, ${removals} removal${removals === 1 ? "" : "s"}`;
|
|
284
|
+
};
|
|
285
|
+
const rowCount = (count) => `${count} ${count === 1 ? "row" : "rows"}`;
|
|
286
|
+
const timestamp = (now) => `[${now().toISOString()}]`;
|
|
287
|
+
const humanStatus = (state) => {
|
|
288
|
+
switch (state.status) {
|
|
289
|
+
case "connecting": return "Connecting…";
|
|
290
|
+
case "live": return "Live — waiting for changes.";
|
|
291
|
+
case "stale": return "Stale — showing the last known result.";
|
|
292
|
+
case "closed": return "Subscription closed.";
|
|
293
|
+
case "error": return `Subscription failed — ${state.error.message}`;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const branchDisplayName = (props, branchId) => {
|
|
297
|
+
const branch = "branch" in props ? props.branch : props.id;
|
|
298
|
+
return branch === branchId ? branchId : `${branch} (${branchId})`;
|
|
188
299
|
};
|
|
189
300
|
const rowFields = (rows) => {
|
|
190
301
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -203,7 +314,7 @@ const consumeSnapshots = async (subscription, render, signal) => consumeSubscrip
|
|
|
203
314
|
type: "snapshot",
|
|
204
315
|
rows
|
|
205
316
|
});
|
|
206
|
-
}))], signal);
|
|
317
|
+
}))], render, signal);
|
|
207
318
|
const consumeChanges = async (subscription, render, signal) => consumeSubscription(subscription, (fail) => [subscription.onReset((rows) => attempt(fail, () => render({
|
|
208
319
|
type: "reset",
|
|
209
320
|
rows
|
|
@@ -211,8 +322,8 @@ const consumeChanges = async (subscription, render, signal) => consumeSubscripti
|
|
|
211
322
|
type: "batch",
|
|
212
323
|
txids: batch.txids,
|
|
213
324
|
changes
|
|
214
|
-
})))], signal);
|
|
215
|
-
const consumeSubscription = (subscription, listenForData, signal) => new Promise((resolve, reject) => {
|
|
325
|
+
})))], render, signal);
|
|
326
|
+
const consumeSubscription = (subscription, listenForData, render, signal) => new Promise((resolve, reject) => {
|
|
216
327
|
let settled = false;
|
|
217
328
|
let detachData = [];
|
|
218
329
|
let detachState = () => void 0;
|
|
@@ -227,6 +338,11 @@ const consumeSubscription = (subscription, listenForData, signal) => new Promise
|
|
|
227
338
|
};
|
|
228
339
|
const fail = (error) => finish(reject, error);
|
|
229
340
|
const onState = (state) => {
|
|
341
|
+
attempt(fail, () => render({
|
|
342
|
+
type: "status",
|
|
343
|
+
state
|
|
344
|
+
}));
|
|
345
|
+
if (settled) return;
|
|
230
346
|
if (state.status === "error") fail(state.error);
|
|
231
347
|
else if (state.status === "closed") finish(resolve);
|
|
232
348
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neon",
|
|
3
|
-
"version": "8.4.
|
|
3
|
+
"version": "8.4.3",
|
|
4
4
|
"description": "CLI tool for Neon, the cloud backend primitives built around Lakebase Postgres",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"neon",
|
|
@@ -66,10 +66,10 @@
|
|
|
66
66
|
"yaml": "^2.9.0",
|
|
67
67
|
"yargs": "17.7.2",
|
|
68
68
|
"yoctocolors": "^2.1.2",
|
|
69
|
-
"@neon/config": "1.9.1",
|
|
70
69
|
"@neon/config-runtime": "1.9.1",
|
|
71
|
-
"@neon/
|
|
72
|
-
"@neon/sdk": "7.2.0"
|
|
70
|
+
"@neon/config": "1.9.1",
|
|
71
|
+
"@neon/sdk": "7.2.0",
|
|
72
|
+
"@neon/realtime": "0.0.1"
|
|
73
73
|
},
|
|
74
74
|
"optionalDependencies": {
|
|
75
75
|
"@napi-rs/keyring": "1.3.0",
|