@clivly/sdk 0.12.0 → 0.14.1

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
@@ -272,10 +272,16 @@ production runtimes.
272
272
  // Cloudflare Workers
273
273
  export default {
274
274
  scheduled: () => clivly.runScheduled(), // cron: one heartbeat + one sync
275
- fetch: clivly.createClivlyHandler(), // or drive via an uptime ping
275
+ fetch: clivly.createClivlyHandler(), // the route Clivly Cloud POSTs to
276
276
  };
277
277
  ```
278
278
 
279
+ **Only `POST` triggers a sync.** `HEAD` answers `204` and does no work — that
280
+ is the liveness probe. `GET` still syncs but logs a deprecation warning, and a
281
+ future release will refuse it with `405`, so point any cron or uptime check at
282
+ `POST`. Set `CLIVLY_TRIGGER_REQUIRE_POST=true` to adopt that now and confirm
283
+ nothing in your setup depends on `GET`.
284
+
279
285
  `runScheduled()` throws if a sync push fails, so a cron run is marked failed.
280
286
  `createClivlyHandler()` returns `200` when the triggered run finishes
281
287
  successfully and `500` otherwise, with `{ ok, runId, durationMs }` or
@@ -350,7 +356,8 @@ it, dashboard-authored objects are fetched and then skipped; nothing errors, and
350
356
  | Nothing connects, no error | You imported the config but never called an entry point | Call `start()` / `runScheduled()`, or add `clivlyVite(sdk)` |
351
357
  | `reason: "invalid_api_key"` | `CLIVLY_SECRET_KEY` missing, wrong, or revoked by a regeneration | Re-run `npx clivly login` |
352
358
  | Sync works once on serverless, then stops | `start()`'s `setInterval` cannot persist | Use `runScheduled()` or `createClivlyHandler()` |
353
- | Every cold start re-reads everything | The default cursor store is in-memory | Pass a persistent [`cursorStore`](#persisting-the-delta-cursor) |
359
+ | Every run re-reads everything | The default cursor store is in-memory, which serverless discards between invocations | Pass [`kvCursorStore`](#persisting-the-delta-cursor) |
360
+ | A cron stopped triggering syncs | Only `POST` triggers a sync | Change the caller to `POST` |
354
361
  | `401` on the tick route | Trigger secret differs between your app and Clivly | Redeploy with the current `CLIVLY_SYNC_TRIGGER_SECRET` |
355
362
  | An entity syncs nothing, silently | `source` keyed by entity key instead of CRM concept | Key it `contacts` / `companies` / `deals` / `custom` |
356
363
  | Rows sync on insert but never on edit | `cursorField` does not change on edit | Add an `updated_at` column and use it. `doctor()` warns about this |
@@ -372,35 +379,92 @@ failed `connect()` result into a throwable.
372
379
 
373
380
  Delta sync tracks a per-entity cursor so each run only pages rows changed since
374
381
  the last one. By default that cursor lives in memory — fine for a long-lived
375
- process, but **lost on every serverless cold start**, so a fresh isolate would
376
- re-page the whole entity.
382
+ process, but **discarded between invocations on serverless**. Every delta run
383
+ then starts from nothing and re-reads your entire source tables: a full sync,
384
+ on every run, reported as a success. The SDK warns at startup when it detects a
385
+ serverless runtime with no `cursorStore`, and `clivly status` flags it.
386
+
387
+ Use the store the SDK ships. It works with any key-value client that has `get`
388
+ and `put` — Workers KV, Upstash, node-redis:
377
389
 
378
390
  ```ts
391
+ import { createClivlySDK, kvCursorStore } from "@clivly/sdk";
392
+
379
393
  const clivly = createClivlySDK({
380
394
  apiKey: process.env.CLIVLY_SECRET_KEY!,
381
395
  entities,
382
396
  source: { /* … */ },
383
- cursorStore: {
384
- // `SyncCursor` is { id: string; time: Date } — serialize `time` yourself.
385
- get: async (entity) => {
386
- const raw = await kv.get(`clivly:cursor:${entity}`);
387
- return raw ? { id: raw.id, time: new Date(raw.time) } : null;
388
- },
389
- set: (entity, cursor) =>
390
- kv.put(
391
- `clivly:cursor:${entity}`,
392
- cursor && { id: cursor.id, time: cursor.time.toISOString() }
393
- ),
394
- },
397
+ cursorStore: kvCursorStore(env.CLIVLY_CURSORS),
395
398
  });
396
399
  ```
397
400
 
401
+ Writing your own is fine too, but mind the one trap: **`SyncCursor.time` is a
402
+ `Date`**. A JSON round-trip turns it into a string, and the sync path then
403
+ calls `.toISOString()` on it and throws. Serialize to ISO on the way out and
404
+ parse back to a `Date` on the way in — which is exactly what `kvCursorStore`
405
+ does for you:
406
+
407
+ ```ts
408
+ cursorStore: {
409
+ get: async (entity) => {
410
+ const raw = await kv.get(`clivly:cursor:${entity}`);
411
+ if (!raw) return null;
412
+ const { id, time } = JSON.parse(raw);
413
+ return { id, time: new Date(time) }; // back to a Date
414
+ },
415
+ set: (entity, cursor) =>
416
+ kv.put(
417
+ `clivly:cursor:${entity}`,
418
+ cursor ? JSON.stringify({ id: cursor.id, time: cursor.time.toISOString() }) : ""
419
+ ),
420
+ }
421
+ ```
422
+
423
+ Return `null` rather than throwing on a missing or malformed value: the cost is
424
+ one full re-read, where throwing would stop sync entirely.
425
+
426
+ `memoryCursorStore()` is also exported, for tests and for long-lived hosts that
427
+ want the default named explicitly.
428
+
398
429
  Delta sync is **at-least-once**: after a cold start with no persisted cursor,
399
430
  rows since the last push may be re-sent (the server dedupes by id) — it never
400
431
  silently skips rows. Use `sync({ mode: "full" })` when you need the server to
401
432
  reconcile deletions (archive-on-leave); a delta cannot tell absence from
402
433
  "unchanged".
403
434
 
435
+ ### Pushing a row the moment it is written
436
+
437
+ Scheduled syncs pick new rows up eventually. To push one immediately (a signup,
438
+ say), opt in with `sync: { instant: true }` and wrap the insert with
439
+ `insertAndPush`. You create it once per mapped table, and it inserts,
440
+ translates the row and pushes it. Without the opt-in, both helpers do nothing
441
+ and the row arrives on the next delta sync instead. The `entity` argument is
442
+ the key from your `entities` config. A push never throws: a failed push only
443
+ logs a warning, and the next delta sync still delivers the row.
444
+
445
+ ```ts
446
+ import { insertAndPush } from "clivly/drizzle";
447
+
448
+ const insertUser = insertAndPush(clivly, db, schema.users, { entity: "user" });
449
+
450
+ // In the signup handler. Pass `waitUntil` on Workers so the push outlives the response.
451
+ const created = await insertUser(newUserValues, c.executionCtx.waitUntil.bind(c.executionCtx));
452
+ ```
453
+
454
+ When a library performs the insert itself (an auth library's lifecycle hook,
455
+ for example) there is no call to wrap. Re-read the row and translate it with
456
+ `toColumnKeyedRow`, then push it:
457
+
458
+ ```ts
459
+ import { toColumnKeyedRow } from "clivly/drizzle";
460
+
461
+ await clivly.pushOne("user", toColumnKeyedRow(schema.users, row));
462
+ ```
463
+
464
+ The translation matters. A Drizzle row is keyed by property name (`fullName`),
465
+ but mappings and the cloud address columns by their database name
466
+ (`full_name`), so an untranslated row pushes empty fields without any error.
467
+
404
468
  ### Sync ordering
405
469
 
406
470
  `runSync` pushes **companies first**, then contacts, then deals, then custom
@@ -418,9 +482,17 @@ membership**, each row carrying the join table's foreign-key column.
418
482
  { id: "u1", full_name: "Ada", email: "ada@acme.test", org_id: "globex" }
419
483
  ```
420
484
 
421
- This mirrors what the embedded model produces with `LEFT JOIN <join table>`, and
422
- it is what lets Clivly keep one CRM row per membership instead of collapsing the
423
- person onto whichever company happened to come first.
485
+ This mirrors what the embedded model produces with `LEFT JOIN <join table>`.
486
+ Clivly keeps Ada as **one contact** (identity is her `id`, not `id` + company)
487
+ with **two memberships**, one per organisation. The contact list shows her once,
488
+ as `Acme +1`, and both organisations' contact counts include her. Her primary
489
+ organisation is the lowest company ref for a new contact, and it stays put
490
+ while she still belongs to it.
491
+
492
+ When she leaves one organisation, a **full** sync ends that membership and
493
+ keeps the contact and her other memberships live. A delta sync never ends a
494
+ membership, because a missing row may just be on the next page of the push.
495
+ She is archived only when she leaves the source entirely.
424
496
 
