@pylonsync/sync 0.3.50 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +109 -0
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.50",
6
+ "version": "0.3.51",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
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
  *