@fourier-labs/harbour 0.1.18 → 0.1.19

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.
@@ -1,6 +1,7 @@
1
1
  import { readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { loadDatabaseGate } from "./database-gate.js";
4
+ import { CoverageLedger, createCoverageProxy, declaredCapabilities, missingFlowOperations, sourceTableVerbs } from "./flow-coverage.js";
4
5
  import { CliError } from "./output.js";
5
6
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
6
7
  import { LOCAL, LocalRuntime, runCommand, runningOrigin } from "./local-runtime.js";
@@ -49,15 +50,35 @@ export async function runChecks(root, options) {
49
50
  }
50
51
  const origin = await (options.localOrigin ?? (() => runningOrigin(root)))();
51
52
  const journeys = (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
52
- if (!journeys.length)
53
+ if (!journeys.length) {
53
54
  record("journeys", "not_run", "no checks under .harbour/checks/");
54
- else if (!origin)
55
+ record("flow", "not_run", "no checks under .harbour/checks/ to exercise the app's operations");
56
+ }
57
+ else if (!origin) {
55
58
  record("journeys", "not_run", "harbour dev is not running; start it to exercise the journey checks");
56
- else
57
- for (const name of journeys) {
58
- const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { HARBOUR_APP_URL: origin, HARBOUR_SDK_MODULE: join(root, "node_modules", "@harbour", "app-sdk", "dist", "index.js") } });
59
- record(`journey:${name}`, result.code === 0 ? "pass" : "fail", result.code === 0 ? undefined : lastLines(result.stderr || result.stdout));
59
+ record("flow", "not_run", "harbour dev is not running; the pipeline's operation-coverage gate needs the checks to run");
60
+ }
61
+ else {
62
+ // The journeys run through a recording proxy so this reports what the
63
+ // pipeline reports: which converted operations and declared capabilities
64
+ // the checks actually exercised at the gateway.
65
+ const ledger = new CoverageLedger();
66
+ const proxy = createCoverageProxy(origin, ledger);
67
+ const appUrl = await proxy.listen();
68
+ try {
69
+ for (const name of journeys) {
70
+ const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { HARBOUR_APP_URL: appUrl, HARBOUR_SDK_MODULE: join(root, "node_modules", "@harbour", "app-sdk", "dist", "index.js") } });
71
+ record(`journey:${name}`, result.code === 0 ? "pass" : "fail", result.code === 0 ? undefined : lastLines(result.stderr || result.stdout));
72
+ }
73
+ }
74
+ finally {
75
+ await proxy.close();
60
76
  }
77
+ const missing = missingFlowOperations(await sourceTableVerbs(root), await declaredCapabilities(root), ledger);
78
+ record("flow", missing.length ? "fail" : "pass", missing.length
79
+ ? `${missing.join("; ")}. Exercise the converted application operations with source-valid inputs and assertions; an unrelated passing check is insufficient. The pipeline refuses this deployment as kit.check-failed: flow.check-failed.`
80
+ : "every operation the app performs and every capability it declares was exercised by a retained check");
81
+ }
61
82
  let integrations = "not tested";
62
83
  if (options.governance && lock?.appId && !declaration.errors.length) {
63
84
  integrations = await testIntegrationReads(lock.appId, options.governance, declaration.declaration, output);
@@ -3,6 +3,7 @@ import { createForwarder } from "./forwarder.js";
3
3
  import { readKitLock } from "./kit.js";
4
4
  import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, releaseDevLock, runCommand } from "./local-runtime.js";
5
5
  import { CliError } from "./output.js";
