@pylonsync/sync 0.3.49 → 0.3.51
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/package.json +1 -1
- package/src/index.ts +109 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -255,6 +255,46 @@ export class LocalStore {
|
|
|
255
255
|
return tempId;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Apply an optimistic insert with a caller-provided id.
|
|
260
|
+
*
|
|
261
|
+
* Used by `useMutation({ optimistic })`: the React hook generates a
|
|
262
|
+
* Pylon-shaped id (40-char hex via `generateId()`), threads it
|
|
263
|
+
* through the mutation args as `_optimisticId`, and the server
|
|
264
|
+
* function honors it on `ctx.db.insert("Entity", { id, ... })`.
|
|
265
|
+
* Because the optimistic ghost and the canonical row share the same
|
|
266
|
+
* `row_id`, the WS broadcast that follows the mutation lands as a
|
|
267
|
+
* field-level merge on top of the optimistic — no delete-then-replace
|
|
268
|
+
* flash, no temp-row swap.
|
|
269
|
+
*
|
|
270
|
+
* Different from `optimisticInsert` (above) which mints a `_pending_`
|
|
271
|
+
* id the server can't possibly know about. Use that for fire-and-
|
|
272
|
+
* forget UI affordances, and this one whenever the canonical insert
|
|
273
|
+
* needs to map back to the same row.
|
|
274
|
+
*/
|
|
275
|
+
optimisticInsertWithId(entity: string, id: string, data: Row): void {
|
|
276
|
+
if (!this.tables.has(entity)) {
|
|
277
|
+
this.tables.set(entity, new Map());
|
|
278
|
+
}
|
|
279
|
+
this.tables.get(entity)!.set(id, { ...data, id });
|
|
280
|
+
this.notify();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Roll back an optimistic insert without leaving a tombstone.
|
|
285
|
+
*
|
|
286
|
+
* Counterpart to `optimisticInsertWithId`. When a mutation rejects,
|
|
287
|
+
* we want the ghost row gone but we do NOT want a tombstone — a
|
|
288
|
+
* future legitimate insert with the same id (e.g. user retries the
|
|
289
|
+
* mutation, or a workflow eventually creates the row) must not be
|
|
290
|
+
* blocked. `optimisticDelete` records a MAX_SAFE_INTEGER tombstone
|
|
291
|
+
* which is the wrong semantic here; this is just a plain remove.
|
|
292
|
+
*/
|
|
293
|
+
rollbackOptimisticInsert(entity: string, id: string): void {
|
|
294
|
+
const removed = this.tables.get(entity)?.delete(id);
|
|
295
|
+
if (removed) this.notify();
|
|
296
|
+
}
|
|
297
|
+
|
|
258
298
|
/** Apply an optimistic update. */
|
|
259
299
|
optimisticUpdate(entity: string, id: string, data: Partial<Row>): void {
|
|
260
300
|
const table = this.tables.get(entity);
|
|
@@ -460,6 +500,37 @@ export interface SyncEngineConfig {
|
|
|
460
500
|
* Generate a stable client_id. Prefers a persisted id from `storage`
|
|
461
501
|
* (so a reload keeps the same identifier) and falls back to a fresh UUID.
|
|
462
502
|
*/
|
|
503
|
+
/**
|
|
504
|
+
* Generate a Pylon-shaped row id (40-char lowercase hex).
|
|
505
|
+
*
|
|
506
|
+
* Mirrors the runtime's `generate_id` shape: 32 hex of milliseconds
|
|
507
|
+
* since epoch (extended to nanos so it lex-sorts alongside server-
|
|
508
|
+
* generated ids) + 8 hex of a per-tab counter. Lex-sortable, monotonic
|
|
509
|
+
* within a tab, statistically unique across tabs (the timestamp
|
|
510
|
+
* disambiguates almost every cross-tab collision; the counter handles
|
|
511
|
+
* the rest within a single tick).
|
|
512
|
+
*
|
|
513
|
+
* Used by `useMutation({ optimistic })` to mint client-side ids that
|
|
514
|
+
* the runtime will accept verbatim — so the optimistic ghost and the
|
|
515
|
+
* canonical row share the same `row_id` and the WS broadcast is an
|
|
516
|
+
* idempotent merge instead of a delete-then-replace flash.
|
|
517
|
+
*
|
|
518
|
+
* Apps can call this directly when they need a stable id earlier than
|
|
519
|
+
* the mutation (e.g. to reference the row from another optimistic
|
|
520
|
+
* insert in the same gesture).
|
|
521
|
+
*/
|
|
522
|
+
let idCounter = 0;
|
|
523
|
+
export function generateId(): string {
|
|
524
|
+
// BigInt to dodge the 2^53 ceiling — `Date.now() * 1_000_000` busts
|
|
525
|
+
// Number.MAX_SAFE_INTEGER for any timestamp past 1973. Hex output is
|
|
526
|
+
// padded to 32 chars so it lex-sorts at width boundaries (a 39-char
|
|
527
|
+
// id sorts before a 40-char one even when the suffix is larger,
|
|
528
|
+
// which would corrupt cursor pagination).
|
|
529
|
+
const nanos = BigInt(Date.now()) * 1_000_000n;
|
|
530
|
+
const seq = idCounter++ >>> 0;
|
|
531
|
+
return nanos.toString(16).padStart(32, "0") + seq.toString(16).padStart(8, "0");
|
|
532
|
+
}
|
|
533
|
+
|
|
463
534
|
function generateClientId(storage: import("./storage").Storage): string {
|
|
464
535
|
const key = "pylon:client_id";
|
|
465
536
|
const existing = storage.get(key);
|
|
@@ -688,6 +759,7 @@ export class SyncEngine {
|
|
|
688
759
|
if (this.running) return;
|
|
689
760
|
this.running = true;
|
|
690
761
|
this.setConnectionStatus("connecting");
|
|
762
|
+
warnIfMisconfigured(this.config.baseUrl);
|
|
691
763
|
|
|
692
764
|
// Load persisted data if available.
|
|
693
765
|
const shouldPersist = this.config.persist !== false && typeof indexedDB !== "undefined";
|
|
@@ -1592,6 +1664,43 @@ export async function getServerData(
|
|
|
1592
1664
|
// Convenience factory
|
|
1593
1665
|
// ---------------------------------------------------------------------------
|
|
1594
1666
|
|
|
1667
|
+
/**
|
|
1668
|
+
* One-shot guard: detect the most common production misconfig — a
|
|
1669
|
+
* deployed app running on HTTPS with `baseUrl` still pointing at a
|
|
1670
|
+
* `localhost` API. Symptom in the wild: blank screen, "Failed to
|
|
1671
|
+
* fetch" in DevTools, browser blocking mixed-content WS upgrades.
|
|
1672
|
+
*
|
|
1673
|
+
* The check fires once per page load and is browser-only (server-
|
|
1674
|
+
* side renders see localhost as a legitimate target). It's a loud
|
|
1675
|
+
* `console.error` block — surfaces in DevTools, Vercel deploy logs,
|
|
1676
|
+
* and Sentry-style trackers — but doesn't throw, so existing apps
|
|
1677
|
+
* can't lock up on a misconfigured deploy.
|
|
1678
|
+
*/
|
|
1679
|
+
let warnedMisconfig = false;
|
|
1680
|
+
function warnIfMisconfigured(baseUrl: string): void {
|
|
1681
|
+
if (warnedMisconfig) return;
|
|
1682
|
+
if (typeof window === "undefined") return;
|
|
1683
|
+
const pageHttps = window.location?.protocol === "https:";
|
|
1684
|
+
const apiLocalhost =
|
|
1685
|
+
/^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)(:|\/|$)/.test(baseUrl);
|
|
1686
|
+
if (!pageHttps || !apiLocalhost) return;
|
|
1687
|
+
warnedMisconfig = true;
|
|
1688
|
+
// eslint-disable-next-line no-console
|
|
1689
|
+
console.error(
|
|
1690
|
+
"[pylon] Misconfigured deployment:\n" +
|
|
1691
|
+
" Page is on " + window.location.origin + " (https)\n" +
|
|
1692
|
+
" Pylon baseUrl is " + baseUrl + " (localhost)\n" +
|
|
1693
|
+
"\n" +
|
|
1694
|
+
"Likely cause: NEXT_PUBLIC_PYLON_URL (or your framework's equivalent)\n" +
|
|
1695
|
+
"is unset in this deployment. The client falls back to localhost,\n" +
|
|
1696
|
+
"and every API request fails with 'Failed to fetch'.\n" +
|
|
1697
|
+
"\n" +
|
|
1698
|
+
"Fix: set NEXT_PUBLIC_PYLON_URL=https://<your-pylon-host> in the\n" +
|
|
1699
|
+
"deployment env, then redeploy. If your dashboard proxies /api/*\n" +
|
|
1700
|
+
"to the backend same-origin, set it to '' (empty string) instead.",
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1595
1704
|
/**
|
|
1596
1705
|
* Create a sync engine connected to the pylon backend.
|
|
1597
1706
|
*
|