425
497
  A person with no membership may omit the column entirely or send it as `null` —
426
498
  both are accepted. But if **no** pushed row carries the column, the push is
@@ -450,13 +522,14 @@ is honoured. Other `4xx` responses return immediately.
450
522
  | `entities` | `ClivlyEntitiesConfig` | — | From `defineClivlyConfig({ entities })` |
451
523
  | `schema` | `DiscoveredTable[]` | — | Use `discoverFromDrizzle(schema)`. It reports keys, types, nullability and FK edges alongside column names; [derived objects](#derived-objects) cannot be authored without them, so prefer it over a hand-written array |
452
524
  | `source` | `{ contacts, companies?, deals?, custom? }` | — | Keyed by CRM concept. `contacts` is required when `source` is present |
453
- | `cursorStore` | `CursorStore` | in-memory | Persist on serverless |
525
+ | `cursorStore` | `CursorStore` | in-memory | **Required on serverless** — `kvCursorStore(kv)` |
454
526
  | `heartbeatIntervalMs` | `number` | `60000` | HTTP presence cadence and default sync cadence; paused when opt-in WebSocket presence is ready |
455
527
  | `presence.transport` | `"http" \\| "websocket"` | `"http"` | WebSocket is for long-lived Node hosts; HTTP resumes during reconnect/fallback |
456
528
  | `presence.deploymentId` | `string` | platform-derived | Optional release identity for a WebSocket deployment |
457
529
  | `sync.onBoot` | `boolean` | `true` | Only affects `start()` |
458
530
  | `sync.intervalMs` | `number` | `heartbeatIntervalMs` | |
459
531
  | `sync.batchSize` | `number` | `500` | Rows per page |
532
+ | `sync.instant` | `boolean` | `false` | Lets `pushOne()` / `insertAndPush()` push a single row immediately; see [Pushing a row the moment it is written](#pushing-a-row-the-moment-it-is-written) |
460
533
  | `retry.maxAttempts` / `retry.baseDelayMs` | `number` | `3` / `500` | |
461
534
  | `syncTrigger.path` / `.url` / `.secret` | `string` | — | See above |
462
535
  | `verifyToken` | `string` | — | For `createAuthVerifyHandler`. **Nothing calls that endpoint today** |
@@ -518,7 +591,7 @@ rejected key and a dead network all mean the question went unanswered.
518
591
  | From `clivly` | From `@clivly/sdk` | Contents |
519
592
  | --- | --- | --- |
520
593
  | `clivly/sdk` | `@clivly/sdk` | `createClivlySDK`, `defineClivlyConfig`, `createChatSessionHandler`, error classes, all types |
521
- | `clivly/drizzle` | `@clivly/sdk/drizzle` | `fromDrizzle`, `discoverFromDrizzle`, `drizzleIntrospector`, `buildUniqueConstraints` |
594
+ | `clivly/drizzle` | `@clivly/sdk/drizzle` | `fromDrizzle`, `discoverFromDrizzle`, `drizzleIntrospector`, `buildUniqueConstraints`, `insertAndPush`, `toColumnKeyedRow` |
522
595
  | `clivly/vite` | `@clivly/sdk/vite` | `clivlyVite` |
523
596
  | — | `@clivly/sdk/chat-session` | `createChatSessionHandler` (also on the root) |
524
597
 
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-CvhxV-IX.cjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-DK8mqmkV.cjs";
2
2
  //#region src/chat-session.d.ts
3
3
  export declare function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl, fetchImpl, resolveUser }: CreateChatSessionHandlerOptions): (request: Request) => Promise<Response>;
4
4
  //#endregion
@@ -1,4 +1,4 @@
1
- import { c as CreateChatSessionHandlerOptions } from "./types-CvhxV-IX.mjs";
1
+ import { c as CreateChatSessionHandlerOptions } from "./types-DK8mqmkV.mjs";
2
2
  //#region src/chat-session.d.ts
3
3
  export declare function createChatSessionHandler({ allowedOrigins, apiKey, apiUrl, fetchImpl, resolveUser }: CreateChatSessionHandlerOptions): (request: Request) => Promise<Response>;
4
4
  //#endregion
package/dist/drizzle.cjs CHANGED
@@ -163,6 +163,69 @@ function fromDrizzle(db, table, opts) {
163
163
  return source;
164
164
  }
