@north-light/sdk 0.3.543 → 0.3.545

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
@@ -176,6 +176,10 @@ CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);
176
176
 
177
177
  `save` upserts all fields and sets `updated_at` to the database clock. `listStale(before)` selects connections with `updated_at < before`, oldest first; it is a **last save** timestamp, not necessarily last successful refresh if an app also calls `save` for another reason. For idle-token maintenance, schedule `oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000})` (for example, daily). It selects stale connections, refreshes under the store lock, reloads there to avoid rotating an already-used token, saves a rotated connection, and reports `{refreshed, refused, failures}`. A connection the directory refuses goes to the client's `onRefusal(refusal)` and is then removed (`remove` deletes the row only while it still holds the refused token); `refusal.kind` is `'removed'` (`grant_removed`: the runtime deleted the app's runs; delete what you store for the person), `'paused'` (`grant_suspended`: nothing was deleted; keep the data until they sign in again), or `'reconnect'` (`refresh_token_expired`, `refresh_token_reused`, `grant_revoked`: nothing was deleted; ask them to sign in again). `refusalOf(error)` returns the same `Refusal` from a refused call's error. Load the signing key `crouter-sdk keygen` wrote with `privateKey: await privateKeyFromFile('local/app-key.json')` (Node), or check one from an environment variable with `parsePrivateJwk(text)`; both refuse anything but a private JWK with a `kid`. The SDK does not start a timer. `listStale` returns connections, not timestamps, so a worker needing a strict cutoff recheck must maintain that policy separately. The table contains credentials: restrict database access, encrypt backups, and delete a row when its user disconnects. PostgreSQL `timestamptz` must be returned as a JavaScript `Date` (the `pg` default parser).
178
178
 
