neon 8.4.1 → 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 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 instead of full query results",
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 full result whenever it changes").example("$0 subscribe --changes \"SELECT * FROM items\"", "Print raw reset and delta events").epilogue("The subscription runs until interrupted. JSON output is newline-delimited; YAML output uses one document per event.").middleware(fillSingleProject).strict();
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: requiredEnv(vars, "NEON_REALTIME_URL"),
72
- secret: requiredEnv(vars, "NEON_REALTIME_SECRET"),
73
- database: await databaseName(requiredEnv(vars, "DATABASE_URL"))
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 consumeSnapshots(materialized, render, props.signal);
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 renderedTableFrame = false;
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
- if (renderedTableFrame) out.write("\n");
139
- renderedTableFrame = true;
140
- renderTableEvent(out, event);
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
- const fields = rowFields(event.rows);
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
- writer({
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(rows, {
212
+ }).end(target, {
180
213
  fields: [
181
- "type",
182
- "row_id",
183
- "row"
214
+ "project",
215
+ "branch",
216
+ "database",
217
+ "mode",
218
+ "query"
184
219
  ],
185
- humanTitle: `Batch (txids: ${event.txids.join(", ") || "none"})`,
186
- emptyMessage: "Batch contains no row changes."
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.1",
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",
@@ -62,13 +62,14 @@
62
62
  "semver": "7.7.2",
63
63
  "undici": "^7.28.0",
64
64
  "which": "3.0.1",
65
+ "ws": "^8.21.2",
65
66
  "yaml": "^2.9.0",
66
67
  "yargs": "17.7.2",
67
68
  "yoctocolors": "^2.1.2",
68
- "@neon/config": "1.9.1",
69
69
  "@neon/config-runtime": "1.9.1",
70
- "@neon/realtime": "0.0.1",
71
- "@neon/sdk": "7.2.0"
70
+ "@neon/config": "1.9.1",
71
+ "@neon/sdk": "7.2.0",
72
+ "@neon/realtime": "0.0.1"
72
73
  },
73
74
  "optionalDependencies": {
74
75
  "@napi-rs/keyring": "1.3.0",