165
165
  /**
166
+ * Insert a row into a mapped Drizzle table and push it to Clivly immediately
167
+ * — the "one call does both" instant-push API (see
168
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md, question 1). Returns
169
+ * only after the write completes; the push itself is fire-and-forget (see
170
+ * `pushOne`), so this resolves at the same time a plain `db.insert(...)`
171
+ * would.
172
+ *
173
+ * Created once per mapped table, mirroring `fromDrizzle`'s own convention —
174
+ * no per-call table-to-entity lookup, so a table two entities share (e.g.
175
+ * `user` for both `user` and `organizationOwner`) is never ambiguous:
176
+ * ```ts
177
+ * const insertUser = insertAndPush(clivly, db, schema.user, { entity: "user" });
178
+ * // in the signup handler:
179
+ * const created = await insertUser(newUserValues, c.executionCtx);
180
+ * ```
181
+ *
182
+ * `waitUntil`, when provided (Hono exposes it as `c.executionCtx.waitUntil`),
183
+ * extends the request's lifetime past the response so the push survives on
184
+ * Cloudflare Workers and similar runtimes, which otherwise kill background
185
+ * work the instant a response is sent. Omit it on a long-lived process where
186
+ * nothing tears the request down early.
187
+ *
188
+ * Table property keys are translated to real database column names before
189
+ * the push — the same translation `fromDrizzle` performs on `fetchPage` rows
190
+ * — because a raw `.returning()` result is keyed by Drizzle property name
191
+ * (`fullName`), and every consumer downstream of a push (the fieldMap, the
192
+ * cloud's ingest) addresses columns by their database name (`full_name`).
193
+ * Skipping this would silently push empty fields with no error anywhere.
194
+ */
195
+ /**
196
+ * Translate a Drizzle-property-keyed row (e.g. a `.returning()` result, or a
197
+ * plain unprojected `select()`) to real database column names — the same
198
+ * translation `fromDrizzle` performs on `fetchPage` rows. Every consumer
199
+ * downstream of a push (the fieldMap, the cloud's ingest) addresses columns
200
+ * by their database name (`full_name`), not the property name (`fullName`);
201
+ * skipping this would silently push empty fields with no error anywhere.
202
+ *
203
+ * Exported so a host can build the same translated row from a table it read
204
+ * some other way — e.g. re-querying a row inside an auth library's own
205
+ * lifecycle hook, where there is no `insertAndPush` call site to wrap
206
+ * because the library performs the insert internally.
207
+ */
208
+ function toColumnKeyedRow(table, row) {
209
+ const columns = (0, drizzle_orm.getTableColumns)(table);
210
+ const columnNameByPropertyKey = /* @__PURE__ */ new Map();
211
+ for (const [propertyKey, column] of Object.entries(columns)) columnNameByPropertyKey.set(propertyKey, column.name);
212
+ const out = {};
213
+ for (const [propertyKey, value] of Object.entries(row)) out[columnNameByPropertyKey.get(propertyKey) ?? propertyKey] = value;
214
+ return out;
215
+ }
216
+ function insertAndPush(clivly, db, table, opts) {
217
+ const insertableDb = db;
218
+ return async (values, waitUntil) => {
219
+ const [created] = await insertableDb.insert(table).values(values).returning();
220
+ if (!created) throw new Error(`insertAndPush: insert into "${(0, drizzle_orm.getTableName)(table)}" returned no row.`);
221
+ const row = toColumnKeyedRow(table, created);
222
+ const pushed = clivly.pushOne(opts.entity, row);
223
+ if (waitUntil) waitUntil(pushed);
224
+ else await pushed;
225
+ return created;
226
+ };
227
+ }
228
+ /**
166
229
  * Build a `NamespaceIntrospector` from any Drizzle client that exposes
167
230
  * `.execute()`. Reads `information_schema` in the connection's default schema
168
231
  * (`current_schema()`), so it reports exactly the tables the app can reach.
@@ -207,3 +270,5 @@ Object.defineProperty(exports, "discoverFromDrizzle", {
207
270
  exports.drizzleIntrospector = drizzleIntrospector;
208
271
  exports.fromDrizzle = fromDrizzle;
209
272
  exports.fromDrizzleProjection = fromDrizzleProjection;
273
+ exports.insertAndPush = insertAndPush;
274
+ exports.toColumnKeyedRow = toColumnKeyedRow;
@@ -1,4 +1,4 @@
1
- import { _ as SyncSource, y as NamespaceIntrospector } from "./types-CvhxV-IX.cjs";
1
+ import { _ as SyncSource, y as NamespaceIntrospector } from "./types-DK8mqmkV.cjs";
2
2
  import { ProjectionMapping, ProjectionRef } from "@clivly/core";
3
3
  import { Table } from "drizzle-orm";
4
4
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
@@ -115,6 +115,56 @@ export declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizz
115
115
  objectType: string;
116
116
  };
117
117
  export declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizzleOptions): SyncSource;
118
+ interface PushOneCapable {
119
+ pushOne(entityKey: string, row: Record<string, unknown>): Promise<void>;
120
+ }
121
+ /**
122
+ * Insert a row into a mapped Drizzle table and push it to Clivly immediately
123
+ * — the "one call does both" instant-push API (see
124
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md, question 1). Returns
125
+ * only after the write completes; the push itself is fire-and-forget (see
126
+ * `pushOne`), so this resolves at the same time a plain `db.insert(...)`
127
+ * would.
128
+ *
129
+ * Created once per mapped table, mirroring `fromDrizzle`'s own convention —
130
+ * no per-call table-to-entity lookup, so a table two entities share (e.g.
131
+ * `user` for both `user` and `organizationOwner`) is never ambiguous:
132
+ * ```ts
133
+ * const insertUser = insertAndPush(clivly, db, schema.user, { entity: "user" });
134
+ * // in the signup handler:
135
+ * const created = await insertUser(newUserValues, c.executionCtx);
136
+ * ```
137
+ *
138
+ * `waitUntil`, when provided (Hono exposes it as `c.executionCtx.waitUntil`),
139
+ * extends the request's lifetime past the response so the push survives on
140
+ * Cloudflare Workers and similar runtimes, which otherwise kill background
141
+ * work the instant a response is sent. Omit it on a long-lived process where
142
+ * nothing tears the request down early.
143
+ *
144
+ * Table property keys are translated to real database column names before
145
+ * the push — the same translation `fromDrizzle` performs on `fetchPage` rows
146
+ * — because a raw `.returning()` result is keyed by Drizzle property name
147
+ * (`fullName`), and every consumer downstream of a push (the fieldMap, the
148
+ * cloud's ingest) addresses columns by their database name (`full_name`).
149
+ * Skipping this would silently push empty fields with no error anywhere.
150
+ */
151
+ /**
152
+ * Translate a Drizzle-property-keyed row (e.g. a `.returning()` result, or a
153
+ * plain unprojected `select()`) to real database column names — the same
154
+ * translation `fromDrizzle` performs on `fetchPage` rows. Every consumer
155
+ * downstream of a push (the fieldMap, the cloud's ingest) addresses columns
156
+ * by their database name (`full_name`), not the property name (`fullName`);
157
+ * skipping this would silently push empty fields with no error anywhere.
158
+ *
159
+ * Exported so a host can build the same translated row from a table it read
160
+ * some other way — e.g. re-querying a row inside an auth library's own
161
+ * lifecycle hook, where there is no `insertAndPush` call site to wrap
162
+ * because the library performs the insert internally.
163
+ */
164
+ export declare function toColumnKeyedRow(table: Table, row: Record<string, unknown>): Record<string, unknown>;
165
+ export declare function insertAndPush(clivly: PushOneCapable, db: unknown, table: Table, opts: {
166
+ entity: string;
167
+ }): (values: Record<string, unknown>, waitUntil?: (promise: Promise<unknown>) => void) => Promise<Record<string, unknown>>;
118
168
  /**
119
169
  * Build a `NamespaceIntrospector` from any Drizzle client that exposes
120
170
  * `.execute()`. Reads `information_schema` in the connection's default schema
@@ -1,4 +1,4 @@
1
- import { _ as SyncSource, y as NamespaceIntrospector } from "./types-CvhxV-IX.mjs";
1
+ import { _ as SyncSource, y as NamespaceIntrospector } from "./types-DK8mqmkV.mjs";
2
2
  import { ProjectionMapping, ProjectionRef } from "@clivly/core";
3
3
  import { buildUniqueConstraints, discoverFromDrizzle } from "@clivly/core/drizzle";
4
4
  import { Table } from "drizzle-orm";
@@ -115,6 +115,56 @@ export declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizz
115
115
  objectType: string;
116
116
  };
117
117
  export declare function fromDrizzle(db: DrizzleDb, table: Table, opts: FromDrizzleOptions): SyncSource;
118
+ interface PushOneCapable {
119
+ pushOne(entityKey: string, row: Record<string, unknown>): Promise<void>;
120
+ }
121
+ /**
122
+ * Insert a row into a mapped Drizzle table and push it to Clivly immediately
123
+ * — the "one call does both" instant-push API (see
124
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md, question 1). Returns
125
+ * only after the write completes; the push itself is fire-and-forget (see
126
+ * `pushOne`), so this resolves at the same time a plain `db.insert(...)`
127
+ * would.
128
+ *
129
+ * Created once per mapped table, mirroring `fromDrizzle`'s own convention —
130
+ * no per-call table-to-entity lookup, so a table two entities share (e.g.
131
+ * `user` for both `user` and `organizationOwner`) is never ambiguous:
132
+ * ```ts
133
+ * const insertUser = insertAndPush(clivly, db, schema.user, { entity: "user" });
134
+ * // in the signup handler:
135
+ * const created = await insertUser(newUserValues, c.executionCtx);
136
+ * ```
137
+ *
138
+ * `waitUntil`, when provided (Hono exposes it as `c.executionCtx.waitUntil`),
139
+ * extends the request's lifetime past the response so the push survives on
140
+ * Cloudflare Workers and similar runtimes, which otherwise kill background
141
+ * work the instant a response is sent. Omit it on a long-lived process where
142
+ * nothing tears the request down early.
143
+ *
144
+ * Table property keys are translated to real database column names before
145
+ * the push — the same translation `fromDrizzle` performs on `fetchPage` rows
146
+ * — because a raw `.returning()` result is keyed by Drizzle property name
147
+ * (`fullName`), and every consumer downstream of a push (the fieldMap, the
148
+ * cloud's ingest) addresses columns by their database name (`full_name`).
149
+ * Skipping this would silently push empty fields with no error anywhere.
150
+ */
151
+ /**
152
+ * Translate a Drizzle-property-keyed row (e.g. a `.returning()` result, or a
153
+ * plain unprojected `select()`) to real database column names — the same
154
+ * translation `fromDrizzle` performs on `fetchPage` rows. Every consumer
155
+ * downstream of a push (the fieldMap, the cloud's ingest) addresses columns
156
+ * by their database name (`full_name`), not the property name (`fullName`);
157
+ * skipping this would silently push empty fields with no error anywhere.
158
+ *
159
+ * Exported so a host can build the same translated row from a table it read
160
+ * some other way — e.g. re-querying a row inside an auth library's own
161
+ * lifecycle hook, where there is no `insertAndPush` call site to wrap
162
+ * because the library performs the insert internally.
163
+ */
164
+ export declare function toColumnKeyedRow(table: Table, row: Record<string, unknown>): Record<string, unknown>;
165
+ export declare function insertAndPush(clivly: PushOneCapable, db: unknown, table: Table, opts: {
166
+ entity: string;
167
+ }): (values: Record<string, unknown>, waitUntil?: (promise: Promise<unknown>) => void) => Promise<Record<string, unknown>>;
118
168
  /**
119
169
  * Build a `NamespaceIntrospector` from any Drizzle client that exposes
120
170
  * `.execute()`. Reads `information_schema` in the connection's default schema
package/dist/drizzle.mjs CHANGED
@@ -162,6 +162,69 @@ function fromDrizzle(db, table, opts) {
162
162
  return source;
163
163
  }
164
164
  /**
165
+ * Insert a row into a mapped Drizzle table and push it to Clivly immediately
166
+ * — the "one call does both" instant-push API (see
167
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md, question 1). Returns
168
+ * only after the write completes; the push itself is fire-and-forget (see
169
+ * `pushOne`), so this resolves at the same time a plain `db.insert(...)`
170
+ * would.
171
+ *
172
+ * Created once per mapped table, mirroring `fromDrizzle`'s own convention —
173
+ * no per-call table-to-entity lookup, so a table two entities share (e.g.
174
+ * `user` for both `user` and `organizationOwner`) is never ambiguous:
175
+ * ```ts
176
+ * const insertUser = insertAndPush(clivly, db, schema.user, { entity: "user" });
177
+ * // in the signup handler:
178
+ * const created = await insertUser(newUserValues, c.executionCtx);
179
+ * ```
180
+ *
181
+ * `waitUntil`, when provided (Hono exposes it as `c.executionCtx.waitUntil`),
182
+ * extends the request's lifetime past the response so the push survives on
183
+ * Cloudflare Workers and similar runtimes, which otherwise kill background
184
+ * work the instant a response is sent. Omit it on a long-lived process where
185
+ * nothing tears the request down early.
186
+ *
187
+ * Table property keys are translated to real database column names before
188
+ * the push — the same translation `fromDrizzle` performs on `fetchPage` rows
189
+ * — because a raw `.returning()` result is keyed by Drizzle property name
190
+ * (`fullName`), and every consumer downstream of a push (the fieldMap, the
191
+ * cloud's ingest) addresses columns by their database name (`full_name`).
192
+ * Skipping this would silently push empty fields with no error anywhere.
193
+ */
194
+ /**
195
+ * Translate a Drizzle-property-keyed row (e.g. a `.returning()` result, or a
196
+ * plain unprojected `select()`) to real database column names — the same
197
+ * translation `fromDrizzle` performs on `fetchPage` rows. Every consumer
198
+ * downstream of a push (the fieldMap, the cloud's ingest) addresses columns
199
+ * by their database name (`full_name`), not the property name (`fullName`);
200
+ * skipping this would silently push empty fields with no error anywhere.
201
+ *
202
+ * Exported so a host can build the same translated row from a table it read
203
+ * some other way — e.g. re-querying a row inside an auth library's own
204
+ * lifecycle hook, where there is no `insertAndPush` call site to wrap
205
+ * because the library performs the insert internally.
206
+ */
207
+ function toColumnKeyedRow(table, row) {
208
+ const columns = getTableColumns(table);
209
+ const columnNameByPropertyKey = /* @__PURE__ */ new Map();
210
+ for (const [propertyKey, column] of Object.entries(columns)) columnNameByPropertyKey.set(propertyKey, column.name);
211
+ const out = {};
212
+ for (const [propertyKey, value] of Object.entries(row)) out[columnNameByPropertyKey.get(propertyKey) ?? propertyKey] = value;
213
+ return out;
214
+ }
215
+ function insertAndPush(clivly, db, table, opts) {
216
+ const insertableDb = db;
217
+ return async (values, waitUntil) => {
218
+ const [created] = await insertableDb.insert(table).values(values).returning();
219
+ if (!created) throw new Error(`insertAndPush: insert into "${getTableName(table)}" returned no row.`);
220
+ const row = toColumnKeyedRow(table, created);
221
+ const pushed = clivly.pushOne(opts.entity, row);
222
+ if (waitUntil) waitUntil(pushed);
223
+ else await pushed;
224
+ return created;
225
+ };
226
+ }
227
+ /**
165
228
  * Build a `NamespaceIntrospector` from any Drizzle client that exposes
166
229
  * `.execute()`. Reads `information_schema` in the connection's default schema
167
230
  * (`current_schema()`), so it reports exactly the tables the app can reach.
@@ -191,4 +254,4 @@ function drizzleIntrospector(db) {
191
254
  };
192
255
  }
193
256
  //#endregion
194
- export { buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle, fromDrizzleProjection };
257
+ export { buildUniqueConstraints, discoverFromDrizzle, drizzleIntrospector, fromDrizzle, fromDrizzleProjection, insertAndPush, toColumnKeyedRow };
package/dist/index.cjs CHANGED
@@ -701,6 +701,20 @@ function detectHostEnvironment(env, configured) {
701
701
  return fromNodeEnv(env.NODE_ENV?.trim());
702
702
  }
703
703
  //#endregion
704
+ //#region src/instant-push-support.ts
705
+ /**
706
+ * The fast rollback switch for instant push (see
707
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md, question 5).
708
+ *
709
+ * A host-side env var, not a Clivly-Cloud-side toggle: it must keep working
710
+ * even when Clivly Cloud is the thing misbehaving (e.g. its ingest endpoint
711
+ * is what instant push is flooding), and it must take effect without a
712
+ * redeploy — just an env var change and the next cold start.
713
+ */
714
+ function isInstantPushDisabled(env) {
715
+ return env.CLIVLY_INSTANT_PUSH_DISABLED === "true";
716
+ }
717
+ //#endregion
704
718
  //#region src/presence-machine.ts