6
+ import { ensureRealtimeStream } from "./realtime-stream.js";
6
7
  /**
7
8
  * Starts the project's Compose services, applies migrations, starts Vite and the
8
9
  * loopback origin. Resolves when the runtime is up; the returned `stop` runs
@@ -39,8 +40,15 @@ export async function startDev(root, options) {
39
40
  const applied = await runtime.migrate();
40
41
  options.output(`Applied ${applied.length} migration file(s).`);
41
42
  const watched = await runtime.installRealtimeOutbox();
42
- if (watched > 0)
43
+ if (watched > 0) {
44
+ // The outbox relay publishes with JetStream, which refuses a subject no
45
+ // stream captures; without the stream harbour.realtime delivers nothing.
46
+ // The relay has already given up by now, so it is restarted against the
47
+ // stream this just created.
48
+ await ensureRealtimeStream(ports.nats);
49
+ await runtime.restartService("outbox");
43
50
  options.output(`Installed the Harbour realtime outbox for ${watched} table(s) (harbour.realtime change events).`);
51
+ }
44
52
  const origin = `http://127.0.0.1:${ports.origin}`;
45
53
  vite = spawn("npm", ["run", "dev"], { cwd: root, env: { ...env, HARBOUR_LOCAL_ORIGIN: origin, HARBOUR_VITE_PORT: String(ports.vite) }, stdio: ["ignore", "inherit", "inherit"] });
46
54
  vite.on("error", () => options.output("Vite could not be started; is npm installed and `npm install` done?"));
@@ -0,0 +1,322 @@
1
+ import { createServer, request as httpRequest } from "node:http";
2
+ import { connect } from "node:net";
3
+ import { readdir, readFile } from "node:fs/promises";
4
+ import { extname, join } from "node:path";
5
+ /**
6
+ * The pipeline's operation-coverage gate, run locally.
7
+ *
8
+ * The deployment pipeline replays `.harbour/checks/` against a real App Gateway
9
+ * and then refuses the deployment (`flow.check-failed`) unless every database
10
+ * operation the browser code performs, and every capability the kit lane
11
+ * derives from that code, was actually exercised while those checks ran. Before
12
+ * this module `harbour check` ran the same journey scripts but asserted nothing
13
+ * about what they exercised, so an app whose checks never touched a converted
14
+ * operation passed locally and failed in the pipeline — the parity gap this
15
+ * closes.
16
+ *
17
+ * The inventory and the wording below deliberately mirror the data plane's
18
+ * packages/toolkit/transformbuild (write_probe.go `inventorySDKTableVerbs`,
19
+ * local_operation_coverage.go `requireOperationCoverage`) so a local failure
20
+ * reads exactly like the pipeline's.
21
+ */
22
+ /** Capabilities the coverage gate demands evidence for, in the pipeline's order. */
23
+ export const COVERED_CAPABILITIES = ["data", "files", "actions", "telemetry", "realtime"];
24
+ /** Every capability the kit lane derives from the browser SDK surface. */
25
+ const SDK_CAPABILITIES = ["data", "files", "actions", "realtime", "telemetry", "integrations"];
26
+ const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
27
+ const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".harbour"]);
28
+ const TABLE_CHAIN = /\.from\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g;
29
+ const VERB_CALL = /\.(select|insert|update|delete|upsert)\s*\(/g;
30
+ /** `createClient()` bindings name the identifier a capability call must be made on. */
31
+ const CLIENT_BINDING = /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?createClient\s*\(/g;
32
+ /** Records the gateway operations observed while the retained checks ran. */
33
+ export class CoverageLedger {
34
+ seen = new Set();
35
+ add(key) { this.seen.add(key); }
36
+ has(key) { return this.seen.has(key); }
37
+ keys() { return [...this.seen].sort(); }
38
+ /**
39
+ * The gateway's data boundary. A write that changed no row proves nothing, so
40
+ * only a select — or a mutation that returned rows — counts (observeData in
41
+ * local_operation_coverage.go).
42
+ */
43
+ observeData(table, operation, rows) {
44
+ if (operation !== "select" && rows === 0)
45
+ return;
46
+ this.add("data");
47
+ this.add(`data:${table}:${operation}`);
48
+ }
49
+ }
50
+ async function sourceFiles(root) {
51
+ const found = [];
52
+ const walk = async (directory) => {
53
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
54
+ for (const entry of entries) {
55
+ const path = join(directory, entry.name);
56
+ if (entry.isDirectory()) {
57
+ if (!SKIPPED_DIRECTORIES.has(entry.name))
58
+ await walk(path);
59
+ continue;
60
+ }
61
+ if (SOURCE_EXTENSIONS.has(extname(entry.name)))
62
+ found.push(path);
63
+ }
64
+ };
65
+ await walk(root);
66
+ return found.sort();
67
+ }
68
+ /**
69
+ * Every `<table> <verb>` the browser code performs. The verb chain is what
70
+ * follows THIS `.from(...)` up to the end of its statement or the next
71
+ * `.from(` — never a later chain's verbs.
72
+ */
73
+ export async function sourceTableVerbs(root) {
74
+ const inventory = new Map();
75
+ for (const path of await sourceFiles(root)) {
76
+ const text = await readFile(path, "utf8");
77
+ for (const match of text.matchAll(TABLE_CHAIN)) {
78
+ const start = match.index ?? 0;
79
+ if (text.slice(0, start).trimEnd().endsWith(".storage"))
80
+ continue;
81
+ let tail = text.slice(start + match[0].length);
82
+ const semicolon = tail.indexOf(";");
83
+ if (/[;\n]/.test(tail) && semicolon >= 0)
84
+ tail = tail.slice(0, semicolon);
85
+ const next = tail.indexOf(".from(");
86
+ if (next > 0)
87
+ tail = tail.slice(0, next);
88
+ tail = tail.slice(0, 200);
89
+ const table = match[1];
90
+ const verbs = inventory.get(table) ?? new Set();
91
+ for (const verb of tail.matchAll(VERB_CALL))
92
+ verbs.add(verb[1]);
93
+ inventory.set(table, verbs);
94
+ }
95
+ }
96
+ return inventory;
97
+ }
98
+ /**
99
+ * The capabilities the kit lane declares for this app: a namespace called as
100
+ * `<client>.<namespace>.<method>(` on an identifier bound to `createClient()`.
101
+ * TypeScript type arguments may sit between the method and its call.
102
+ */
103
+ export async function declaredCapabilities(root) {
104
+ const files = await sourceFiles(root);
105
+ const texts = await Promise.all(files.map(path => readFile(path, "utf8").catch(() => "")));
106
+ const clients = new Set();
107
+ for (const text of texts)
108
+ for (const match of text.matchAll(CLIENT_BINDING))
109
+ clients.add(match[1]);
110
+ const used = new Set();
111
+ if (!clients.size)
112
+ return used;
113
+ const namespaces = SDK_CAPABILITIES.join("|");
114
+ for (const client of clients) {
115
+ const call = new RegExp(`\\b${client}\\s*\\.\\s*(${namespaces})\\s*\\.\\s*[A-Za-z_$][A-Za-z0-9_$]*\\s*(?:<[^<>()]*>\\s*)?\\(`, "g");
116
+ for (const text of texts)
117
+ for (const match of text.matchAll(call))
118
+ used.add(match[1]);
119
+ }
120
+ return used;
121
+ }
122
+ /**
123
+ * What the pipeline would refuse, in its own words: an inventoried operation or
124
+ * a declared capability with no successful observation while the checks ran.
125
+ */
126
+ export function missingFlowOperations(inventory, capabilities, ledger) {
127
+ const missing = [];
128
+ for (const [table, verbs] of inventory) {
129
+ for (const verb of verbs) {
130
+ if (!ledger.has(`data:${table}:${verb}`))
131
+ missing.push(`${table} ${verb.toUpperCase()}: no successful converted application operation observed`);
132
+ }
133
+ }
134
+ for (const capability of COVERED_CAPABILITIES) {
135
+ if (capabilities.has(capability) && !ledger.has(capability))
136
+ missing.push(`${capability}: retained checks did not exercise this declared component`);
137
+ }
138
+ return missing.sort();
139
+ }
140
+ const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
141
+ /**
142
+ * A loopback reverse proxy in front of the running `harbour dev` origin that
143
+ * records what the retained checks exercise. The pipeline observes the same
144
+ * boundary from inside its own gateway; observing it here needs no second
145
+ * gateway and no change to the checks, which keep using HARBOUR_APP_URL.
146
+ */
147
+ export function createCoverageProxy(origin, ledger) {
148
+ const target = new URL(origin);
149
+ const port = Number(target.port || 80);
150
+ const host = target.hostname;
151
+ const upstreamOrigin = target.origin;
152
+ const server = createServer((incoming, response) => {
153
+ const path = (incoming.url ?? "/").split("?")[0];
154
+ const chunks = [];
155
+ incoming.on("data", chunk => { if (chunks.length < 64)
156
+ chunks.push(chunk); });
157
+ const upstream = httpRequest({ host, port, method: incoming.method, path: incoming.url, headers: forwardable(incoming.headers, upstreamOrigin) }, upstreamResponse => {
158
+ const body = [];
159
+ upstreamResponse.on("data", chunk => { if (body.length < 64)
160
+ body.push(chunk); });
161
+ upstreamResponse.on("end", () => {
162
+ const status = upstreamResponse.statusCode ?? 0;
163
+ if (status >= 200 && status < 300)
164
+ record(ledger, path, incoming.method ?? "GET", Buffer.concat(chunks).toString("utf8"), Buffer.concat(body).toString("utf8"));
165
+ });
166
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
167
+ upstreamResponse.pipe(response);
168
+ });
169
+ upstream.on("error", () => { if (!response.headersSent)
170
+ response.writeHead(502); response.end(); });
171
+ incoming.pipe(upstream);
172
+ });
173
+ // Realtime is a WebSocket: the proxy tunnels it untouched and reads the
174
+ // server's frames as they pass. Only a delivered event counts — the gateway
175
+ // fires OnRealtimeDelivery for postgres_changes, broadcast and presence, and
176
+ // never for the `subscribed` acknowledgement, so a check that subscribes and
177
+ // receives nothing does not clear the capability here either.
178
+ server.on("upgrade", (incoming, socket, head) => {
179
+ const path = (incoming.url ?? "/").split("?")[0];
180
+ const upstream = connect(port, host, () => {
181
+ const headers = Object.entries({ ...forwardable(incoming.headers, upstreamOrigin), host: `${host}:${port}`, connection: "Upgrade", upgrade: "websocket" })
182
+ .map(([key, value]) => `${key}: ${value}`);
183
+ upstream.write(`${incoming.method} ${incoming.url} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
184
+ if (head?.length)
185
+ upstream.write(head);
186
+ if (path === "/_harbour/realtime") {
187
+ const frames = websocketTextFrames();
188
+ upstream.on("data", (chunk) => { for (const message of frames(chunk))
189
+ if (isRealtimeDelivery(message))
190
+ ledger.add("realtime"); });
191
+ }
192
+ upstream.pipe(socket);
193
+ socket.pipe(upstream);
194
+ });
195
+ upstream.on("error", () => socket.destroy());
196
+ socket.on("error", () => upstream.destroy());
197
+ });
198
+ return {
199
+ listen: () => new Promise((resolve, reject) => {
200
+ server.on("error", reject);
201
+ server.listen(0, "127.0.0.1", () => resolve(`http://127.0.0.1:${server.address().port}`));
202
+ }),
203
+ close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()); })
204
+ };
205
+ }
206
+ /**
207
+ * `harbour dev` serves one origin and refuses anything that does not carry it,
208
+ * so the proxy presents the upstream's own Host and Origin. Forwarding its own
209
+ * address instead is what made the realtime upgrade fail before this: the
210
+ * forwarder dropped the socket and the channel never subscribed.
211
+ */
212
+ function forwardable(headers, origin) {
213
+ const result = {};
214
+ for (const [key, value] of Object.entries(headers)) {
215
+ if (HOP_HEADERS.has(key.toLowerCase()) || value === undefined)
216
+ continue;
217
+ result[key] = key.toLowerCase() === "origin" ? origin : Array.isArray(value) ? value.join(", ") : value;
218
+ }
219
+ return result;
220
+ }
221
+ /** One successful gateway request, classified exactly as the pipeline's coverage handler does. */
222
+ export function record(ledger, path, method, requestBody, responseBody) {
223
+ const data = /^\/_harbour\/data\/([A-Za-z0-9_]+)\/query$/.exec(path);
224
+ if (data) {
225
+ const operation = String(readJson(requestBody)?.operation ?? "");
226
+ if (!operation)
227
+ return;
228
+ const envelope = readJson(responseBody)?.data;
229
+ const rows = Array.isArray(envelope?.data) ? envelope.data.length : envelope?.count ?? 0;
230
+ if (operation === "rpc") {
231
+ ledger.add("data");
232
+ ledger.add(`rpc:${data[1]}`);
233
+ return;
234
+ }
235
+ ledger.observeData(data[1], operation, rows);
236
+ return;
237
+ }
238
+ if (path.startsWith("/_harbour/actions/")) {
239
+ ledger.add("actions");
240
+ ledger.add(`action:${path.slice("/_harbour/actions/".length)}`);
241
+ return;
242
+ }
243
+ if (path === "/_harbour/files/uploads/complete") {
244
+ ledger.add("files");
245
+ ledger.add("files:upload");
246
+ return;
247
+ }
248
+ if (path === "/_harbour/files" && method === "GET") {
249
+ ledger.add("files");
250
+ ledger.add("files:list");
251
+ return;
252
+ }
253
+ if (path === "/_harbour/files/remove") {
254
+ ledger.add("files");
255
+ ledger.add("files:remove");
256
+ return;
257
+ }
258
+ if (path === "/_harbour/telemetry")
259
+ ledger.add("telemetry");
260
+ }
261
+ /** The event types the gateway counts as a delivery (appgateway/realtime.go). */
262
+ export function isRealtimeDelivery(message) {
263
+ const type = readJson(message)?.type;
264
+ return type === "postgres_changes" || type === "broadcast" || type === "presence";
265
+ }
266
+ /**
267
+ * Reads complete text frames out of a server-to-client WebSocket byte stream.
268
+ * Server frames are never masked, so the payload is the bytes themselves; the
269
+ * head of the stream is the HTTP 101 response, which carries no frame.
270
+ */
271
+ export function websocketTextFrames() {
272
+ let buffer = Buffer.alloc(0);
273
+ let started = false;
274
+ return chunk => {
275
+ buffer = Buffer.concat([buffer, chunk]);
276
+ if (!started) {
277
+ const end = buffer.indexOf("\r\n\r\n");
278
+ if (end < 0)
279
+ return [];
280
+ buffer = buffer.subarray(end + 4);
281
+ started = true;
282
+ }
283
+ const messages = [];
284
+ for (;;) {
285
+ if (buffer.length < 2)
286
+ break;
287
+ const opcode = buffer[0] & 0x0f;
288
+ const masked = (buffer[1] & 0x80) !== 0;
289
+ let length = buffer[1] & 0x7f;
290
+ let offset = 2;
291
+ if (length === 126) {
292
+ if (buffer.length < 4)
293
+ break;
294
+ length = buffer.readUInt16BE(2);
295
+ offset = 4;
296
+ }
297
+ else if (length === 127) {
298
+ if (buffer.length < 10)
299
+ break;
300
+ length = Number(buffer.readBigUInt64BE(2));
301
+ offset = 10;
302
+ }
303
+ if (masked)
304
+ offset += 4;
305
+ if (buffer.length < offset + length)
306
+ break;
307
+ if (opcode === 1 && !masked)
308
+ messages.push(buffer.subarray(offset, offset + length).toString("utf8"));
309
+ buffer = buffer.subarray(offset + length);
310
+ }
311
+ return messages;
312
+ };
313
+ }
314
+ function readJson(body) {
315
+ try {
316
+ const value = JSON.parse(body);
317
+ return value && typeof value === "object" ? value : undefined;
318
+ }
319
+ catch {
320
+ return undefined;
321
+ }
322
+ }
@@ -250,6 +250,8 @@ export class LocalRuntime {
250
250
  if (result.code !== 0)
251
251
  throw new CliError("LOCAL_RUNTIME_FAILED", "Docker could not start the local Harbour services. Is Docker running and are the kit images available (see .harbour/kit.lock.json)?");
252
252
  }
253
+ /** Restarts one service; the outbox relay needs it once its JetStream stream exists. */
254
+ async restartService(service) { await this.compose(["restart", service], { quiet: true }); }
253
255
  /** Stops containers and keeps volumes; safe to repeat. */
254
256
  async stop() {
255
257
  const result = await this.compose(["stop"], { quiet: true });
@@ -0,0 +1,79 @@
1
+ import { connect } from "node:net";
2
+ import { randomUUID } from "node:crypto";
3
+ import { LOCAL } from "./local-runtime.js";
4
+ /**
5
+ * The JetStream stream the local realtime path needs.
6
+ *
7
+ * The App Gateway's outbox relay publishes committed row changes with a
8
+ * JetStream publish (appgateway/outbox.go); a JetStream publish to a subject no
9
+ * stream captures is refused with "no response from stream", so without this
10
+ * the relay retries forever and `harbour.realtime` never delivers anything
11
+ * locally — while the deployment pipeline, which creates the same stream for
12
+ * its probe (toolkit/transformbuild/local_component_runtime.go), delivers
13
+ * normally. A realtime app therefore could not be exercised locally at all.
14
+ *
15
+ * Creating it needs three NATS protocol lines, so the CLI speaks them itself
16
+ * rather than taking a client dependency.
17
+ */
18
+ export const REALTIME_STREAM = "HARBOUR_LOCAL";
19
+ export const REALTIME_SUBJECTS = `harbour.app.${LOCAL.tenant}.${LOCAL.app}.>`;
20
+ /** JetStream's "stream name already in use": the stream from an earlier `harbour dev`. */
21
+ const ALREADY_EXISTS = 10058;
22
+ export async function ensureRealtimeStream(port, host = "127.0.0.1", timeoutMs = 10_000) {
23
+ const inbox = `_INBOX.${randomUUID()}`;
24
+ const request = JSON.stringify({ name: REALTIME_STREAM, subjects: [REALTIME_SUBJECTS], storage: "memory", retention: "limits", num_replicas: 1, discard: "old" });
25
+ const reply = await natsRequest(host, port, `$JS.API.STREAM.CREATE.${REALTIME_STREAM}`, inbox, request, timeoutMs);
26
+ const answer = JSON.parse(reply);
27
+ if (!answer.error)
28
+ return "created";
29
+ if (answer.error.err_code === ALREADY_EXISTS)
30
+ return "present";
31
+ throw new Error(`the local realtime stream could not be created: ${answer.error.description ?? reply}`);
32
+ }
33
+ /** CONNECT, SUB the inbox, PUB the request, and return the first reply. */
34
+ function natsRequest(host, port, subject, inbox, payload, timeoutMs) {
35
+ return new Promise((resolve, reject) => {
36
+ const socket = connect(port, host);
37
+ const finish = (error, value) => { clearTimeout(timer); socket.destroy(); error ? reject(error) : resolve(value); };
38
+ const timer = setTimeout(() => finish(new Error("the local NATS server did not answer the JetStream request")), timeoutMs);
39
+ timer.unref?.();
40
+ let buffer = "";
41
+ let greeted = false;
42
+ socket.on("error", error => finish(error));
43
+ socket.setEncoding("utf8");
44
+ socket.on("data", chunk => {
45
+ buffer += chunk;
46
+ if (!greeted && buffer.includes("\r\n")) {
47
+ greeted = true;
48
+ socket.write(`CONNECT {"verbose":false,"pedantic":false,"tls_required":false,"name":"harbour-cli","lang":"node","version":"1"}\r\n`);
49
+ socket.write(`SUB ${inbox} 1\r\n`);
50
+ socket.write(`PUB ${subject} ${inbox} ${Buffer.byteLength(payload)}\r\n${payload}\r\n`);
51
+ buffer = buffer.slice(buffer.indexOf("\r\n") + 2);
52
+ }
53
+ if (buffer.startsWith("PING\r\n")) {
54
+ socket.write("PONG\r\n");
55
+ buffer = buffer.slice(6);
56
+ }
57
+ const message = readMessage(buffer);
58
+ if (message)
59
+ finish(undefined, message);
60
+ });
61
+ });
62
+ }
63
+ /** One `MSG <subject> <sid> <bytes>\r\n<payload>\r\n`, once its payload has arrived. */
64
+ export function readMessage(buffer) {
65
+ const start = buffer.indexOf("MSG ");
66
+ if (start < 0)
67
+ return undefined;
68
+ const headerEnd = buffer.indexOf("\r\n", start);
69
+ if (headerEnd < 0)
70
+ return undefined;
71
+ const parts = buffer.slice(start + 4, headerEnd).trim().split(/\s+/);
72
+ const bytes = Number(parts.at(-1));
73
+ if (!Number.isFinite(bytes))
74
+ return undefined;
75
+ const payloadEnd = headerEnd + 2 + bytes;
76
+ if (buffer.length < payloadEnd)
77
+ return undefined;
78
+ return buffer.slice(headerEnd + 2, payloadEnd);
79
+ }
@@ -360,7 +360,7 @@ CREATE POLICY notes_owner ON notes
360
360
  GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO harbour_app_gateway;
361
361
  GRANT USAGE, SELECT ON SEQUENCE notes_id_seq TO harbour_app_gateway;
362
362
  `;
363
- const JOURNEY_CHECK = `// Journey check: the signed-in identity can create, read and delete a note through
363
+ const JOURNEY_CHECK = `// Journey check: the signed-in identity can create, read, tick and delete a note through
364
364
  // the running app (HARBOUR_APP_URL). Runs under \`harbour check\` while \`harbour dev\`
365
365
  // is up, and in the deployment pipeline's retained-check container.
366
366
  import assert from "node:assert/strict";
@@ -378,6 +378,12 @@ const title = \`journey \${Date.now()}\`;
378
378
  await harbour.data.from("notes").insert({ title });
379
379
  const { data: notes } = await harbour.data.from("notes").select("*").eq("title", title);
380
380
  assert.equal(notes.length, 1, "the inserted note is readable by its owner");
381
+ // The tick box in src/App.tsx is an UPDATE. The deployment pipeline refuses an
382
+ // app whose checks never exercise an operation its own code performs, so the
383
+ // journey toggles the note and reads the new value back.
384
+ await harbour.data.from("notes").update({ done: true }).eq("id", notes[0].id);
385
+ const { data: toggled } = await harbour.data.from("notes").select("*").eq("id", notes[0].id);
386
+ assert.equal(toggled[0].done, true, "the note is marked done for its owner");
381
387
  await harbour.data.from("notes").delete().eq("id", notes[0].id);
382
388
  const { data: remaining } = await harbour.data.from("notes").select("id").eq("title", title);
383
389
  assert.equal(remaining.length, 0, "the note is deleted");
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.18";
1
+ export const CLI_VERSION = "0.1.19";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": { "harbour": "./dist/packages/harbour-cli/src/cli.js" },