@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 +94 -21
- package/dist/chat-session.d.cts +1 -1
- package/dist/chat-session.d.mts +1 -1
- package/dist/drizzle.cjs +65 -0
- package/dist/drizzle.d.cts +51 -1
- package/dist/drizzle.d.mts +51 -1
- package/dist/drizzle.mjs +64 -1
- package/dist/index.cjs +179 -5
- package/dist/index.d.cts +33 -1
- package/dist/index.d.mts +33 -1
- package/dist/index.mjs +177 -6
- package/dist/{types-CvhxV-IX.d.cts → types-DK8mqmkV.d.cts} +42 -0
- package/dist/{types-CvhxV-IX.d.mts → types-DK8mqmkV.d.mts} +42 -0
- package/dist/vite.d.cts +1 -1
- package/dist/vite.d.mts +1 -1
- package/package.json +2 -2
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(), //
|
|
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
|
|
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 **
|
|
376
|
-
re-
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
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 |
|
|
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
|
|
package/dist/chat-session.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as CreateChatSessionHandlerOptions } from "./types-
|
|
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
|
package/dist/chat-session.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as CreateChatSessionHandlerOptions } from "./types-
|
|
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;
|
package/dist/drizzle.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as SyncSource, y as NamespaceIntrospector } from "./types-
|
|
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
|
package/dist/drizzle.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as SyncSource, y as NamespaceIntrospector } from "./types-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2229
|
-
if (
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2228
|
-
if (
|
|
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
package/dist/vite.d.mts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clivly/sdk",
|
|
3
|
-
"version": "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.
|
|
56
|
+
"@clivly/core": "0.14.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@electric-sql/pglite": "^0.5.4",
|