705
719
  const PRESENCE_BACKOFF_BASE_MS = 1e3;
706
720
  const PRESENCE_BACKOFF_MAX_MS = 3e4;
@@ -1379,6 +1393,51 @@ async function fullSyncSource(args) {
1379
1393
  };
1380
1394
  }
1381
1395
  //#endregion
1396
+ //#region src/trigger-method.ts
1397
+ /** Host-side override, mirroring `CLIVLY_INSTANT_PUSH_DISABLED`. */
1398
+ const REQUIRE_POST_ENV_VAR = "CLIVLY_TRIGGER_REQUIRE_POST";
1399
+ const NON_POST_DEPRECATION_WARNING = `Clivly: this sync trigger was called with GET. It still ran the sync, but a future release will refuse any method except POST with a 405. Change the caller — your cron job or uptime check — to POST. To adopt the new behaviour now, set ${REQUIRE_POST_ENV_VAR}=true.`;
1400
+ const METHOD_NOT_ALLOWED = 405;
1401
+ const NO_CONTENT = 204;
1402
+ const OK = 200;
1403
+ /**
1404
+ * Resolve the phase. Only the exact strings `"true"` and `"false"` count —
1405
+ * anything else (unset, empty, `"1"`, `"TRUE"`) falls through to the default
1406
+ * rather than guessing, so a typo can never silently disable a guard.
1407
+ */
1408
+ function enforcementEnabled(input) {
1409
+ const override = input.env[REQUIRE_POST_ENV_VAR];
1410
+ if (override === "true") return true;
1411
+ if (override === "false") return false;
1412
+ return input.enforceDefault;
1413
+ }
1414
+ function resolveTriggerMethod(input) {
1415
+ const method = input.method.toUpperCase();
1416
+ if (method === "POST") return {
1417
+ runSync: true,
1418
+ status: OK
1419
+ };
1420
+ if (method === "HEAD") return {
1421
+ runSync: false,
1422
+ status: NO_CONTENT
1423
+ };
1424
+ const refuse = {
1425
+ body: {
1426
+ error: `Method ${method} is not allowed on the Clivly sync trigger. Use POST.`,
1427
+ ok: false
1428
+ },
1429
+ runSync: false,
1430
+ status: METHOD_NOT_ALLOWED
1431
+ };
1432
+ if (method !== "GET") return refuse;
1433
+ if (enforcementEnabled(input)) return refuse;
1434
+ return {
1435
+ runSync: true,
1436
+ status: OK,
1437
+ warn: NON_POST_DEPRECATION_WARNING
1438
+ };
1439
+ }
1440
+ //#endregion
1382
1441
  //#region src/trigger-url.ts
1383
1442
  const SCHEME_REGEX = /^https?:\/\//;
1384
1443
  const INVALID_HOST_CHARS_REGEX = /[/\\@?#\s]/;
@@ -1570,7 +1629,7 @@ function buildSchema(config) {
1570
1629
  });
1571
1630
  }
