@gemmein/sdk 0.4.1 → 0.4.3

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/dist/index.cjs CHANGED
@@ -439,11 +439,185 @@ class CollectionClient {
439
439
  query.set("search", options.search);
440
440
  if (options.expand && options.expand.length > 0)
441
441
  query.set("expand", options.expand.join(","));
442
+ if (options.since !== undefined)
443
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
442
444
  // NOT query.size — absent on Node <19.8 / older browsers, where
443
445
  // `undefined > 0` would silently drop every filter.
444
446
  const qs = query.toString();
445
447
  return this.request(qs ? `?${qs}` : "");
446
448
  }
449
+ /**
450
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
451
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
452
+ * exactly the same permission gate as list(). Nothing is pushed.
453
+ *
454
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
455
+ * // initial=true: REPLACE your state — records IS the full current
456
+ * // set, and replacing is what clears anything removed while you
457
+ * // weren't looking. initial=false: upsert records by id (a change
458
+ * // can arrive twice, never be missed); remove ids in `deleted`.
459
+ * });
460
+ * // later: w.stop()
461
+ *
462
+ * In a browser it sleeps while the tab is hidden and does a full resync on
463
+ * return (that resync is also how a record erased outright — not deleted
464
+ * by the app, erased — leaves the screen). On errors it backs off,
465
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
466
+ * page is the intended shape — share its result, don't stack watchers.
467
+ */
468
+ watch(onChange, options = {}) {
469
+ const asked = options.every;
470
+ const every = Math.max(5000, Math.min(300000, Number.isFinite(asked) ? asked : 10000));
471
+ const base = { where: options.where, search: options.search, limit: options.limit ?? 100 };
472
+ let stopped = false;
473
+ let timer;
474
+ let delayMs = every;
475
+ let watermark;
476
+ // One tick at a time. A visibility flip mid-tick sets resyncPending
477
+ // instead of racing a second loop into existence (each raced loop would
478
+ // have doubled the poll rate forever).
479
+ let inFlight = false;
480
+ let resyncPending = false;
481
+ const page = async (since) => {
482
+ const records = [];
483
+ const deleted = [];
484
+ let cursor;
485
+ let mark;
486
+ do {
487
+ const result = await this.list({ ...base, cursor, ...(since !== undefined ? { since } : {}) });
488
+ records.push(...result.records);
489
+ if (result.deleted)
490
+ deleted.push(...result.deleted);
491
+ cursor = result.hasMore ? result.cursor : undefined;
492
+ if (since === undefined) {
493
+ // Full sync: keep the FIRST page's watermark — anything written
494
+ // while later pages stream must redeliver on the next poll, not
495
+ // fall below an end-of-paging mark and vanish.
496
+ mark = mark ?? result.watermark;
497
+ }
498
+ else if (!result.hasMore) {
499
+ mark = result.watermark;
500
+ }
501
+ } while (cursor && !stopped);
502
+ return { records, deleted, mark };
503
+ };
504
+ const tick = async (resync) => {
505
+ if (stopped || inFlight)
506
+ return;
507
+ inFlight = true;
508
+ let delivery;
509
+ try {
510
+ const doResync = resync || resyncPending;
511
+ resyncPending = false;
512
+ const { records, deleted, mark } = await page(doResync ? undefined : watermark);
513
+ if (stopped) {
514
+ inFlight = false;
515
+ return;
516
+ }
517
+ if (mark !== undefined)
518
+ watermark = mark;
519
+ delayMs = every;
520
+ if (doResync || records.length > 0 || deleted.length > 0) {
521
+ delivery = { records, deleted, initial: doResync };
522
+ }
523
+ }
524
+ catch (err) {
525
+ if (stopped) {
526
+ inFlight = false;
527
+ return;
528
+ }
529
+ // An auth refusal is not transient: a signed-out or unentitled
530
+ // watcher polling forever writes a denied-audit row per attempt on
531
+ // the server (audit finding #8). Stop; a fresh watch() after
532
+ // sign-in starts clean.
533
+ if (err instanceof GemmeinError && (err.status === 401 || err.status === 403)) {
534
+ inFlight = false;
535
+ stopped = true;
536
+ if (timer !== undefined) {
537
+ clearTimeout(timer);
538
+ timer = undefined;
539
+ }
540
+ if (typeof document !== "undefined") {
541
+ document.removeEventListener("visibilitychange", onVisibility);
542
+ }
543
+ if (typeof console !== "undefined")
544
+ console.warn(`gemmein watch stopped: ${err.code} — start a new watch after signing in`);
545
+ return;
546
+ }
547
+ // Back off; a rate limit says exactly when to come back.
548
+ delayMs = Math.min(Math.max(delayMs * 2, every), 60000);
549
+ if (err instanceof GemmeinError && err.resetAt) {
550
+ const wait = new Date(err.resetAt).getTime() - Date.now();
551
+ if (Number.isFinite(wait) && wait > delayMs)
552
+ delayMs = Math.min(wait, 300000);
553
+ }
554
+ }
555
+ inFlight = false;
556
+ if (delivery) {
557
+ // The app's callback runs OUTSIDE the wire handling: its exceptions
558
+ // are its own bugs, never a reason to back off or mangle the loop.
559
+ try {
560
+ onChange(delivery);
561
+ }
562
+ catch { /* the app's error, not the wire's */ }
563
+ }
564
+ schedule();
565
+ };
566
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
567
+ const schedule = () => {
568
+ if (stopped || hidden() || timer !== undefined)
569
+ return;
570
+ timer = setTimeout(() => { timer = undefined; void tick(false); }, delayMs);
571
+ // In Node a live timer pins the process open; a watcher should never
572
+ // be the reason a script can't exit.
573
+ timer.unref?.();
574
+ };
575
+ const onVisibility = () => {
576
+ if (stopped)
577
+ return;
578
+ if (hidden()) {
579
+ if (timer !== undefined) {
580
+ clearTimeout(timer);
581
+ timer = undefined;
582
+ }
583
+ }
584
+ else {
585
+ // Waking resyncs in full — the cheap answer to "what did I miss?",
586
+ // including anything erased while the tab slept. If a tick is mid
587
+ // flight, flag it rather than racing a second loop.
588
+ if (timer !== undefined) {
589
+ clearTimeout(timer);
590
+ timer = undefined;
591
+ }
592
+ delayMs = every;
593
+ if (inFlight) {
594
+ resyncPending = true;
595
+ return;
596
+ }
597
+ void tick(true);
598
+ }
599
+ };
600
+ if (typeof document !== "undefined") {
601
+ document.addEventListener("visibilitychange", onVisibility);
602
+ }
603
+ // Born hidden: wait for the tab — the visibility handler runs the first
604
+ // sync when the user actually looks.
605
+ if (!hidden())
606
+ void tick(true);
607
+ else
608
+ resyncPending = true;
609
+ return {
610
+ stop: () => {
611
+ stopped = true;
612
+ if (timer !== undefined)
613
+ clearTimeout(timer);
614
+ timer = undefined;
615
+ if (typeof document !== "undefined") {
616
+ document.removeEventListener("visibilitychange", onVisibility);
617
+ }
618
+ },
619
+ };
620
+ }
447
621
  async get(id, options = {}) {
448
622
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
449
623
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -491,15 +665,24 @@ class CollectionClient {
491
665
  * // later, to render:
492
666
  * const { url } = await g.files.link(record.poster)
493
667
  *
668
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
669
+ * file. A document always downloads — it is served as an attachment and
670
+ * never opens inside your page — so link it with `{ intent: "download" }`.
671
+ * Pass `{ name }` so the download carries a real filename, and
672
+ * `{ contentType }` when the Blob has no type of its own.
673
+ *
494
674
  * There is deliberately no `url` here. A URL that outlives a refund is the
495
675
  * bug this replaced.
496
676
  */
497
677
  async upload(file, options) {
498
678
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
679
+ // A Blob built from bytes has type "" — `contentType` names it. The
680
+ // server proves the bytes either way.
681
+ const contentType = options?.contentType ?? file.type;
499
682
  // Step 1: Get presigned upload URL
500
683
  const presign = await this.request("/upload", {
501
684
  method: "POST",
502
- body: JSON.stringify({ name, size: file.size, contentType: file.type }),
685
+ body: JSON.stringify({ name, size: file.size, contentType }),
503
686
  });
504
687
  // Step 2: Upload directly to S3 via presigned POST
505
688
  const form = new FormData();
@@ -588,6 +771,45 @@ class GemmeinServer {
588
771
  }
589
772
  return response.json();
590
773
  }
774
+ /**
775
+ * W7.3 — tell one of YOUR OWN people that something happened, by email:
776
+ *
777
+ * await g.notify(order.ownerUserId, {
778
+ * subject: "Your order shipped",
779
+ * text: "Order #142 left the warehouse today.",
780
+ * key: "order-142-shipped", // optional: retries can't double-send
781
+ * });
782
+ *
783
+ * A person id, never an email address — the recipient must be a verified
784
+ * user of this app (their address comes from the server's own record).
785
+ * Plain text (an account-context footer is added). The email is branded
786
+ * as your app; the send appears in your dashboard Inbox, and a customer's
787
+ * reply lands there too once your domain's receiving is verified — the
788
+ * response's `replyRail` says which is true. This is for EVENTS, not
789
+ * campaigns: event-class sends
790
+ * are capped at about 5 per person per day (429 notify_capped, with
791
+ * resetAt). Pass `kind: "account"` for account activity — a new sign-in,
792
+ * an access change, a billing problem — which skips the per-person cap
793
+ * (a security notice must never lose to five order emails); misusing it
794
+ * for campaigns is visible in your own audit trail.
795
+ */
796
+ async notify(personId, input) {
797
+ const response = await fetch(new URL("/server/notify", this.apiUrl), {
798
+ method: "POST",
799
+ headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
800
+ body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
801
+ });
802
+ if (!response.ok) {
803
+ const body = await response.json().catch(() => ({ code: "request_failed", message: `Request failed: ${response.status}` }));
804
+ throw new GemmeinError({
805
+ status: response.status,
806
+ code: body.code ?? "request_failed",
807
+ message: body.message ?? `Request failed: ${response.status}`,
808
+ resetAt: typeof body.resetAt === "string" ? body.resetAt : undefined,
809
+ });
810
+ }
811
+ return response.json();
812
+ }
591
813
  }
592
814
  exports.GemmeinServer = GemmeinServer;
593
815
  class ServerCollectionClient {
@@ -613,6 +835,8 @@ class ServerCollectionClient {
613
835
  query.set("search", options.search);
614
836
  if (options.expand && options.expand.length > 0)
615
837
  query.set("expand", options.expand.join(","));
838
+ if (options.since !== undefined)
839
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
616
840
  // NOT query.size — absent on Node <19.8 / older browsers, where
617
841
  // `undefined > 0` would silently drop every filter.
618
842
  const qs = query.toString();
package/dist/index.d.cts CHANGED
@@ -65,6 +65,15 @@ export type ListResult<T extends Record<string, unknown> = Record<string, unknow
65
65
  records: GemmeinRecord<T>[];
66
66
  cursor?: string;
67
67
  hasMore: boolean;
68
+ /** W7.2: on a delta read (`since`), ids of records deleted after that
69
+ * instant — ids only, and only ones your rule scope admitted. */
70
+ deleted?: string[];
71
+ /** The instant to pass as the next `since`. On a plain list it rides
72
+ * every page (adopt the FIRST page's when you page a full sync); on a
73
+ * delta it rides only the final page. Deliberately lags the server clock
74
+ * ~2s, so a change can be delivered twice but never silently missed —
75
+ * apply records by id. */
76
+ watermark?: string;
68
77
  };
69
78
  export type ListOptions = {
70
79
  limit?: number;
@@ -78,6 +87,10 @@ export type ListOptions = {
78
87
  /** SHAPE-1: link fields to embed (up to 3) — each expanded record is only
79
88
  * what YOU could have read directly; unreadable/deleted targets are null. */
80
89
  expand?: string[];
90
+ /** W7.2: everything changed OR deleted after this instant, oldest change
91
+ * first. Pass the `watermark` from the previous answer. Incompatible with
92
+ * `sort` (delta order is fixed). */
93
+ since?: string | Date;
81
94
  };
82
95
  /**
83
96
  * Answer to "who is signed in right now?". `authenticated: false` simply
@@ -414,6 +427,37 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
414
427
  * an object, not a bare array.
415
428
  */
416
429
  list(options?: ListOptions): Promise<ListResult<T>>;
430
+ /**
431
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
432
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
433
+ * exactly the same permission gate as list(). Nothing is pushed.
434
+ *
435
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
436
+ * // initial=true: REPLACE your state — records IS the full current
437
+ * // set, and replacing is what clears anything removed while you
438
+ * // weren't looking. initial=false: upsert records by id (a change
439
+ * // can arrive twice, never be missed); remove ids in `deleted`.
440
+ * });
441
+ * // later: w.stop()
442
+ *
443
+ * In a browser it sleeps while the tab is hidden and does a full resync on
444
+ * return (that resync is also how a record erased outright — not deleted
445
+ * by the app, erased — leaves the screen). On errors it backs off,
446
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
447
+ * page is the intended shape — share its result, don't stack watchers.
448
+ */
449
+ watch(onChange: (delta: {
450
+ records: GemmeinRecord<T>[];
451
+ deleted: string[];
452
+ initial: boolean;
453
+ }) => void, options?: {
454
+ every?: number;
455
+ where?: Record<string, unknown>;
456
+ search?: string;
457
+ limit?: number;
458
+ }): {
459
+ stop(): void;
460
+ };
417
461
  get(id: string, options?: {
418
462
  expand?: string[];
419
463
  }): Promise<GemmeinRecord<T>>;
@@ -450,11 +494,18 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
450
494
  * // later, to render:
451
495
  * const { url } = await g.files.link(record.poster)
452
496
  *
497
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
498
+ * file. A document always downloads — it is served as an attachment and
499
+ * never opens inside your page — so link it with `{ intent: "download" }`.
500
+ * Pass `{ name }` so the download carries a real filename, and
501
+ * `{ contentType }` when the Blob has no type of its own.
502
+ *
453
503
  * There is deliberately no `url` here. A URL that outlives a refund is the
454
504
  * bug this replaced.
455
505
  */
456
506
  upload(file: Blob | File, options?: {
457
507
  name?: string;
508
+ contentType?: string;
458
509
  }): Promise<{
459
510
  id: string;
460
511
  ref: FileRef;
@@ -489,6 +540,41 @@ export declare class GemmeinServer {
489
540
  role: string;
490
541
  };
491
542
  }>;
543
+ /**
544
+ * W7.3 — tell one of YOUR OWN people that something happened, by email:
545
+ *
546
+ * await g.notify(order.ownerUserId, {
547
+ * subject: "Your order shipped",
548
+ * text: "Order #142 left the warehouse today.",
549
+ * key: "order-142-shipped", // optional: retries can't double-send
550
+ * });
551
+ *
552
+ * A person id, never an email address — the recipient must be a verified
553
+ * user of this app (their address comes from the server's own record).
554
+ * Plain text (an account-context footer is added). The email is branded
555
+ * as your app; the send appears in your dashboard Inbox, and a customer's
556
+ * reply lands there too once your domain's receiving is verified — the
557
+ * response's `replyRail` says which is true. This is for EVENTS, not
558
+ * campaigns: event-class sends
559
+ * are capped at about 5 per person per day (429 notify_capped, with
560
+ * resetAt). Pass `kind: "account"` for account activity — a new sign-in,
561
+ * an access change, a billing problem — which skips the per-person cap
562
+ * (a security notice must never lose to five order emails); misusing it
563
+ * for campaigns is visible in your own audit trail.
564
+ */
565
+ notify(personId: string, input: {
566
+ subject: string;
567
+ text: string;
568
+ kind?: "event" | "account";
569
+ key?: string;
570
+ }): Promise<{
571
+ sent: boolean;
572
+ deduped?: boolean;
573
+ id: string | null;
574
+ threadId: string | null;
575
+ replyRail?: boolean;
576
+ recorded?: boolean;
577
+ }>;
492
578
  }
493
579
  declare class ServerCollectionClient {
494
580
  private readonly apiUrl;
@@ -503,6 +589,7 @@ declare class ServerCollectionClient {
503
589
  cursor?: string;
504
590
  search?: string;
505
591
  expand?: string[];
592
+ since?: string | Date;
506
593
  }): Promise<unknown>;
507
594
  update(id: string, data: Record<string, unknown>): Promise<unknown>;
508
595
  private request;
package/dist/index.d.ts CHANGED
@@ -65,6 +65,15 @@ export type ListResult<T extends Record<string, unknown> = Record<string, unknow
65
65
  records: GemmeinRecord<T>[];
66
66
  cursor?: string;
67
67
  hasMore: boolean;
68
+ /** W7.2: on a delta read (`since`), ids of records deleted after that
69
+ * instant — ids only, and only ones your rule scope admitted. */
70
+ deleted?: string[];
71
+ /** The instant to pass as the next `since`. On a plain list it rides
72
+ * every page (adopt the FIRST page's when you page a full sync); on a
73
+ * delta it rides only the final page. Deliberately lags the server clock
74
+ * ~2s, so a change can be delivered twice but never silently missed —
75
+ * apply records by id. */
76
+ watermark?: string;
68
77
  };
69
78
  export type ListOptions = {
70
79
  limit?: number;
@@ -78,6 +87,10 @@ export type ListOptions = {
78
87
  /** SHAPE-1: link fields to embed (up to 3) — each expanded record is only
79
88
  * what YOU could have read directly; unreadable/deleted targets are null. */
80
89
  expand?: string[];
90
+ /** W7.2: everything changed OR deleted after this instant, oldest change
91
+ * first. Pass the `watermark` from the previous answer. Incompatible with
92
+ * `sort` (delta order is fixed). */
93
+ since?: string | Date;
81
94
  };
82
95
  /**
83
96
  * Answer to "who is signed in right now?". `authenticated: false` simply
@@ -414,6 +427,37 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
414
427
  * an object, not a bare array.
415
428
  */
416
429
  list(options?: ListOptions): Promise<ListResult<T>>;
430
+ /**
431
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
432
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
433
+ * exactly the same permission gate as list(). Nothing is pushed.
434
+ *
435
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
436
+ * // initial=true: REPLACE your state — records IS the full current
437
+ * // set, and replacing is what clears anything removed while you
438
+ * // weren't looking. initial=false: upsert records by id (a change
439
+ * // can arrive twice, never be missed); remove ids in `deleted`.
440
+ * });
441
+ * // later: w.stop()
442
+ *
443
+ * In a browser it sleeps while the tab is hidden and does a full resync on
444
+ * return (that resync is also how a record erased outright — not deleted
445
+ * by the app, erased — leaves the screen). On errors it backs off,
446
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
447
+ * page is the intended shape — share its result, don't stack watchers.
448
+ */
449
+ watch(onChange: (delta: {
450
+ records: GemmeinRecord<T>[];
451
+ deleted: string[];
452
+ initial: boolean;
453
+ }) => void, options?: {
454
+ every?: number;
455
+ where?: Record<string, unknown>;
456
+ search?: string;
457
+ limit?: number;
458
+ }): {
459
+ stop(): void;
460
+ };
417
461
  get(id: string, options?: {
418
462
  expand?: string[];
419
463
  }): Promise<GemmeinRecord<T>>;
@@ -450,11 +494,18 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
450
494
  * // later, to render:
451
495
  * const { url } = await g.files.link(record.poster)
452
496
  *
497
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
498
+ * file. A document always downloads — it is served as an attachment and
499
+ * never opens inside your page — so link it with `{ intent: "download" }`.
500
+ * Pass `{ name }` so the download carries a real filename, and
501
+ * `{ contentType }` when the Blob has no type of its own.
502
+ *
453
503
  * There is deliberately no `url` here. A URL that outlives a refund is the
454
504
  * bug this replaced.
455
505
  */
456
506
  upload(file: Blob | File, options?: {
457
507
  name?: string;
508
+ contentType?: string;
458
509
  }): Promise<{
459
510
  id: string;
460
511
  ref: FileRef;
@@ -489,6 +540,41 @@ export declare class GemmeinServer {
489
540
  role: string;
490
541
  };
491
542
  }>;
543
+ /**
544
+ * W7.3 — tell one of YOUR OWN people that something happened, by email:
545
+ *
546
+ * await g.notify(order.ownerUserId, {
547
+ * subject: "Your order shipped",
548
+ * text: "Order #142 left the warehouse today.",
549
+ * key: "order-142-shipped", // optional: retries can't double-send
550
+ * });
551
+ *
552
+ * A person id, never an email address — the recipient must be a verified
553
+ * user of this app (their address comes from the server's own record).
554
+ * Plain text (an account-context footer is added). The email is branded
555
+ * as your app; the send appears in your dashboard Inbox, and a customer's
556
+ * reply lands there too once your domain's receiving is verified — the
557
+ * response's `replyRail` says which is true. This is for EVENTS, not
558
+ * campaigns: event-class sends
559
+ * are capped at about 5 per person per day (429 notify_capped, with
560
+ * resetAt). Pass `kind: "account"` for account activity — a new sign-in,
561
+ * an access change, a billing problem — which skips the per-person cap
562
+ * (a security notice must never lose to five order emails); misusing it
563
+ * for campaigns is visible in your own audit trail.
564
+ */
565
+ notify(personId: string, input: {
566
+ subject: string;
567
+ text: string;
568
+ kind?: "event" | "account";
569
+ key?: string;
570
+ }): Promise<{
571
+ sent: boolean;
572
+ deduped?: boolean;
573
+ id: string | null;
574
+ threadId: string | null;
575
+ replyRail?: boolean;
576
+ recorded?: boolean;
577
+ }>;
492
578
  }
493
579
  declare class ServerCollectionClient {
494
580
  private readonly apiUrl;
@@ -503,6 +589,7 @@ declare class ServerCollectionClient {
503
589
  cursor?: string;
504
590
  search?: string;
505
591
  expand?: string[];
592
+ since?: string | Date;
506
593
  }): Promise<unknown>;
507
594
  update(id: string, data: Record<string, unknown>): Promise<unknown>;
508
595
  private request;
package/dist/index.js CHANGED
@@ -423,11 +423,185 @@ export class CollectionClient {
423
423
  query.set("search", options.search);
424
424
  if (options.expand && options.expand.length > 0)
425
425
  query.set("expand", options.expand.join(","));
426
+ if (options.since !== undefined)
427
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
426
428
  // NOT query.size — absent on Node <19.8 / older browsers, where
427
429
  // `undefined > 0` would silently drop every filter.
428
430
  const qs = query.toString();
429
431
  return this.request(qs ? `?${qs}` : "");
430
432
  }
433
+ /**
434
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
435
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
436
+ * exactly the same permission gate as list(). Nothing is pushed.
437
+ *
438
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
439
+ * // initial=true: REPLACE your state — records IS the full current
440
+ * // set, and replacing is what clears anything removed while you
441
+ * // weren't looking. initial=false: upsert records by id (a change
442
+ * // can arrive twice, never be missed); remove ids in `deleted`.
443
+ * });
444
+ * // later: w.stop()
445
+ *
446
+ * In a browser it sleeps while the tab is hidden and does a full resync on
447
+ * return (that resync is also how a record erased outright — not deleted
448
+ * by the app, erased — leaves the screen). On errors it backs off,
449
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
450
+ * page is the intended shape — share its result, don't stack watchers.
451
+ */
452
+ watch(onChange, options = {}) {
453
+ const asked = options.every;
454
+ const every = Math.max(5000, Math.min(300000, Number.isFinite(asked) ? asked : 10000));
455
+ const base = { where: options.where, search: options.search, limit: options.limit ?? 100 };
456
+ let stopped = false;
457
+ let timer;
458
+ let delayMs = every;
459
+ let watermark;
460
+ // One tick at a time. A visibility flip mid-tick sets resyncPending
461
+ // instead of racing a second loop into existence (each raced loop would
462
+ // have doubled the poll rate forever).
463
+ let inFlight = false;
464
+ let resyncPending = false;
465
+ const page = async (since) => {
466
+ const records = [];
467
+ const deleted = [];
468
+ let cursor;
469
+ let mark;
470
+ do {
471
+ const result = await this.list({ ...base, cursor, ...(since !== undefined ? { since } : {}) });
472
+ records.push(...result.records);
473
+ if (result.deleted)
474
+ deleted.push(...result.deleted);
475
+ cursor = result.hasMore ? result.cursor : undefined;
476
+ if (since === undefined) {
477
+ // Full sync: keep the FIRST page's watermark — anything written
478
+ // while later pages stream must redeliver on the next poll, not
479
+ // fall below an end-of-paging mark and vanish.
480
+ mark = mark ?? result.watermark;
481
+ }
482
+ else if (!result.hasMore) {
483
+ mark = result.watermark;
484
+ }
485
+ } while (cursor && !stopped);
486
+ return { records, deleted, mark };
487
+ };
488
+ const tick = async (resync) => {
489
+ if (stopped || inFlight)
490
+ return;
491
+ inFlight = true;
492
+ let delivery;
493
+ try {
494
+ const doResync = resync || resyncPending;
495
+ resyncPending = false;
496
+ const { records, deleted, mark } = await page(doResync ? undefined : watermark);
497
+ if (stopped) {
498
+ inFlight = false;
499
+ return;
500
+ }
501
+ if (mark !== undefined)
502
+ watermark = mark;
503
+ delayMs = every;
504
+ if (doResync || records.length > 0 || deleted.length > 0) {
505
+ delivery = { records, deleted, initial: doResync };
506
+ }
507
+ }
508
+ catch (err) {
509
+ if (stopped) {
510
+ inFlight = false;
511
+ return;
512
+ }
513
+ // An auth refusal is not transient: a signed-out or unentitled
514
+ // watcher polling forever writes a denied-audit row per attempt on
515
+ // the server (audit finding #8). Stop; a fresh watch() after
516
+ // sign-in starts clean.
517
+ if (err instanceof GemmeinError && (err.status === 401 || err.status === 403)) {
518
+ inFlight = false;
519
+ stopped = true;
520
+ if (timer !== undefined) {
521
+ clearTimeout(timer);
522
+ timer = undefined;
523
+ }
524
+ if (typeof document !== "undefined") {
525
+ document.removeEventListener("visibilitychange", onVisibility);
526
+ }
527
+ if (typeof console !== "undefined")
528
+ console.warn(`gemmein watch stopped: ${err.code} — start a new watch after signing in`);
529
+ return;
530
+ }
531
+ // Back off; a rate limit says exactly when to come back.
532
+ delayMs = Math.min(Math.max(delayMs * 2, every), 60000);
533
+ if (err instanceof GemmeinError && err.resetAt) {
534
+ const wait = new Date(err.resetAt).getTime() - Date.now();
535
+ if (Number.isFinite(wait) && wait > delayMs)
536
+ delayMs = Math.min(wait, 300000);
537
+ }
538
+ }
539
+ inFlight = false;
540
+ if (delivery) {
541
+ // The app's callback runs OUTSIDE the wire handling: its exceptions
542
+ // are its own bugs, never a reason to back off or mangle the loop.
543
+ try {
544
+ onChange(delivery);
545
+ }
546
+ catch { /* the app's error, not the wire's */ }
547
+ }
548
+ schedule();
549
+ };
550
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
551
+ const schedule = () => {
552
+ if (stopped || hidden() || timer !== undefined)
553
+ return;
554
+ timer = setTimeout(() => { timer = undefined; void tick(false); }, delayMs);
555
+ // In Node a live timer pins the process open; a watcher should never
556
+ // be the reason a script can't exit.
557
+ timer.unref?.();
558
+ };
559
+ const onVisibility = () => {
560
+ if (stopped)
561
+ return;
562
+ if (hidden()) {
563
+ if (timer !== undefined) {
564
+ clearTimeout(timer);
565
+ timer = undefined;
566
+ }
567
+ }
568
+ else {
569
+ // Waking resyncs in full — the cheap answer to "what did I miss?",
570
+ // including anything erased while the tab slept. If a tick is mid
571
+ // flight, flag it rather than racing a second loop.
572
+ if (timer !== undefined) {
573
+ clearTimeout(timer);
574
+ timer = undefined;
575
+ }
576
+ delayMs = every;
577
+ if (inFlight) {
578
+ resyncPending = true;
579
+ return;
580
+ }
581
+ void tick(true);
582
+ }
583
+ };
584
+ if (typeof document !== "undefined") {
585
+ document.addEventListener("visibilitychange", onVisibility);
586
+ }
587
+ // Born hidden: wait for the tab — the visibility handler runs the first
588
+ // sync when the user actually looks.
589
+ if (!hidden())
590
+ void tick(true);
591
+ else
592
+ resyncPending = true;
593
+ return {
594
+ stop: () => {
595
+ stopped = true;
596
+ if (timer !== undefined)
597
+ clearTimeout(timer);
598
+ timer = undefined;
599
+ if (typeof document !== "undefined") {
600
+ document.removeEventListener("visibilitychange", onVisibility);
601
+ }
602
+ },
603
+ };
604
+ }
431
605
  async get(id, options = {}) {
432
606
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
433
607
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -475,15 +649,24 @@ export class CollectionClient {
475
649
  * // later, to render:
476
650
  * const { url } = await g.files.link(record.poster)
477
651
  *
652
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
653
+ * file. A document always downloads — it is served as an attachment and
654
+ * never opens inside your page — so link it with `{ intent: "download" }`.
655
+ * Pass `{ name }` so the download carries a real filename, and
656
+ * `{ contentType }` when the Blob has no type of its own.
657
+ *
478
658
  * There is deliberately no `url` here. A URL that outlives a refund is the
479
659
  * bug this replaced.
480
660
  */
481
661
  async upload(file, options) {
482
662
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
663
+ // A Blob built from bytes has type "" — `contentType` names it. The
664
+ // server proves the bytes either way.
665
+ const contentType = options?.contentType ?? file.type;
483
666
  // Step 1: Get presigned upload URL
484
667
  const presign = await this.request("/upload", {
485
668
  method: "POST",
486
- body: JSON.stringify({ name, size: file.size, contentType: file.type }),
669
+ body: JSON.stringify({ name, size: file.size, contentType }),
487
670
  });
488
671
  // Step 2: Upload directly to S3 via presigned POST
489
672
  const form = new FormData();
@@ -571,6 +754,45 @@ export class GemmeinServer {
571
754
  }
572
755
  return response.json();
573
756
  }
757
+ /**
758
+ * W7.3 — tell one of YOUR OWN people that something happened, by email:
759
+ *
760
+ * await g.notify(order.ownerUserId, {
761
+ * subject: "Your order shipped",
762
+ * text: "Order #142 left the warehouse today.",
763
+ * key: "order-142-shipped", // optional: retries can't double-send
764
+ * });
765
+ *
766
+ * A person id, never an email address — the recipient must be a verified
767
+ * user of this app (their address comes from the server's own record).
768
+ * Plain text (an account-context footer is added). The email is branded
769
+ * as your app; the send appears in your dashboard Inbox, and a customer's
770
+ * reply lands there too once your domain's receiving is verified — the
771
+ * response's `replyRail` says which is true. This is for EVENTS, not
772
+ * campaigns: event-class sends
773
+ * are capped at about 5 per person per day (429 notify_capped, with
774
+ * resetAt). Pass `kind: "account"` for account activity — a new sign-in,
775
+ * an access change, a billing problem — which skips the per-person cap
776
+ * (a security notice must never lose to five order emails); misusing it
777
+ * for campaigns is visible in your own audit trail.
778
+ */
779
+ async notify(personId, input) {
780
+ const response = await fetch(new URL("/server/notify", this.apiUrl), {
781
+ method: "POST",
782
+ headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
783
+ body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
784
+ });
785
+ if (!response.ok) {
786
+ const body = await response.json().catch(() => ({ code: "request_failed", message: `Request failed: ${response.status}` }));
787
+ throw new GemmeinError({
788
+ status: response.status,
789
+ code: body.code ?? "request_failed",
790
+ message: body.message ?? `Request failed: ${response.status}`,
791
+ resetAt: typeof body.resetAt === "string" ? body.resetAt : undefined,
792
+ });
793
+ }
794
+ return response.json();
795
+ }
574
796
  }
575
797
  class ServerCollectionClient {
576
798
  constructor(apiUrl, secretKey, name) {
@@ -595,6 +817,8 @@ class ServerCollectionClient {
595
817
  query.set("search", options.search);
596
818
  if (options.expand && options.expand.length > 0)
597
819
  query.set("expand", options.expand.join(","));
820
+ if (options.since !== undefined)
821
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
598
822
  // NOT query.size — absent on Node <19.8 / older browsers, where
599
823
  // `undefined > 0` would silently drop every filter.
600
824
  const qs = query.toString();
package/llms.txt CHANGED
@@ -140,7 +140,14 @@ go-live. Everything else is yours.
140
140
  never by you or the SDK; in local dev (`npx gemmein dev`) they're born from
141
141
  the terminal's rule conversation or a dropped declaration file, and
142
142
  `npx gemmein sync` creates them in the cloud app's dev environment from
143
- those local declarations. Best practice: at planning time, list the collections your
143
+ those local declarations. A declaration is `gemmein/collections/<name>.json`:
144
+ `{ "rule": "shared", "means": "<why, in your human's words>" }`, plus an
145
+ optional `"unlockedBy": ["pro"]` — plan or product NAMES from
146
+ gemmein/payments.json (never access keys) — which is the console's
147
+ "Unlocked by": the local engine refuses members without one of them
148
+ (403 entitlement_required) the moment the file lands, and sync carries
149
+ it to the cloud app. An unknown name is refused out loud in the dev
150
+ terminal and the collection is not created. Best practice: at planning time, list the collections your
144
151
  app will need and ask your human up front, and pass your intent whenever a
145
152
  collection might not exist yet —
146
153
  `g.collection("bookings", { intent: "students reserve slots; each sees only their own" })`
@@ -183,9 +190,9 @@ go-live. Everything else is yours.
183
190
  as private or encrypted chat.
184
191
  Inbox mechanics (addressed + direct): the recipient is server-stamped
185
192
  (`record.audienceUserId`) — never a data field; user ids come from
186
- records' ownerUserId or the owner's admin views. Reading is plain
187
- `.list({ sort: "newest" })` — poll on window focus plus a gentle ~60s
188
- interval, never a tight loop. Track read-state in the user's own private
193
+ records' ownerUserId or the owner's admin views. For a live-feeling inbox
194
+ (or any live list) use `.watch()` — see "Live data" below never a tight
195
+ loop of your own. Track read-state in the user's own private
189
196
  collection. Both rules store plain text like community. Errors teach the
190
197
  fix: 400 invalid_audience (recipient isn't a user of this app), 403
191
198
  reply_only (this collection only allows replying to someone who wrote to
@@ -207,7 +214,64 @@ go-live. Everything else is yours.
207
214
  `record.title`). `ownerUserId` and the rest are server-derived and
208
215
  read-only: never store your own userId/role/owner fields inside `data`.
209
216
  `.list()` returns `{ records, hasMore }` (an object, not an array) and
210
- accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor }`.
217
+ accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor,
218
+ since }`.
219
+ - Live data (dashboards, feeds, inboxes): nothing is pushed — BY DESIGN
220
+ (pushed data is where other platforms leak; every Gemmein read passes the
221
+ permission check). Instead, polling is built in and cheap:
222
+
223
+ const w = g.collection("orders").watch(({ records, deleted, initial }) => {
224
+ // initial=true → REPLACE your state with `records` (it IS the full
225
+ // current set — replacing is what clears anything removed while you
226
+ // weren't looking). initial=false → upsert `records` BY ID and drop
227
+ // ids in `deleted`.
228
+ });
229
+ // when the view unmounts: w.stop()
230
+
231
+ Every 10s by default (`{ every: ms }`, floor 5s) it asks "what changed?" —
232
+ a near-free answer when nothing did. It sleeps while the tab is hidden and
233
+ refreshes fully on return. Apply records by id: a change can be delivered
234
+ twice, never silently missed. One watch per page, shared — don't stack a
235
+ watcher per component, and never wrap it in your own setInterval. Under
236
+ the hood it's `list({ since })`: pass the previous answer's `watermark` as
237
+ `since` and you get records changed or deleted after it (ids in
238
+ `deleted`), oldest first — same permissions as any read. `since` can't
239
+ combine with `sort`. A record erased by the platform (data erasure, not an
240
+ app delete) doesn't appear in `deleted` — the replace-on-initial refresh
241
+ on tab-return is what clears it. Watching with `where`/`search`: deletions
242
+ always come through, but a record EDITED so it stops matching your filter
243
+ isn't reported (it isn't deleted) — it clears on the next tab-return
244
+ refresh. Watch suits lists you'd actually render (up to a few thousand
245
+ records); it re-reads the full list on every tab return.
246
+ - Telling a customer something happened ("order shipped", "booking
247
+ confirmed", "new reply") is built in — your SERVER calls it with a
248
+ secret key; there is no SMTP, no email service to wire, and it cannot
249
+ be called from the browser:
250
+
251
+ import { gemmeinServer } from "@gemmein/sdk";
252
+ const g = gemmeinServer(process.env.GEMMEIN_SECRET_KEY);
253
+ await g.notify(order.ownerUserId, {
254
+ subject: "Your order shipped",
255
+ text: "Order #142 left the warehouse today.",
256
+ key: "order-142-shipped", // retries can't double-send
257
+ });
258
+
259
+ A person id, NEVER an email address — the recipient must be a verified
260
+ user of your app (404 not_a_customer otherwise; the address comes from
261
+ the server's own record). Plain text; a short "you have an account with
262
+ {your app}" footer is added for you. The email is branded as your app
263
+ (your verified domain when you have one). Every send appears in your
264
+ dashboard Inbox as a conversation; a customer's REPLY lands there too
265
+ once your domain's receiving is verified (the Domains page) — before
266
+ that the mail has no reply path, so if you expect answers, say where to
267
+ write. The response's `replyRail: true|false` states which is true right
268
+ now. This is for EVENTS, not campaigns:
269
+ about 5 event-class sends per person per day (429 notify_capped with
270
+ resetAt says when). Account activity — a new sign-in, an access change,
271
+ a billing problem — passes `kind: "account"`, which skips the per-person
272
+ cap (a security notice never loses to five order emails); misusing it
273
+ for campaigns shows in your own audit trail. In `gemmein dev` the send
274
+ prints in the terminal (NOTIFY · …) instead of mailing.
211
275
  - Linking records (author on a post, product on an order): store the other
212
276
  record's id in a field (`authorProfileId: profile.id`) — in collections
213
277
  users write (community, shared, direct) the server learns it's a link;
@@ -228,7 +292,8 @@ go-live. Everything else is yours.
228
292
  storage bucket — uploads are built in:
229
293
  `const file = await g.collection("posts").upload(blob, { name })`
230
294
  → `{ id, ref, contentType, sizeBytes }`. Store `file.ref` (`"file:01K…"`)
231
- in a record field like any text — that's how a record "has" an image.
295
+ in a record field like any text — that's how a record "has" an image or
296
+ a document.
232
297
  Store the REFERENCE, never a URL: a reference never expires and grants
233
298
  nothing on its own.
234
299
  To show or download it: `const { url } = await g.files.link(record.photo)`
@@ -236,12 +301,18 @@ go-live. Everything else is yours.
236
301
  rather than a preview. Files in a collection anyone can read get a
237
302
  permanent link; every other file gets one that expires in a couple of
238
303
  minutes, so call `link()` when you render, don't store what it returns.
239
- Upload permission follows the collection's WRITE rule; images only
240
- (JPEG/PNG/WebP/GIF/HEIC). Oversized files are refused loudly (413
304
+ Upload permission follows the collection's WRITE rule. Images (JPEG/PNG/
305
+ WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per file; nothing
306
+ else (no video, audio, SVG, HTML or office files — zip an office file).
307
+ Images render with `<img>`; a document always DOWNLOADS (served as an
308
+ attachment under the `name` you gave upload() — it never opens inside
309
+ your page), so link documents with `{ intent: "download" }`. A Blob you
310
+ built from bytes has no type: pass `{ contentType: "application/pdf" }`
311
+ to upload(), or a 415 says "none declared". Oversized files are refused loudly (413
241
312
  file_too_large — the message says the cap). The server checks the actual
242
313
  bytes at confirm — a 400 invalid_file_content means the file isn't really
243
- the image type it claimed (usually a renamed file); send the real image,
244
- don't retry. A 403 from `link()` means the customer isn't allowed this
314
+ the type it claimed (usually a renamed file); send the real file, don't
315
+ retry. A 403 from `link()` means the customer isn't allowed this
245
316
  file right now — signed out, not theirs, or an entitlement they no longer
246
317
  hold (`entitlement_required` — `err.requires` is the plan's key, the one
247
318
  the console shows by name).
@@ -309,7 +380,11 @@ go-live. Everything else is yours.
309
380
  with `sub?.plan === "pro"`.
310
381
  - Paid ACCESS (entitlements): a collection can be locked to a plan or
311
382
  product — your human picks it BY NAME in the "Unlocked by" row on the
312
- Collections page (several allowed; any one of them opens it) and the
383
+ Collections page (several allowed; any one of them opens it), or, in
384
+ local dev, you write the same thing as `"unlockedBy": ["pro"]` in
385
+ gemmein/collections/<name>.json (names, never keys; `npx gemmein check`
386
+ checks that the lock holds and names collections left open while the app
387
+ sells something) — and the
313
388
  engine refuses customers without it, under all seven rules. (Server
314
389
  secret keys and the owner's console are exempt by design; link/expand
315
390
  silently hide gated records rather than naming them.) Every plan and
@@ -473,10 +548,15 @@ REFERENCE.md) — copy it out, name your collections, run it in CI.
473
548
  metered. Safe limits exist as safety rails against runaway scripts and
474
549
  are never billed. Ceilings follow the app's verified people — they grow
475
550
  automatically as the business grows, with generous floors so a small app
476
- never starts at a wall. Hitting a limit returns a clear coded error
477
- (`usage_limit_exceeded`; monthly counters carry `resetAt`, storage
478
- responds to deleting files), and the dashboard's usage page shows what
479
- is binding.
551
+ never starts at a wall. Reaching a ceiling never stops your app: sign-ins,
552
+ writes and API requests keep working past it, the founder is told once
553
+ for the month, and next month's bill follows the band their people land
554
+ in. Storage is the one thing that can fill — files are accepted up to
555
+ twice the storage ceiling, then uploads return `usage_limit_exceeded`
556
+ (no `resetAt`; storage frees when files are deleted). The per-IP and
557
+ per-email rate limits underneath are safety rails, not ceilings, and
558
+ still answer 429 with `resetAt`. The dashboard's usage page shows what
559
+ crossed this month.
480
560
 
481
561
  ## Facts for citation
482
562
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.4.1",
4
- "description": "Gemmein SDK passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
3
+ "version": "0.4.3",
4
+ "description": "Gemmein SDK \u2014 passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./dist/index.cjs",
@@ -53,4 +53,4 @@
53
53
  "bugs": {
54
54
  "email": "hello@gemmein.com"
55
55
  }
56
- }
56
+ }