179
+ ## PostgreSQL object kit (Node)
180
+
181
+ `PostgresObjectStore` from `@north-light/sdk/stores/postgres` keeps per-person custom objects, the nodes watching them, and a durable publish outbox. `objectKitSchemaSql(tables?)` returns the DDL for the three tables (`watch_object`, `watch_watcher`, `watch_publish` by default) for your migration. `new PostgresObjectStore(pool, {providerId, runtime: (sub) => oauth.appClient(sub), tables?, log?})` has `instance(sub, kind, target, {name, description})`, `watch(objectId, node, machine?)`, `enqueue(client, objectId, {event, summary, state?})` (pass your open transaction's client), `tick(batch?)` and `startPublisher({tick: 5000, batch: 100})`. Delivery is at least once under an advisory lock, backs off to 15 minutes, and drops a row after 24 hours or a permanent refusal (HTTP 400, 404 or 422). Full guide: [Watchable objects](https://sdk.docs.northlight.ai/docs/guides/watchable-objects).
182
+
179
183
  ## Example application
180
184
 
181
185
  See the complete [localhost SDK demo](https://github.com/Northlight-Systems/crouter/tree/main/examples/localhost-demo) for streaming, structured output, and an HTTP plugin in one local page.
@@ -2,16 +2,32 @@
2
2
  name: northlight
3
3
  description: "Use when building, debugging or shipping an app that signs people in with Northlight, starts runs on their Northlight runtime, or calls Northlight providers (sms, search, integrations, context, device) with @north-light/sdk."
4
4
  metadata:
5
- sdk-version: "0.3.543"
5
+ sdk-version: "0.3.545"
6
6
  ---
7
7
 
8
8
  # Building a Northlight app
9
9
 
10
- An app on Northlight signs people in with their Northlight account, then starts agent runs and calls providers (sms, search, integrations, context, device) on that person's own Northlight-hosted runtime, using their AI subscription. You write and host the app server with `@north-light/sdk`. Northlight hosts the directory (accounts, app registration, consent, the marketplace) and each person's runtime. It does not host your app's code. See [Where your app runs](https://sdk.docs.northlight.ai/docs/how-it-fits.md).
10
+ An app on Northlight signs people in with their Northlight account, then starts agent runs and calls providers (sms, search, integrations, context, device) on that person's own Northlight-hosted runtime, using their AI subscription. You write and host the app server with [`@north-light/sdk`](https://www.npmjs.com/package/@north-light/sdk). Northlight hosts the directory (accounts, app registration, consent, the marketplace) and each person's runtime. It does not host your app's code. See [Where your app runs](https://sdk.docs.northlight.ai/docs/how-it-fits.md).
11
+
12
+ ## If the person gave you an idea: build it as an app people text
13
+
14
+ The default Northlight app is one people text. It gets its own iMessage number through Comms; each text reaches your server as a webhook, and your server answers with an agent run on the texter's own runtime. If the person gave you an idea, build it this way. If they gave you none, ask them what the app should do before you start.
15
+
16
+ [comms.northlight.ai/llms.txt](https://comms.northlight.ai/llms.txt) is the whole Comms guide: read it and follow it rather than this summary. [Reach people by text](https://sdk.docs.northlight.ai/docs/reach-people-by-text.md) shows the webhook handler.
17
+
18
+ 1. **Build in fake mode.** `npx @north-light/sdk@latest init <dir>`, then `npm i @north-light/comms`. Run `npx @north-light/comms fake --webhook http://localhost:3000/hook`, and play a person with `npx @north-light/comms fake send --from +15550001 --text "hi"`. No account is needed yet.
19
+ 2. **Sign up, log in, register.** Steps 1 to 4 of the journey below. Comms signs in with the same app key.
20
+ 3. **Connect the app to Comms.** Ask the person to sign in at `https://comms.northlight.ai` and choose **Connect an app**; an admin of the app approves it there. This is done in a browser only.
21
+ 4. **Turn Texting on**, on the app's page there or with `crtr comms turn-on`: display name, square avatar, HELP text, test webhook URL. Copy the test signing secret it shows into the server's environment.
22
+ 5. **Answer with an agent run.** On `message.received`, get the person's runtime with `oauth.client(contact.user.id)` and pass it to `continueInRuntime` from `@north-light/comms`. When that throws `not_connected`, call `sendSignInLink(comms, contact, {environment: event.environment})` so the person signs in to your app.
23
+ 6. **Text it from the person's phone, in test mode.** Test mode is free. Add them as a tester (`crtr comms testers add --app-id <id> --phone <number> --label <name>`, or on the site) and ask them to text `JOIN <code>` to the test number; the Comms guide has the tester limit.
24
+ 7. **Production is paid.** When they want the app's own number and anyone to text it, they add a card on the billing account at `https://comms.northlight.ai`. Do not promise a free production number.
25
+
26
+ Never text a person first: Comms refuses a send to anyone who has not texted your number (`403 not_texted_first`). The rest of this skill is the Northlight side: sign-in, runs and providers.
11
27
 
12
28
  ## First: check this skill matches the SDK
13
29
 
14
- This skill is for SDK version `0.3.543`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. That command needs no account and no crtr. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
30
+ This skill is for SDK version `0.3.545`. If the project has `node_modules/@north-light/sdk/package.json`, compare its `version` with that number. If they differ, run `npx @north-light/sdk skill` in the project, then read the rewritten `.agents/skills/northlight/SKILL.md` (or `.claude/skills/northlight/SKILL.md`) instead of this copy. It needs no account. A project that has no SDK yet starts with `npx @north-light/sdk@latest init <dir>`.
15
31
 
16
32
  ## Where things run
17
33
 
@@ -164,7 +180,7 @@ Details behind each rule: [Errors](https://sdk.docs.northlight.ai/docs/errors.md
164
180
 
165
181
  ## The CLI
166
182
 
167
- `npx @north-light/sdk <command>`. None needs crtr unless noted.
183
+ `npx @north-light/sdk <command>`. The last column says which commands also use `crtr`, the [`@north-light/cli`](https://www.npmjs.com/package/@north-light/cli) runtime, when it is joined to your account.
168
184
 
169
185
  | Command | Effect | Needs crtr |
170
186
  |---|---|---|
@@ -180,28 +196,46 @@ Details behind each rule: [Errors](https://sdk.docs.northlight.ai/docs/errors.md
180
196
 
181
197
  ## Pages
182
198
 
183
- Read a page as markdown by adding `.md` to its URL. The whole site as one file: https://sdk.docs.northlight.ai/llms-full.txt.
199
+ Read a page as markdown by adding `.md` to its URL. The whole site as one file: https://sdk.docs.northlight.ai/llms-full.txt. The site is organised by what the app does; each section's first page shows one snippet and lists its guides:
200
+
201
+ - [Get started](https://sdk.docs.northlight.ai/docs/get-started.md): account, registration, sign-in, connections, scopes, testing mode.
202
+ - [Let your app use our AI](https://sdk.docs.northlight.ai/docs/use-our-ai.md): runs on the person's runtime, typed results, streaming, background runs.
203
+ - [Let your app use our apps](https://sdk.docs.northlight.ai/docs/use-our-apps.md): providers (sms, search, integrations, context, device).
204
+ - [Let your app know the person](https://sdk.docs.northlight.ai/docs/know-the-person.md): the context provider and their public documents.
205
+ - [Reach people by text](https://sdk.docs.northlight.ai/docs/reach-people-by-text.md): your app's own number with Comms.
206
+ - [Let the person's AI use your app](https://sdk.docs.northlight.ai/docs/let-their-ai-use-your-app.md): offer tools with [`@north-light/plugin`](https://www.npmjs.com/package/@north-light/plugin).
207
+ - [Let other apps build on yours](https://sdk.docs.northlight.ai/docs/build-on-your-app.md): offer a service and list it with a docs link.
184
208
 
209
+ Every page:
210
+
211
+ - [Get started](https://sdk.docs.northlight.ai/docs/get-started.md)
185
212
  - [Quick setup](https://sdk.docs.northlight.ai/docs/quickstart.md)
186
213
  - [Use your coding agent](https://sdk.docs.northlight.ai/docs/coding-agents.md)
187
214
  - [Where your app runs](https://sdk.docs.northlight.ai/docs/how-it-fits.md)
188
215
  - [Apps and sign-in](https://sdk.docs.northlight.ai/docs/concepts/apps-and-sign-in.md)
189
216
  - [Scopes and grants](https://sdk.docs.northlight.ai/docs/concepts/scopes-and-grants.md)
190
217
  - [Connections](https://sdk.docs.northlight.ai/docs/concepts/connections.md)
191
- - [Runs](https://sdk.docs.northlight.ai/docs/concepts/runs.md)
192
218
  - [Testing mode](https://sdk.docs.northlight.ai/docs/concepts/testing-mode.md)
193
- - [Providers](https://sdk.docs.northlight.ai/docs/concepts/providers.md)
219
+ - [Test your app](https://sdk.docs.northlight.ai/docs/guides/test-your-app.md)
220
+ - [Manage your app](https://sdk.docs.northlight.ai/docs/guides/manage-your-app.md)
221
+ - [Go live](https://sdk.docs.northlight.ai/docs/guides/go-live.md)
222
+ - [Let your app use our AI](https://sdk.docs.northlight.ai/docs/use-our-ai.md)
223
+ - [Runs](https://sdk.docs.northlight.ai/docs/concepts/runs.md)
194
224
  - [Typed results](https://sdk.docs.northlight.ai/docs/guides/typed-results.md)
195
225
  - [Stream a run](https://sdk.docs.northlight.ai/docs/guides/stream-a-run.md)
196
226
  - [Background runs](https://sdk.docs.northlight.ai/docs/guides/background-runs.md)
197
- - [Many users](https://sdk.docs.northlight.ai/docs/guides/many-users.md)
198
- - [Grant changes](https://sdk.docs.northlight.ai/docs/guides/grant-changes.md)
199
227
  - [Files and documents](https://sdk.docs.northlight.ai/docs/guides/files-and-documents.md)
200
- - [Test your app](https://sdk.docs.northlight.ai/docs/guides/test-your-app.md)
201
- - [Manage your app](https://sdk.docs.northlight.ai/docs/guides/manage-your-app.md)
202
- - [Go live](https://sdk.docs.northlight.ai/docs/guides/go-live.md)
228
+ - [Many users](https://sdk.docs.northlight.ai/docs/guides/many-users.md)
229
+ - [Let your app use our apps](https://sdk.docs.northlight.ai/docs/use-our-apps.md)
230
+ - [Providers](https://sdk.docs.northlight.ai/docs/concepts/providers.md)
203
231
  - [Providers index](https://sdk.docs.northlight.ai/docs/providers.md)
204
232
  - [Texting (sms)](https://sdk.docs.northlight.ai/docs/providers/sms.md)
233
+ - [Grant changes](https://sdk.docs.northlight.ai/docs/guides/grant-changes.md)
234
+ - [Let your app know the person](https://sdk.docs.northlight.ai/docs/know-the-person.md)
235
+ - [Context](https://sdk.docs.northlight.ai/docs/providers/context.md)
236
+ - [Reach people by text](https://sdk.docs.northlight.ai/docs/reach-people-by-text.md)
237
+ - [Let the person's AI use your app](https://sdk.docs.northlight.ai/docs/let-their-ai-use-your-app.md)
238
+ - [Let other apps build on yours](https://sdk.docs.northlight.ai/docs/build-on-your-app.md)
205
239
  - [Errors](https://sdk.docs.northlight.ai/docs/errors.md)
206
240
  - [CLI reference](https://sdk.docs.northlight.ai/docs/cli.md)
207
241
  - [Release notes](https://sdk.docs.northlight.ai/docs/release-notes.md)
@@ -1,3 +1,4 @@
1
+ import type { AppClient } from '../client.js';
1
2
  import type { Connection, ConnectionStore } from '../oauth/index.js';
2
3
  interface QueryResult {
3
4
  rows: Record<string, unknown>[];
@@ -29,4 +30,89 @@ export declare class PostgresConnectionStore implements ConnectionStore {
29
30
  /** Connections not saved since `before`, oldest first. Save activity is not necessarily refresh activity. */
30
31
  listStale(before: Date): Promise<Connection[]>;
31
32
  }
33
+ /** Anything with `query`: a `pg.Pool`, a `pg.PoolClient`, or a client inside the caller's open transaction. */
34
+ export interface PostgresQueryable {
35
+ query(sql: string, values?: unknown[]): Promise<QueryResult>;
36
+ }
37
+ /** The part of a person's runtime client the kit uses. `ConnectedCrouter` (`oauth.appClient(sub)`) satisfies it. */
38
+ export type ObjectRuntime = Pick<AppClient, 'customObjects' | 'canvas'>;
39
+ export interface ObjectKitTables {
40
+ /** One row per (person, kind, target): the registered custom object. Default `watch_object`. */
41
+ object?: string;
42
+ /** Nodes linked to an object. Default `watch_watcher`. */
43
+ watcher?: string;
44
+ /** The publish outbox. Default `watch_publish`. */
45
+ publish?: string;
46
+ }
47
+ /** The DDL the app migrates for the object kit. Table names follow `ObjectKitTables` (lowercase identifiers, optionally schema-qualified). */
48
+ export declare function objectKitSchemaSql(tables?: ObjectKitTables): string;
49
+ export interface PostgresObjectStoreOptions {
50
+ /** The app's provider id (`CROUTER_CLIENT_ID`). A kind `problem` registers as the custom-object kind `<providerId>.problem`, which the plugin's `objectKinds` declares. */
51
+ providerId: string;
52
+ /** The app's client on a person's runtime, e.g. `(sub) => oauth.appClient(sub)`. */
53
+ runtime(sub: string): Promise<ObjectRuntime>;
54
+ tables?: ObjectKitTables;
55
+ /** Receives retry and drop notices from the publisher. Default `console`. */
56
+ log?: {
57
+ warn(message: string): void;
58
+ error(message: string, error?: unknown): void;
59
+ };
60
+ }
61
+ export interface ObjectInstance {
62
+ objectId: string;
63
+ sub: string;
64
+ kind: string;
65
+ target: string;
66
+ name: string;
67
+ }
68
+ export interface PublishRequest {
69
+ event: string;
70
+ /** Truncated to 500 characters. */
71
+ summary: string;
72
+ state?: Record<string, unknown> | null;
73
+ }
74
+ export interface PublisherOptions {
75
+ /** Milliseconds between ticks. Default 5000. */
76
+ tick?: number;
77
+ /** Rows delivered per tick. Default 100. */
78
+ batch?: number;
79
+ }
80
+ export interface Publisher {
81
+ /** Stops the timer and waits for a tick in progress. */
82
+ stop(): Promise<void>;
83
+ }
84
+ /** A publish not delivered this long after it was enqueued is dropped. */
85
+ export declare const PUBLISH_WINDOW_MS: number;
86
+ /**
87
+ * Per-person custom objects on each person's hosted runtime, the nodes that watch them, and an at-least-once publish
88
+ * outbox. The app migrates the tables (`objectKitSchemaSql`), registers the kinds in its plugin, calls `instance` and
89
+ * `watch` from its commands, calls `enqueue` inside the transaction of the write an event records, and runs
90
+ * `startPublisher` in each process.
91
+ */
92
+ export declare class PostgresObjectStore {
93
+ private readonly pool;
94
+ private readonly options;
95
+ private readonly object;
96
+ private readonly watcher;
97
+ private readonly publish;
98
+ private readonly lockKey;
99
+ private readonly log;
100
+ constructor(pool: PostgresPool, options: PostgresObjectStoreOptions);
101
+ /** The person's object for (kind, target), registered on their runtime the first time. A concurrent first call registers a second object; the row that committed first wins and the loser is unregistered. `target` is `''` for a kind with one object per person. */
102
+ instance(sub: string, kind: string, target: string, meta: {
103
+ name: string;
104
+ description: string;
105
+ }): Promise<ObjectInstance>;
106
+ /** Links `node` (on `machine`, `undefined` or `''` for the person's hosted runtime) to the object, then records the link. A node the runtime does not know rejects with `NotFoundError`. */
107
+ watch(objectId: string, node: string, machine?: string): Promise<void>;
108
+ /** Adds a publish row. Pass the client of the caller's open transaction so the row exists exactly when the write it records does. */
109
+ enqueue(client: PostgresQueryable, objectId: string, event: PublishRequest): Promise<void>;
110
+ /** Delivers due rows once under the advisory lock; false when another process holds it. */
111
+ tick(batch?: number): Promise<boolean>;
112
+ /** Runs `tick` every `tick` ms (default 5000, 100 rows). A failed tick is logged and the next one runs. */
113
+ startPublisher(options?: PublisherOptions): Publisher;
114
+ private find;
115
+ private deliver;
116
+ private end;
117
+ }
32
118
  export {};
@@ -1,4 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { customObjectPublishFailure } from '@north-light/plugin';
4
+ import { APIError } from '../errors.js';
2
5
  function tableName(name) {
3
6
  const parts = name.split('.');
4
7
  if (parts.length < 1 || parts.length > 2 || parts.some((part) => !/^[a-z_][a-z_0-9]*$/.test(part))) {
@@ -90,3 +93,182 @@ export class PostgresConnectionStore {
90
93
  return result.rows.map(toConnection);
91
94
  }
92
95
  }
96
+ const DEFAULT_TABLES = { object: 'watch_object', watcher: 'watch_watcher', publish: 'watch_publish' };
97
+ /** The DDL the app migrates for the object kit. Table names follow `ObjectKitTables` (lowercase identifiers, optionally schema-qualified). */
98
+ export function objectKitSchemaSql(tables = {}) {
99
+ const t = { ...DEFAULT_TABLES, ...tables };
100
+ const [object, watcher, publish] = [t.object, t.watcher, t.publish].map(tableName);
101
+ const base = t.publish.split('.').pop();
102
+ return `CREATE TABLE ${object} (
103
+ object_id text PRIMARY KEY,
104
+ sub text NOT NULL,
105
+ kind text NOT NULL,
106
+ target text NOT NULL,
107
+ name text NOT NULL,
108
+ created_at timestamptz NOT NULL DEFAULT now(),
109
+ UNIQUE (sub, kind, target)
110
+ );
111
+ CREATE TABLE ${watcher} (
112
+ object_id text NOT NULL REFERENCES ${object} (object_id) ON DELETE CASCADE,
113
+ node text NOT NULL,
114
+ machine text NOT NULL DEFAULT '',
115
+ PRIMARY KEY (object_id, node, machine)
116
+ );
117
+ CREATE TABLE ${publish} (
118
+ id text PRIMARY KEY,
119
+ object_id text NOT NULL REFERENCES ${object} (object_id) ON DELETE CASCADE,
120
+ event text NOT NULL,
121
+ summary text NOT NULL,
122
+ state jsonb,
123
+ status text NOT NULL DEFAULT 'pending',
124
+ attempts integer NOT NULL DEFAULT 0,
125
+ last_error text,
126
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
127
+ created_at timestamptz NOT NULL DEFAULT now()
128
+ );
129
+ CREATE INDEX "${base}_due_idx" ON ${publish} (status, next_attempt_at);
130
+ CREATE INDEX "${base}_object_idx" ON ${publish} (object_id);
131
+ `;
132
+ }
133
+ /** A publish not delivered this long after it was enqueued is dropped. */
134
+ export const PUBLISH_WINDOW_MS = 24 * 60 * 60 * 1000;
135
+ const MAX_BACKOFF_MS = 15 * 60 * 1000;
136
+ /**
137
+ * Per-person custom objects on each person's hosted runtime, the nodes that watch them, and an at-least-once publish
138
+ * outbox. The app migrates the tables (`objectKitSchemaSql`), registers the kinds in its plugin, calls `instance` and
139
+ * `watch` from its commands, calls `enqueue` inside the transaction of the write an event records, and runs
140
+ * `startPublisher` in each process.
141
+ */
142
+ export class PostgresObjectStore {
143
+ pool;
144
+ options;
145
+ object;
146
+ watcher;
147
+ publish;
148
+ lockKey;
149
+ log;
150
+ constructor(pool, options) {
151
+ this.pool = pool;
152
+ this.options = options;
153
+ const t = { ...DEFAULT_TABLES, ...options.tables };
154
+ this.object = tableName(t.object);
155
+ this.watcher = tableName(t.watcher);
156
+ this.publish = tableName(t.publish);
157
+ this.lockKey = `north-light-sdk-object-publish:${t.publish}`;
158
+ this.log = options.log ?? { warn: (m) => console.warn(m), error: (m, e) => console.error(m, e) };
159
+ }
160
+ /** The person's object for (kind, target), registered on their runtime the first time. A concurrent first call registers a second object; the row that committed first wins and the loser is unregistered. `target` is `''` for a kind with one object per person. */
161
+ async instance(sub, kind, target, meta) {
162
+ const existing = await this.find(sub, kind, target);
163
+ if (existing)
164
+ return existing;
165
+ const runtime = await this.options.runtime(sub);
166
+ const registered = await runtime.customObjects.register({
167
+ kind: `${this.options.providerId}.${kind}`,
168
+ name: meta.name,
169
+ description: meta.description,
170
+ privacy: 'private',
171
+ });
172
+ const inserted = await this.pool.query(`INSERT INTO ${this.object} (object_id, sub, kind, target, name) VALUES ($1, $2, $3, $4, $5)
173
+ ON CONFLICT (sub, kind, target) DO NOTHING RETURNING object_id`, [registered.id, sub, kind, target, meta.name]);
174
+ if (inserted.rows.length > 0)
175
+ return { objectId: registered.id, sub, kind, target, name: meta.name };
176
+ const winner = await this.find(sub, kind, target);
177
+ if (!winner)
178
+ throw new Error(`object ${sub}/${kind}/${target} vanished during registration`);
179
+ if (winner.objectId !== registered.id)
180
+ await runtime.customObjects.unregister(registered.id);
181
+ return winner;
182
+ }
183
+ /** Links `node` (on `machine`, `undefined` or `''` for the person's hosted runtime) to the object, then records the link. A node the runtime does not know rejects with `NotFoundError`. */
184
+ async watch(objectId, node, machine) {
185
+ const found = await this.pool.query(`SELECT sub FROM ${this.object} WHERE object_id = $1`, [objectId]);
186
+ const sub = found.rows[0]?.sub;
187
+ if (sub === undefined)
188
+ throw new Error(`no object ${objectId}`);
189
+ const runtime = await this.options.runtime(sub);
190
+ await runtime.canvas.watch(objectId, { watcher: node, ...(machine ? { watcher_machine: machine } : {}) });
191
+ await this.pool.query(`INSERT INTO ${this.watcher} (object_id, node, machine) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [objectId, node, machine ?? '']);
192
+ }
193
+ /** Adds a publish row. Pass the client of the caller's open transaction so the row exists exactly when the write it records does. */
194
+ async enqueue(client, objectId, event) {
195
+ await client.query(`INSERT INTO ${this.publish} (id, object_id, event, summary, state) VALUES ($1, $2, $3, $4, $5::jsonb)`, [randomUUID(), objectId, event.event, event.summary.slice(0, 500), event.state == null ? null : JSON.stringify(event.state)]);
196
+ }
197
+ /** Delivers due rows once under the advisory lock; false when another process holds it. */
198
+ async tick(batch = 100) {
199
+ const lock = await this.pool.connect();
200
+ try {
201
+ const got = await lock.query('SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS ok', [this.lockKey]);
202
+ if (!got.rows[0]?.ok)
203
+ return false;
204
+ try {
205
+ const due = await this.pool.query(`SELECT p.id, p.object_id, o.sub, p.event, p.summary, p.state, p.attempts, p.created_at
206
+ FROM ${this.publish} p JOIN ${this.object} o ON o.object_id = p.object_id
207
+ WHERE p.status = 'pending' AND p.next_attempt_at <= now()
208
+ ORDER BY p.created_at ASC, p.id ASC LIMIT $1`, [batch]);
209
+ for (const row of due.rows)
210
+ await this.deliver(row);
211
+ }
212
+ finally {
213
+ await lock.query('SELECT pg_advisory_unlock(hashtextextended($1, 0))', [this.lockKey]);
214
+ }
215
+ return true;
216
+ }
217
+ finally {
218
+ lock.release();
219
+ }
220
+ }
221
+ /** Runs `tick` every `tick` ms (default 5000, 100 rows). A failed tick is logged and the next one runs. */
222
+ startPublisher(options = {}) {
223
+ let running;
224
+ const timer = setInterval(() => {
225
+ if (running)
226
+ return; // a pass is still delivering: skip this firing
227
+ running = this.tick(options.batch)
228
+ .catch((error) => this.log.error('object publish tick failed', error))
229
+ .finally(() => { running = undefined; });
230
+ }, options.tick ?? 5_000);
231
+ timer.unref();
232
+ return {
233
+ stop: async () => {
234
+ clearInterval(timer);
235
+ await running;
236
+ },
237
+ };
238
+ }
239
+ async find(sub, kind, target) {
240
+ const result = await this.pool.query(`SELECT object_id, name FROM ${this.object} WHERE sub = $1 AND kind = $2 AND target = $3`, [sub, kind, target]);
241
+ const row = result.rows[0];
242
+ return row ? { objectId: row.object_id, sub, kind, target, name: row.name } : null;
243
+ }
244
+ async deliver(row) {
245
+ const now = Date.now();
246
+ if (now - row.created_at.getTime() > PUBLISH_WINDOW_MS)
247
+ return this.end(row, 'dropped', '24 h window elapsed');
248
+ try {
249
+ const runtime = await this.options.runtime(row.sub);
250
+ await runtime.customObjects.publish(row.object_id, {
251
+ event: row.event,
252
+ summary: row.summary,
253
+ ...(row.state == null ? {} : { state: row.state }),
254
+ });
255
+ }
256
+ catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ const refusal = error instanceof APIError ? { status: error.status, code: error.code } : undefined;
259
+ if (refusal && customObjectPublishFailure(refusal) === 'drop') {
260
+ return this.end(row, 'dropped', `${refusal.code}: ${message}`);
261
+ }
262
+ const attempts = row.attempts + 1;
263
+ await this.pool.query(`UPDATE ${this.publish} SET attempts = $2, last_error = $3, next_attempt_at = now() + $4 * interval '1 millisecond' WHERE id = $1`, [row.id, attempts, message.slice(0, 500), Math.min(2 ** attempts * 10_000, MAX_BACKOFF_MS)]);
264
+ this.log.warn(`publish ${row.id} attempt ${attempts} failed: ${message}`);
265
+ return;
266
+ }
267
+ await this.end(row, 'done', null);
268
+ }
269
+ async end(row, status, lastError) {
270
+ if (status === 'dropped')
271
+ this.log.warn(`dropped publish ${row.id}: ${lastError}`);
272
+ await this.pool.query(`UPDATE ${this.publish} SET status = $2, last_error = $3, attempts = attempts + 1 WHERE id = $1`, [row.id, status, lastError]);
273
+ }
274
+ }
package/dist/version.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
2
- export declare const SDK_VERSION = "0.3.543";
2
+ export declare const SDK_VERSION = "0.3.545";
3
3
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
4
4
  * contract level existed (which only knows this header) refuses this SDK, while every later runtime accepts it. */
5
5
  export declare const LEGACY_FLOOR = "0.3.423";
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SDK_VERSION is written by scripts/release-version.mjs on every release; do not edit by hand.
2
2
  /** This SDK's release version. Informational only: compatibility is the contract level, not this number. */
3
- export const SDK_VERSION = '0.3.543';
3
+ export const SDK_VERSION = '0.3.545';
4
4
  // LEGACY_FLOOR is written once by `scripts/contract.mjs release`, at the first release that carries the contract level
5
5
  // (the `0.0.0` placeholder is replaced there and never again); do not edit by hand.
6
6
  /** The release sent in `Crouter-Minimum-Runtime-Version` on every request. It is frozen, so a runtime from before the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/sdk",
3
- "version": "0.3.543",
3
+ "version": "0.3.545",
4
4
  "crouterContract": "1.29",
5
5
  "description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
6
6
  "type": "module",
@@ -44,8 +44,9 @@
44
44
  "prepublishOnly": "node -e \"if (process.env.npm_config_tag !== 'next') { console.error('@north-light/sdk publishes only with --tag next; scripts/promote-sdk.mjs is the only mover of latest.'); process.exit(1); }\""
45
45
  },
46
46
  "dependencies": {
47
- "@north-light/api": "0.3.543",
48
- "@north-light/identity": "0.3.543",
47
+ "@north-light/api": "0.3.545",
48
+ "@north-light/identity": "0.3.545",
49
+ "@north-light/plugin": "0.3.545",
49
50
  "jose": "^6.2.1"
50
51
  },
51
52
  "peerDependencies": {