1572
1631
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1573
- function createMemoryCursorStore() {
1632
+ function memoryCursorStore() {
1574
1633
  const cursors = /* @__PURE__ */ new Map();
1575
1634
  return {
1576
1635
  get: (entity) => cursors.get(entity) ?? null,
@@ -1579,6 +1638,56 @@ function createMemoryCursorStore() {
1579
1638
  }
1580
1639
  };
1581
1640
  }
1641
+ /** Default key prefix for {@link kvCursorStore}. */
1642
+ const CURSOR_KEY_PREFIX = "clivly:cursor:";
1643
+ /**
1644
+ * A persistent `CursorStore` backed by any key-value store.
1645
+ *
1646
+ * Serverless runtimes discard memory between invocations, so without a
1647
+ * persisted cursor every `delta` sync starts from `null` and re-reads the
1648
+ * whole source table. That is invisible — the run succeeds — and bills like a
1649
+ * full sync forever.
1650
+ *
1651
+ * This exists because the hand-written version has a trap in it:
1652
+ * `SyncCursor.time` is a `Date`, which a JSON round-trip turns into a string,
1653
+ * and `fromDrizzle` then calls `since.time.toISOString()` on it. The cursor is
1654
+ * stored as an ISO string and parsed back to a `Date` here so no caller has to
1655
+ * remember that.
1656
+ *
1657
+ * ```ts
1658
+ * cursorStore: kvCursorStore(env.CLIVLY_CURSORS) // Workers KV binding
1659
+ * ```
1660
+ *
1661
+ * A malformed or partial stored value resolves to `null` rather than throwing:
1662
+ * the worst case is one full re-read, where throwing would wedge sync entirely.
1663
+ */
1664
+ function kvCursorStore(kv, options) {
1665
+ const prefix = options?.prefix ?? "clivly:cursor:";
1666
+ const keyFor = (entity) => `${prefix}${entity}`;
1667
+ return {
1668
+ get: async (entity) => {
1669
+ const raw = await kv.get(keyFor(entity));
1670
+ if (!raw) return null;
1671
+ try {
1672
+ const parsed = JSON.parse(raw);
1673
+ if (typeof parsed.id !== "string" || typeof parsed.time !== "string") return null;
1674
+ const time = new Date(parsed.time);
1675
+ return Number.isNaN(time.getTime()) ? null : {
1676
+ id: parsed.id,
1677
+ time
1678
+ };
1679
+ } catch {
1680
+ return null;
1681
+ }
1682
+ },
1683
+ set: async (entity, cursor) => {
1684
+ await kv.put(keyFor(entity), cursor === null ? "" : JSON.stringify({
1685
+ id: cursor.id,
1686
+ time: cursor.time.toISOString()
1687
+ }));
1688
+ }
1689
+ };
1690
+ }
1582
1691
  function isLikelyServerless() {
1583
1692
  const runtime = globalThis;
1584
1693
  if (runtime.navigator?.userAgent === "Cloudflare-Workers") return true;
@@ -1808,9 +1917,11 @@ function createClivlySDK(config) {
1808
1917
  let presenceClient = null;
1809
1918
  let httpHeartbeatActive = true;
1810
1919
  const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
1811
- const cursorStore = config.cursorStore ?? createMemoryCursorStore();
1920
+ if (serverless && !config.cursorStore) console.warn("Clivly: no `cursorStore` is configured and this looks like a serverless/edge runtime, where memory does not survive between invocations. Every `delta` sync will start from scratch and re-read your entire source tables — a full sync, on every run, billed as such. Pass a persistent store: `cursorStore: kvCursorStore(env.MY_KV)` (exported from `@clivly/sdk`). See https://clivly.com/docs/concepts/sync#cursor-stores");
1921
+ const cursorStore = config.cursorStore ?? memoryCursorStore();
1812
1922
  const fetcher = createFetcher(config.retry);
1813
1923
  let warnedUnsignedTrigger = false;
1924
+ let warnedNonPostTrigger = false;
1814
1925
  const remoteProjections = createRemoteProjectionFetcher({
1815
1926
  apiKey: config.apiKey,
1816
1927
  apiUrl,
@@ -1972,6 +2083,28 @@ function createClivlySDK(config) {
1972
2083
  lastPushFailure = null;
1973
2084
  if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1974
2085
  };
2086
+ const pushOne = async (entityKey, row) => {
2087
+ if (config.sync?.instant !== true) return;
2088
+ if (isInstantPushDisabled(config.env ?? process.env)) return;
2089
+ const resolved = resolveEntitySource(config, entityKey);
2090
+ if (!resolved) {
2091
+ console.warn(`Clivly instant push: no source is configured for entity "${entityKey}" — this row was not pushed. It will still sync on the next delta run once \`entities\` declares it.`);
2092
+ return;
2093
+ }
2094
+ const path = CONCEPT_PATHS[resolved.expectedKey];
2095
+ if (!path) return;
2096
+ const push = createPush();
2097
+ try {
2098
+ if (!await push(path, {
2099
+ source: pushSourceOf(resolved.source),
2100
+ rows: [row],
2101
+ mode: "delta",
2102
+ ...resolved.expectedKey === "custom" ? { objectType: resolved.source.objectType } : {}
2103
+ })) console.warn(`Clivly instant push failed for "${entityKey}". This row will still sync on the next delta run.`);
2104
+ } catch (error) {
2105
+ console.warn(`Clivly instant push failed for "${entityKey}": ${error instanceof Error ? error.message : String(error)}. This row will still sync on the next delta run.`);
2106
+ }
2107
+ };
1975
2108
  const describeSyncTrigger = () => ({
1976
2109
  configured: config.syncTrigger?.url,
1977
2110
  resolved: resolveTriggerUrl({
@@ -2214,8 +2347,45 @@ function createClivlySDK(config) {
2214
2347
  return {};
2215
2348
  }
2216
2349
  };
2350
+ /**
2351
+ * The response to send instead of running a sync, or null to proceed.
2352
+ *
2353
+ * Split out of the handler so the method decision reads as one thing rather
2354
+ * than three branches ahead of the request plumbing — the same shape as
2355
+ * `triggerRequestAllowed` below.
2356
+ */
2357
+ const refuseDisallowedMethod = (req) => {
2358
+ const decision = resolveTriggerMethod({
2359
+ enforceDefault: false,
2360
+ env: config.env ?? process.env,
2361
+ method: req.method
2362
+ });
2363
+ if (decision.warn && !warnedNonPostTrigger) {
2364
+ warnedNonPostTrigger = true;
2365
+ console.warn(decision.warn);
2366
+ }
2367
+ if (decision.runSync) return null;
2368
+ return decision.body ? jsonResponse(decision.body, decision.status) : new Response(null, { status: decision.status });
2369
+ };
2370
+ /**
2371
+ * Serve a preview request, or null when this payload is a real sync.
2372
+ *
2373
+ * Preview (T10) is a signed, ZERO-WRITE dry run: it returns ≤5 RAW source
2374
+ * rows, slimmed to only the columns the draft maps or filters on and coerced
2375
+ * to JSON scalars, which the cloud then projects through the same path as a
2376
+ * real sync. It never syncs, never pushes, never writes a cursor and never
2377
+ * ingests. Callers keep it inside their `try` so a malformed body or a
2378
+ * throwing `fetchPage` degrades to the same graceful 500 as the sync paths.
2379
+ */
2380
+ const handleIfPreview = async (payload) => {
2381
+ if (payload.preview) return await handlePreviewRequest(config, payload.preview);
2382
+ if (payload.previewProjection) return await handleProjectionPreviewRequest(config, payload.previewProjection);
2383
+ return null;
2384
+ };
2217
2385
  const createClivlyHandler = () => async (req) => {
2218
2386
  observedUrl = observedTriggerUrl(req) ?? observedUrl;
2387
+ const refusal = refuseDisallowedMethod(req);
2388
+ if (refusal) return refusal;
2219
2389
  const rawBody = await req.text().catch(() => "");
2220
2390
  if (!await triggerRequestAllowed(req, rawBody)) return jsonResponse({
2221
2391
  ok: false,
@@ -2225,8 +2395,8 @@ function createClivlySDK(config) {
2225
2395
  const runId = payload.runId ?? null;
2226
2396
  const startedAt = Date.now();
2227
2397
  try {
2228
- if (payload.preview) return await handlePreviewRequest(config, payload.preview);
2229
- if (payload.previewProjection) return await handleProjectionPreviewRequest(config, payload.previewProjection);
2398
+ const previewResponse = await handleIfPreview(payload);
2399
+ if (previewResponse) return previewResponse;
2230
2400
  if (payload.sample) await runSampleSync(payload.sample.entityKey, payload.runId);
2231
2401
  else await runScheduled({
2232
2402
  mode: payload.mode,
@@ -2327,10 +2497,12 @@ function createClivlySDK(config) {
2327
2497
  runScheduled,
2328
2498
  createClivlyHandler,
2329
2499
  sync: runSync,
2330
- createAuthVerifyHandler
2500
+ createAuthVerifyHandler,
2501
+ pushOne
2331
2502
  };
2332
2503
  }
2333
2504
  //#endregion
2505
+ exports.CURSOR_KEY_PREFIX = CURSOR_KEY_PREFIX;
2334
2506
  exports.ClivlyAuthError = ClivlyAuthError;
2335
2507
  exports.ClivlyError = ClivlyError;
2336
2508
  exports.ClivlyNetworkError = ClivlyNetworkError;
@@ -2350,6 +2522,8 @@ Object.defineProperty(exports, "defineClivlyConfig", {
2350
2522
  }
2351
2523
  });
2352
2524
  exports.isPlaceholderUrl = isPlaceholderUrl;
2525
+ exports.kvCursorStore = kvCursorStore;
2526
+ exports.memoryCursorStore = memoryCursorStore;
2353
2527
  Object.defineProperty(exports, "validateEntitiesConfig", {
2354
2528
  enumerable: true,
2355
2529
  get: function() {
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-CvhxV-IX.cjs";
1
+ import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-DK8mqmkV.cjs";
2
2
  import { createChatSessionHandler } from "./chat-session.cjs";
3
3
  import { ClivlyEntitiesConfig, ConfigValidationError, ConfigValidationResult, CustomObjectDeclaration, DiscoveredSchemaTable, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig } from "@clivly/core/entity-config";
4
4
  //#region src/trigger-url.d.ts
@@ -26,6 +26,38 @@ export declare class ClivlyRateLimitError extends ClivlyError {}
26
26
  export declare class ClivlyNetworkError extends ClivlyError {}
27
27
  //#endregion
28
28
  //#region src/index.d.ts
29
+ export declare function memoryCursorStore(): CursorStore;
30
+ /** The shape every key-value client shares: Workers KV, Upstash, node-redis. */
31
+ export interface CursorKV {
32
+ get(key: string): Promise<string | null> | string | null;
33
+ put(key: string, value: string): Promise<unknown> | unknown;
34
+ }
35
+ /** Default key prefix for {@link kvCursorStore}. */
36
+ export declare const CURSOR_KEY_PREFIX = "clivly:cursor:";
37
+ /**
38
+ * A persistent `CursorStore` backed by any key-value store.
39
+ *
40
+ * Serverless runtimes discard memory between invocations, so without a
41
+ * persisted cursor every `delta` sync starts from `null` and re-reads the
42
+ * whole source table. That is invisible — the run succeeds — and bills like a
43
+ * full sync forever.
44
+ *
45
+ * This exists because the hand-written version has a trap in it:
46
+ * `SyncCursor.time` is a `Date`, which a JSON round-trip turns into a string,
47
+ * and `fromDrizzle` then calls `since.time.toISOString()` on it. The cursor is
48
+ * stored as an ISO string and parsed back to a `Date` here so no caller has to
49
+ * remember that.
50
+ *
51
+ * ```ts
52
+ * cursorStore: kvCursorStore(env.CLIVLY_CURSORS) // Workers KV binding
53
+ * ```
54
+ *
55
+ * A malformed or partial stored value resolves to `null` rather than throwing:
56
+ * the worst case is one full re-read, where throwing would wedge sync entirely.
57
+ */
58
+ export declare function kvCursorStore(kv: CursorKV, options?: {
59
+ prefix?: string;
60
+ }): CursorStore;
29
61
  /**
30
62
  * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
31
63
  * and the auth-verify handler — connects a developer's app to a running Clivly
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-CvhxV-IX.mjs";
1
+ import { a as ClivlySDK, c as CreateChatSessionHandlerOptions, d as DoctorCheck, f as DoctorReport, g as RemoteProjectionsDescription, h as EnsuredObjectTypeResult, i as ClivlyConnectResult, l as CursorStore, m as EnsuredMappingResult, n as ChatWidgetVisitor, o as ClivlySDKConfig, p as EnsureSchemaResult, r as ClivlyConnectReason, s as ClivlyUser, t as ChatSessionRequestBody, u as DiscoveredTable, v as SyncTriggerDescription } from "./types-DK8mqmkV.mjs";
2
2
  import { createChatSessionHandler } from "./chat-session.mjs";
3
3
  import { ClivlyEntitiesConfig, ConfigValidationError, ConfigValidationResult, CustomObjectDeclaration, DiscoveredSchemaTable, customObjectDeclarations, defineClivlyConfig, validateEntitiesConfig } from "@clivly/core/entity-config";
4
4
  //#region src/trigger-url.d.ts
@@ -26,6 +26,38 @@ export declare class ClivlyRateLimitError extends ClivlyError {}
26
26
  export declare class ClivlyNetworkError extends ClivlyError {}
27
27
  //#endregion
28
28
  //#region src/index.d.ts
29
+ export declare function memoryCursorStore(): CursorStore;
30
+ /** The shape every key-value client shares: Workers KV, Upstash, node-redis. */
31
+ export interface CursorKV {
32
+ get(key: string): Promise<string | null> | string | null;
33
+ put(key: string, value: string): Promise<unknown> | unknown;
34
+ }
35
+ /** Default key prefix for {@link kvCursorStore}. */
36
+ export declare const CURSOR_KEY_PREFIX = "clivly:cursor:";
37
+ /**
38
+ * A persistent `CursorStore` backed by any key-value store.
39
+ *
40
+ * Serverless runtimes discard memory between invocations, so without a
41
+ * persisted cursor every `delta` sync starts from `null` and re-reads the
42
+ * whole source table. That is invisible — the run succeeds — and bills like a
43
+ * full sync forever.
44
+ *
45
+ * This exists because the hand-written version has a trap in it:
46
+ * `SyncCursor.time` is a `Date`, which a JSON round-trip turns into a string,
47
+ * and `fromDrizzle` then calls `since.time.toISOString()` on it. The cursor is
48
+ * stored as an ISO string and parsed back to a `Date` here so no caller has to
49
+ * remember that.
50
+ *
51
+ * ```ts
52
+ * cursorStore: kvCursorStore(env.CLIVLY_CURSORS) // Workers KV binding
53
+ * ```
54
+ *
55
+ * A malformed or partial stored value resolves to `null` rather than throwing:
56
+ * the worst case is one full re-read, where throwing would wedge sync entirely.
57
+ */
58
+ export declare function kvCursorStore(kv: CursorKV, options?: {
59
+ prefix?: string;
60
+ }): CursorStore;
29
61
  /**
30
62
  * Create the Clivly SDK. The implemented surface — heartbeat/discovery reporting
31
63
  * and the auth-verify handler — connects a developer's app to a running Clivly
package/dist/index.mjs CHANGED
@@ -700,6 +700,20 @@ function detectHostEnvironment(env, configured) {
700
700
  return fromNodeEnv(env.NODE_ENV?.trim());
701
701
  }
702
702
  //#endregion
703
+ //#region src/instant-push-support.ts
704
+ /**
705
+ * The fast rollback switch for instant push (see
706
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md, question 5).
707
+ *
708
+ * A host-side env var, not a Clivly-Cloud-side toggle: it must keep working
709
+ * even when Clivly Cloud is the thing misbehaving (e.g. its ingest endpoint
710
+ * is what instant push is flooding), and it must take effect without a
711
+ * redeploy — just an env var change and the next cold start.
712
+ */
713
+ function isInstantPushDisabled(env) {
714
+ return env.CLIVLY_INSTANT_PUSH_DISABLED === "true";
715
+ }
716
+ //#endregion
703
717
  //#region src/presence-machine.ts
704
718
  const PRESENCE_BACKOFF_BASE_MS = 1e3;
705
719
  const PRESENCE_BACKOFF_MAX_MS = 3e4;
@@ -1378,6 +1392,51 @@ async function fullSyncSource(args) {
1378
1392
  };
1379
1393
  }
1380
1394
  //#endregion
1395
+ //#region src/trigger-method.ts
1396
+ /** Host-side override, mirroring `CLIVLY_INSTANT_PUSH_DISABLED`. */
1397
+ const REQUIRE_POST_ENV_VAR = "CLIVLY_TRIGGER_REQUIRE_POST";
1398
+ const NON_POST_DEPRECATION_WARNING = `Clivly: this sync trigger was called with GET. It still ran the sync, but a future release will refuse any method except POST with a 405. Change the caller — your cron job or uptime check — to POST. To adopt the new behaviour now, set ${REQUIRE_POST_ENV_VAR}=true.`;
1399
+ const METHOD_NOT_ALLOWED = 405;
1400
+ const NO_CONTENT = 204;
1401
+ const OK = 200;
1402
+ /**
1403
+ * Resolve the phase. Only the exact strings `"true"` and `"false"` count —
1404
+ * anything else (unset, empty, `"1"`, `"TRUE"`) falls through to the default
1405
+ * rather than guessing, so a typo can never silently disable a guard.
1406
+ */
1407
+ function enforcementEnabled(input) {
1408
+ const override = input.env[REQUIRE_POST_ENV_VAR];
1409
+ if (override === "true") return true;
1410
+ if (override === "false") return false;
1411
+ return input.enforceDefault;
1412
+ }
1413
+ function resolveTriggerMethod(input) {
1414
+ const method = input.method.toUpperCase();
1415
+ if (method === "POST") return {
1416
+ runSync: true,
1417
+ status: OK
1418
+ };
1419
+ if (method === "HEAD") return {
1420
+ runSync: false,
1421
+ status: NO_CONTENT
1422
+ };
1423
+ const refuse = {
1424
+ body: {
1425
+ error: `Method ${method} is not allowed on the Clivly sync trigger. Use POST.`,
1426
+ ok: false
1427
+ },
1428
+ runSync: false,
1429
+ status: METHOD_NOT_ALLOWED
1430
+ };
1431
+ if (method !== "GET") return refuse;
1432
+ if (enforcementEnabled(input)) return refuse;
1433
+ return {
1434
+ runSync: true,
1435
+ status: OK,
1436
+ warn: NON_POST_DEPRECATION_WARNING
1437
+ };
1438
+ }
1439
+ //#endregion
1381
1440
  //#region src/trigger-url.ts
1382
1441
  const SCHEME_REGEX = /^https?:\/\//;
1383
1442
  const INVALID_HOST_CHARS_REGEX = /[/\\@?#\s]/;
@@ -1569,7 +1628,7 @@ function buildSchema(config) {
1569
1628
  });
1570
1629
  }
1571
1630
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1572
- function createMemoryCursorStore() {
1631
+ function memoryCursorStore() {
1573
1632
  const cursors = /* @__PURE__ */ new Map();
1574
1633
  return {
1575
1634
  get: (entity) => cursors.get(entity) ?? null,
@@ -1578,6 +1637,56 @@ function createMemoryCursorStore() {
1578
1637
  }
1579
1638
  };
1580
1639
  }
1640
+ /** Default key prefix for {@link kvCursorStore}. */
1641
+ const CURSOR_KEY_PREFIX = "clivly:cursor:";
1642
+ /**
1643
+ * A persistent `CursorStore` backed by any key-value store.
1644
+ *
1645
+ * Serverless runtimes discard memory between invocations, so without a
1646
+ * persisted cursor every `delta` sync starts from `null` and re-reads the
1647
+ * whole source table. That is invisible — the run succeeds — and bills like a
1648
+ * full sync forever.
1649
+ *
1650
+ * This exists because the hand-written version has a trap in it:
1651
+ * `SyncCursor.time` is a `Date`, which a JSON round-trip turns into a string,
1652
+ * and `fromDrizzle` then calls `since.time.toISOString()` on it. The cursor is
1653
+ * stored as an ISO string and parsed back to a `Date` here so no caller has to
1654
+ * remember that.
1655
+ *
1656
+ * ```ts
1657
+ * cursorStore: kvCursorStore(env.CLIVLY_CURSORS) // Workers KV binding
1658
+ * ```
1659
+ *
1660
+ * A malformed or partial stored value resolves to `null` rather than throwing:
1661
+ * the worst case is one full re-read, where throwing would wedge sync entirely.
1662
+ */
1663
+ function kvCursorStore(kv, options) {
1664
+ const prefix = options?.prefix ?? "clivly:cursor:";
1665
+ const keyFor = (entity) => `${prefix}${entity}`;
1666
+ return {
1667
+ get: async (entity) => {
1668
+ const raw = await kv.get(keyFor(entity));
1669
+ if (!raw) return null;
1670
+ try {
1671
+ const parsed = JSON.parse(raw);
1672
+ if (typeof parsed.id !== "string" || typeof parsed.time !== "string") return null;
1673
+ const time = new Date(parsed.time);
1674
+ return Number.isNaN(time.getTime()) ? null : {
1675
+ id: parsed.id,
1676
+ time
1677
+ };
1678
+ } catch {
1679
+ return null;
1680
+ }
1681
+ },
1682
+ set: async (entity, cursor) => {
1683
+ await kv.put(keyFor(entity), cursor === null ? "" : JSON.stringify({
1684
+ id: cursor.id,
1685
+ time: cursor.time.toISOString()
1686
+ }));
1687
+ }
1688
+ };
1689
+ }
1581
1690
  function isLikelyServerless() {
1582
1691
  const runtime = globalThis;
1583
1692
  if (runtime.navigator?.userAgent === "Cloudflare-Workers") return true;
@@ -1807,9 +1916,11 @@ function createClivlySDK(config) {
1807
1916
  let presenceClient = null;
1808
1917
  let httpHeartbeatActive = true;
1809
1918
  const batchSize = config.sync?.batchSize ?? DEFAULT_BATCH_SIZE;
1810
- const cursorStore = config.cursorStore ?? createMemoryCursorStore();
1919
+ if (serverless && !config.cursorStore) console.warn("Clivly: no `cursorStore` is configured and this looks like a serverless/edge runtime, where memory does not survive between invocations. Every `delta` sync will start from scratch and re-read your entire source tables — a full sync, on every run, billed as such. Pass a persistent store: `cursorStore: kvCursorStore(env.MY_KV)` (exported from `@clivly/sdk`). See https://clivly.com/docs/concepts/sync#cursor-stores");
1920
+ const cursorStore = config.cursorStore ?? memoryCursorStore();
1811
1921
  const fetcher = createFetcher(config.retry);
1812
1922
  let warnedUnsignedTrigger = false;
1923
+ let warnedNonPostTrigger = false;
1813
1924
  const remoteProjections = createRemoteProjectionFetcher({
1814
1925
  apiKey: config.apiKey,
1815
1926
  apiUrl,
@@ -1971,6 +2082,28 @@ function createClivlySDK(config) {
1971
2082
  lastPushFailure = null;
1972
2083
  if (!await syncOne(resolved.source, resolved.path, "delta", resolved.objectType, runId, SAMPLE_BATCH_SIZE, SAMPLE_BATCH_SIZE, false)) throw pushFailureError(describePushFailure([resolved.source.entity]));
1973
2084
  };
2085
+ const pushOne = async (entityKey, row) => {
2086
+ if (config.sync?.instant !== true) return;
2087
+ if (isInstantPushDisabled(config.env ?? process.env)) return;
2088
+ const resolved = resolveEntitySource(config, entityKey);
2089
+ if (!resolved) {
2090
+ console.warn(`Clivly instant push: no source is configured for entity "${entityKey}" — this row was not pushed. It will still sync on the next delta run once \`entities\` declares it.`);
2091
+ return;
2092
+ }
2093
+ const path = CONCEPT_PATHS[resolved.expectedKey];
2094
+ if (!path) return;
2095
+ const push = createPush();
2096
+ try {
2097
+ if (!await push(path, {
2098
+ source: pushSourceOf(resolved.source),
2099
+ rows: [row],
2100
+ mode: "delta",
2101
+ ...resolved.expectedKey === "custom" ? { objectType: resolved.source.objectType } : {}
2102
+ })) console.warn(`Clivly instant push failed for "${entityKey}". This row will still sync on the next delta run.`);
2103
+ } catch (error) {
2104
+ console.warn(`Clivly instant push failed for "${entityKey}": ${error instanceof Error ? error.message : String(error)}. This row will still sync on the next delta run.`);
2105
+ }
2106
+ };
1974
2107
  const describeSyncTrigger = () => ({
1975
2108
  configured: config.syncTrigger?.url,
1976
2109
  resolved: resolveTriggerUrl({
@@ -2213,8 +2346,45 @@ function createClivlySDK(config) {
2213
2346
  return {};
2214
2347
  }
2215
2348
  };
2349
+ /**
2350
+ * The response to send instead of running a sync, or null to proceed.
2351
+ *
2352
+ * Split out of the handler so the method decision reads as one thing rather
2353
+ * than three branches ahead of the request plumbing — the same shape as
2354
+ * `triggerRequestAllowed` below.
2355
+ */
2356
+ const refuseDisallowedMethod = (req) => {
2357
+ const decision = resolveTriggerMethod({
2358
+ enforceDefault: false,
2359
+ env: config.env ?? process.env,
2360
+ method: req.method
2361
+ });
2362
+ if (decision.warn && !warnedNonPostTrigger) {
2363
+ warnedNonPostTrigger = true;
2364
+ console.warn(decision.warn);
2365
+ }
2366
+ if (decision.runSync) return null;
2367
+ return decision.body ? jsonResponse(decision.body, decision.status) : new Response(null, { status: decision.status });
2368
+ };
2369
+ /**
2370
+ * Serve a preview request, or null when this payload is a real sync.
2371
+ *
2372
+ * Preview (T10) is a signed, ZERO-WRITE dry run: it returns ≤5 RAW source
2373
+ * rows, slimmed to only the columns the draft maps or filters on and coerced
2374
+ * to JSON scalars, which the cloud then projects through the same path as a
2375
+ * real sync. It never syncs, never pushes, never writes a cursor and never
2376
+ * ingests. Callers keep it inside their `try` so a malformed body or a
2377
+ * throwing `fetchPage` degrades to the same graceful 500 as the sync paths.
2378
+ */
2379
+ const handleIfPreview = async (payload) => {
2380
+ if (payload.preview) return await handlePreviewRequest(config, payload.preview);
2381
+ if (payload.previewProjection) return await handleProjectionPreviewRequest(config, payload.previewProjection);
2382
+ return null;
2383
+ };
2216
2384
  const createClivlyHandler = () => async (req) => {
2217
2385
  observedUrl = observedTriggerUrl(req) ?? observedUrl;
2386
+ const refusal = refuseDisallowedMethod(req);
2387
+ if (refusal) return refusal;
2218
2388
  const rawBody = await req.text().catch(() => "");
2219
2389
  if (!await triggerRequestAllowed(req, rawBody)) return jsonResponse({
2220
2390
  ok: false,
@@ -2224,8 +2394,8 @@ function createClivlySDK(config) {
2224
2394
  const runId = payload.runId ?? null;
2225
2395
  const startedAt = Date.now();
2226
2396
  try {
2227
- if (payload.preview) return await handlePreviewRequest(config, payload.preview);
2228
- if (payload.previewProjection) return await handleProjectionPreviewRequest(config, payload.previewProjection);
2397
+ const previewResponse = await handleIfPreview(payload);
2398
+ if (previewResponse) return previewResponse;
2229
2399
  if (payload.sample) await runSampleSync(payload.sample.entityKey, payload.runId);
2230
2400
  else await runScheduled({
2231
2401
  mode: payload.mode,
@@ -2326,8 +2496,9 @@ function createClivlySDK(config) {
2326
2496
  runScheduled,
2327
2497
  createClivlyHandler,
2328
2498
  sync: runSync,
2329
- createAuthVerifyHandler
2499
+ createAuthVerifyHandler,
2500
+ pushOne
2330
2501
  };
2331
2502
  }
2332
2503
  //#endregion
2333
- export { ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, isPlaceholderUrl, validateEntitiesConfig };
2504
+ export { CURSOR_KEY_PREFIX, ClivlyAuthError, ClivlyError, ClivlyNetworkError, ClivlyRateLimitError, createChatSessionHandler, createClivlySDK, customObjectDeclarations, defineClivlyConfig, isPlaceholderUrl, kvCursorStore, memoryCursorStore, validateEntitiesConfig };
@@ -208,6 +208,23 @@ interface ClivlySDKConfig {
208
208
  */
209
209
  intervalMs?: number;
210
210
  batchSize?: number;
211
+ /**
212
+ * Opt into `pushOne()`/`insertAndPush()` pushing a single changed row to
213
+ * Clivly immediately, instead of waiting for the next delta sync. Off by
214
+ * default: turning it on changes a write's async failure surface (a push
215
+ * can fail independently of the write that triggered it, swallowed and
216
+ * left for the next delta sync to catch), which an existing install
217
+ * upgrading to a newer `clivly` should opt into deliberately rather than
218
+ * inherit silently. Requires a real scheduled delta sync running
219
+ * underneath as the safety net for anything the instant push misses (a
220
+ * failed push, a raw SQL write, a bulk import) — see
221
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md.
222
+ *
223
+ * Can also be disabled at runtime without a redeploy via the
224
+ * `CLIVLY_INSTANT_PUSH_DISABLED=true` environment variable — the fast
225
+ * rollback switch if this misbehaves in production.
226
+ */
227
+ instant?: boolean;
211
228
  };
212
229
  /**
213
230
  * The remote sync trigger: how Clivly Cloud asks this app to run a sync.
@@ -521,6 +538,31 @@ interface ClivlySDK {
521
538
  * own state.
522
539
  */
523
540
  ping(): Promise<ClivlyConnectResult>;
541
+ /**
542
+ * Push one row to Clivly immediately, instead of waiting for the next delta
543
+ * sync — the "instant" half of near-real-time sync (see
544
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md). No-ops unless
545
+ * `sync.instant` is set and `CLIVLY_INSTANT_PUSH_DISABLED` is unset.
546
+ *
547
+ * `entityKey` is a key of `config.entities.entities` (the same key
548
+ * `clivly.config.ts` declares the entity under), not a table name — an
549
+ * unresolvable key logs a warning and returns without throwing, the same
550
+ * fail-safe as every other call site here: a config mismatch never syncs
551
+ * the wrong thing, it just doesn't sync this row instantly (the safety-net
552
+ * delta sync still will).
553
+ *
554
+ * `row` must be keyed by the source table's real database column names
555
+ * (`full_name`, not `fullName`) — the same contract `fetchPage` rows
556
+ * satisfy. `insertAndPush` (from `clivly/drizzle`) performs this
557
+ * translation for you from a Drizzle table; calling `pushOne` directly with
558
+ * a raw Drizzle `.returning()` row will silently push empty fields.
559
+ *
560
+ * Never throws and never advances the delta cursor. Fire-and-forget by
561
+ * design: on Cloudflare Workers and similar runtimes, wrap the call in
562
+ * `ctx.waitUntil(...)` so it survives past the response, since nothing
563
+ * here blocks or awaits the caller's own write.
564
+ */
565
+ pushOne(entityKey: string, row: Record<string, unknown>): Promise<void>;
524
566
  /**
525
567
  * One-shot heartbeat + a single sync, with no keep-alive timers. The correct
526
568
  * entry point on serverless/edge (Workers, Vercel, Lambda), where `start()`'s
@@ -208,6 +208,23 @@ interface ClivlySDKConfig {
208
208
  */
209
209
  intervalMs?: number;
210
210
  batchSize?: number;
211
+ /**
212
+ * Opt into `pushOne()`/`insertAndPush()` pushing a single changed row to
213
+ * Clivly immediately, instead of waiting for the next delta sync. Off by
214
+ * default: turning it on changes a write's async failure surface (a push
215
+ * can fail independently of the write that triggered it, swallowed and
216
+ * left for the next delta sync to catch), which an existing install
217
+ * upgrading to a newer `clivly` should opt into deliberately rather than
218
+ * inherit silently. Requires a real scheduled delta sync running
219
+ * underneath as the safety net for anything the instant push misses (a
220
+ * failed push, a raw SQL write, a bulk import) — see
221
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md.
222
+ *
223
+ * Can also be disabled at runtime without a redeploy via the
224
+ * `CLIVLY_INSTANT_PUSH_DISABLED=true` environment variable — the fast
225
+ * rollback switch if this misbehaves in production.
226
+ */
227
+ instant?: boolean;
211
228
  };
212
229
  /**
213
230
  * The remote sync trigger: how Clivly Cloud asks this app to run a sync.
@@ -521,6 +538,31 @@ interface ClivlySDK {
521
538
  * own state.
522
539
  */
523
540
  ping(): Promise<ClivlyConnectResult>;
541
+ /**
542
+ * Push one row to Clivly immediately, instead of waiting for the next delta
543
+ * sync — the "instant" half of near-real-time sync (see
544
+ * docs/reviews/2026-09-19-near-real-time-sync-plan.md). No-ops unless
545
+ * `sync.instant` is set and `CLIVLY_INSTANT_PUSH_DISABLED` is unset.
546
+ *
547
+ * `entityKey` is a key of `config.entities.entities` (the same key
548
+ * `clivly.config.ts` declares the entity under), not a table name — an
549
+ * unresolvable key logs a warning and returns without throwing, the same
550
+ * fail-safe as every other call site here: a config mismatch never syncs
551
+ * the wrong thing, it just doesn't sync this row instantly (the safety-net
552
+ * delta sync still will).
553
+ *
554
+ * `row` must be keyed by the source table's real database column names
555
+ * (`full_name`, not `fullName`) — the same contract `fetchPage` rows
556
+ * satisfy. `insertAndPush` (from `clivly/drizzle`) performs this
557
+ * translation for you from a Drizzle table; calling `pushOne` directly with
558
+ * a raw Drizzle `.returning()` row will silently push empty fields.
559
+ *
560
+ * Never throws and never advances the delta cursor. Fire-and-forget by
561
+ * design: on Cloudflare Workers and similar runtimes, wrap the call in
562
+ * `ctx.waitUntil(...)` so it survives past the response, since nothing
563
+ * here blocks or awaits the caller's own write.
564
+ */
565
+ pushOne(entityKey: string, row: Record<string, unknown>): Promise<void>;
524
566
  /**
525
567
  * One-shot heartbeat + a single sync, with no keep-alive timers. The correct
526
568
  * entry point on serverless/edge (Workers, Vercel, Lambda), where `start()`'s
package/dist/vite.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK } from "./types-CvhxV-IX.cjs";
1
+ import { a as ClivlySDK } from "./types-DK8mqmkV.cjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region src/vite.d.ts
4
4
  export interface ClivlyViteOptions {
package/dist/vite.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ClivlySDK } from "./types-CvhxV-IX.mjs";
1
+ import { a as ClivlySDK } from "./types-DK8mqmkV.mjs";
2
2
  import { Plugin } from "vite";
3
3
  //#region src/vite.d.ts
4
4
  export interface ClivlyViteOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clivly/sdk",
3
- "version": "0.12.0",
3
+ "version": "0.14.1",
4
4
  "private": false,
5
5
  "description": "Clivly SDK — connect your app to Clivly Cloud (heartbeat, discovery, auth bridge).",
6
6
  "license": "MIT",
@@ -53,7 +53,7 @@
53
53
  "README.md"
54
54
  ],
55
55
  "dependencies": {
56
- "@clivly/core": "0.12.0"
56
+ "@clivly/core": "0.14.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@electric-sql/pglite": "^0.5.4",