@gemmein/sdk 0.4.1 → 0.4.2

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,167 @@ 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
+ // Back off; a rate limit says exactly when to come back.
530
+ delayMs = Math.min(Math.max(delayMs * 2, every), 60000);
531
+ if (err instanceof GemmeinError && err.resetAt) {
532
+ const wait = new Date(err.resetAt).getTime() - Date.now();
533
+ if (Number.isFinite(wait) && wait > delayMs)
534
+ delayMs = Math.min(wait, 300000);
535
+ }
536
+ }
537
+ inFlight = false;
538
+ if (delivery) {
539
+ // The app's callback runs OUTSIDE the wire handling: its exceptions
540
+ // are its own bugs, never a reason to back off or mangle the loop.
541
+ try {
542
+ onChange(delivery);
543
+ }
544
+ catch { /* the app's error, not the wire's */ }
545
+ }
546
+ schedule();
547
+ };
548
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
549
+ const schedule = () => {
550
+ if (stopped || hidden() || timer !== undefined)
551
+ return;
552
+ timer = setTimeout(() => { timer = undefined; void tick(false); }, delayMs);
553
+ // In Node a live timer pins the process open; a watcher should never
554
+ // be the reason a script can't exit.
555
+ timer.unref?.();
556
+ };
557
+ const onVisibility = () => {
558
+ if (stopped)
559
+ return;
560
+ if (hidden()) {
561
+ if (timer !== undefined) {
562
+ clearTimeout(timer);
563
+ timer = undefined;
564
+ }
565
+ }
566
+ else {
567
+ // Waking resyncs in full — the cheap answer to "what did I miss?",
568
+ // including anything erased while the tab slept. If a tick is mid
569
+ // flight, flag it rather than racing a second loop.
570
+ if (timer !== undefined) {
571
+ clearTimeout(timer);
572
+ timer = undefined;
573
+ }
574
+ delayMs = every;
575
+ if (inFlight) {
576
+ resyncPending = true;
577
+ return;
578
+ }
579
+ void tick(true);
580
+ }
581
+ };
582
+ if (typeof document !== "undefined") {
583
+ document.addEventListener("visibilitychange", onVisibility);
584
+ }
585
+ // Born hidden: wait for the tab — the visibility handler runs the first
586
+ // sync when the user actually looks.
587
+ if (!hidden())
588
+ void tick(true);
589
+ else
590
+ resyncPending = true;
591
+ return {
592
+ stop: () => {
593
+ stopped = true;
594
+ if (timer !== undefined)
595
+ clearTimeout(timer);
596
+ timer = undefined;
597
+ if (typeof document !== "undefined") {
598
+ document.removeEventListener("visibilitychange", onVisibility);
599
+ }
600
+ },
601
+ };
602
+ }
447
603
  async get(id, options = {}) {
448
604
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
449
605
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -491,15 +647,24 @@ class CollectionClient {
491
647
  * // later, to render:
492
648
  * const { url } = await g.files.link(record.poster)
493
649
  *
650
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
651
+ * file. A document always downloads — it is served as an attachment and
652
+ * never opens inside your page — so link it with `{ intent: "download" }`.
653
+ * Pass `{ name }` so the download carries a real filename, and
654
+ * `{ contentType }` when the Blob has no type of its own.
655
+ *
494
656
  * There is deliberately no `url` here. A URL that outlives a refund is the
495
657
  * bug this replaced.
496
658
  */
497
659
  async upload(file, options) {
498
660
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
661
+ // A Blob built from bytes has type "" — `contentType` names it. The
662
+ // server proves the bytes either way.
663
+ const contentType = options?.contentType ?? file.type;
499
664
  // Step 1: Get presigned upload URL
500
665
  const presign = await this.request("/upload", {
501
666
  method: "POST",
502
- body: JSON.stringify({ name, size: file.size, contentType: file.type }),
667
+ body: JSON.stringify({ name, size: file.size, contentType }),
503
668
  });
504
669
  // Step 2: Upload directly to S3 via presigned POST
505
670
  const form = new FormData();
@@ -613,6 +778,8 @@ class ServerCollectionClient {
613
778
  query.set("search", options.search);
614
779
  if (options.expand && options.expand.length > 0)
615
780
  query.set("expand", options.expand.join(","));
781
+ if (options.since !== undefined)
782
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
616
783
  // NOT query.size — absent on Node <19.8 / older browsers, where
617
784
  // `undefined > 0` would silently drop every filter.
618
785
  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;
@@ -503,6 +554,7 @@ declare class ServerCollectionClient {
503
554
  cursor?: string;
504
555
  search?: string;
505
556
  expand?: string[];
557
+ since?: string | Date;
506
558
  }): Promise<unknown>;
507
559
  update(id: string, data: Record<string, unknown>): Promise<unknown>;
508
560
  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;
@@ -503,6 +554,7 @@ declare class ServerCollectionClient {
503
554
  cursor?: string;
504
555
  search?: string;
505
556
  expand?: string[];
557
+ since?: string | Date;
506
558
  }): Promise<unknown>;
507
559
  update(id: string, data: Record<string, unknown>): Promise<unknown>;
508
560
  private request;
package/dist/index.js CHANGED
@@ -423,11 +423,167 @@ 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
+ // Back off; a rate limit says exactly when to come back.
514
+ delayMs = Math.min(Math.max(delayMs * 2, every), 60000);
515
+ if (err instanceof GemmeinError && err.resetAt) {
516
+ const wait = new Date(err.resetAt).getTime() - Date.now();
517
+ if (Number.isFinite(wait) && wait > delayMs)
518
+ delayMs = Math.min(wait, 300000);
519
+ }
520
+ }
521
+ inFlight = false;
522
+ if (delivery) {
523
+ // The app's callback runs OUTSIDE the wire handling: its exceptions
524
+ // are its own bugs, never a reason to back off or mangle the loop.
525
+ try {
526
+ onChange(delivery);
527
+ }
528
+ catch { /* the app's error, not the wire's */ }
529
+ }
530
+ schedule();
531
+ };
532
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
533
+ const schedule = () => {
534
+ if (stopped || hidden() || timer !== undefined)
535
+ return;
536
+ timer = setTimeout(() => { timer = undefined; void tick(false); }, delayMs);
537
+ // In Node a live timer pins the process open; a watcher should never
538
+ // be the reason a script can't exit.
539
+ timer.unref?.();
540
+ };
541
+ const onVisibility = () => {
542
+ if (stopped)
543
+ return;
544
+ if (hidden()) {
545
+ if (timer !== undefined) {
546
+ clearTimeout(timer);
547
+ timer = undefined;
548
+ }
549
+ }
550
+ else {
551
+ // Waking resyncs in full — the cheap answer to "what did I miss?",
552
+ // including anything erased while the tab slept. If a tick is mid
553
+ // flight, flag it rather than racing a second loop.
554
+ if (timer !== undefined) {
555
+ clearTimeout(timer);
556
+ timer = undefined;
557
+ }
558
+ delayMs = every;
559
+ if (inFlight) {
560
+ resyncPending = true;
561
+ return;
562
+ }
563
+ void tick(true);
564
+ }
565
+ };
566
+ if (typeof document !== "undefined") {
567
+ document.addEventListener("visibilitychange", onVisibility);
568
+ }
569
+ // Born hidden: wait for the tab — the visibility handler runs the first
570
+ // sync when the user actually looks.
571
+ if (!hidden())
572
+ void tick(true);
573
+ else
574
+ resyncPending = true;
575
+ return {
576
+ stop: () => {
577
+ stopped = true;
578
+ if (timer !== undefined)
579
+ clearTimeout(timer);
580
+ timer = undefined;
581
+ if (typeof document !== "undefined") {
582
+ document.removeEventListener("visibilitychange", onVisibility);
583
+ }
584
+ },
585
+ };
586
+ }
431
587
  async get(id, options = {}) {
432
588
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
433
589
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -475,15 +631,24 @@ export class CollectionClient {
475
631
  * // later, to render:
476
632
  * const { url } = await g.files.link(record.poster)
477
633
  *
634
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
635
+ * file. A document always downloads — it is served as an attachment and
636
+ * never opens inside your page — so link it with `{ intent: "download" }`.
637
+ * Pass `{ name }` so the download carries a real filename, and
638
+ * `{ contentType }` when the Blob has no type of its own.
639
+ *
478
640
  * There is deliberately no `url` here. A URL that outlives a refund is the
479
641
  * bug this replaced.
480
642
  */
481
643
  async upload(file, options) {
482
644
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
645
+ // A Blob built from bytes has type "" — `contentType` names it. The
646
+ // server proves the bytes either way.
647
+ const contentType = options?.contentType ?? file.type;
483
648
  // Step 1: Get presigned upload URL
484
649
  const presign = await this.request("/upload", {
485
650
  method: "POST",
486
- body: JSON.stringify({ name, size: file.size, contentType: file.type }),
651
+ body: JSON.stringify({ name, size: file.size, contentType }),
487
652
  });
488
653
  // Step 2: Upload directly to S3 via presigned POST
489
654
  const form = new FormData();
@@ -595,6 +760,8 @@ class ServerCollectionClient {
595
760
  query.set("search", options.search);
596
761
  if (options.expand && options.expand.length > 0)
597
762
  query.set("expand", options.expand.join(","));
763
+ if (options.since !== undefined)
764
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
598
765
  // NOT query.size — absent on Node <19.8 / older browsers, where
599
766
  // `undefined > 0` would silently drop every filter.
600
767
  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,35 @@ 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.
211
246
  - Linking records (author on a post, product on an order): store the other
212
247
  record's id in a field (`authorProfileId: profile.id`) — in collections
213
248
  users write (community, shared, direct) the server learns it's a link;
@@ -228,7 +263,8 @@ go-live. Everything else is yours.
228
263
  storage bucket — uploads are built in:
229
264
  `const file = await g.collection("posts").upload(blob, { name })`
230
265
  → `{ 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.
266
+ in a record field like any text — that's how a record "has" an image or
267
+ a document.
232
268
  Store the REFERENCE, never a URL: a reference never expires and grants
233
269
  nothing on its own.
234
270
  To show or download it: `const { url } = await g.files.link(record.photo)`
@@ -236,12 +272,18 @@ go-live. Everything else is yours.
236
272
  rather than a preview. Files in a collection anyone can read get a
237
273
  permanent link; every other file gets one that expires in a couple of
238
274
  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
275
+ Upload permission follows the collection's WRITE rule. Images (JPEG/PNG/
276
+ WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per file; nothing
277
+ else (no video, audio, SVG, HTML or office files — zip an office file).
278
+ Images render with `<img>`; a document always DOWNLOADS (served as an
279
+ attachment under the `name` you gave upload() — it never opens inside
280
+ your page), so link documents with `{ intent: "download" }`. A Blob you
281
+ built from bytes has no type: pass `{ contentType: "application/pdf" }`
282
+ to upload(), or a 415 says "none declared". Oversized files are refused loudly (413
241
283
  file_too_large — the message says the cap). The server checks the actual
242
284
  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
285
+ the type it claimed (usually a renamed file); send the real file, don't
286
+ retry. A 403 from `link()` means the customer isn't allowed this
245
287
  file right now — signed out, not theirs, or an entitlement they no longer
246
288
  hold (`entitlement_required` — `err.requires` is the plan's key, the one
247
289
  the console shows by name).
@@ -309,7 +351,11 @@ go-live. Everything else is yours.
309
351
  with `sub?.plan === "pro"`.
310
352
  - Paid ACCESS (entitlements): a collection can be locked to a plan or
311
353
  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
354
+ Collections page (several allowed; any one of them opens it), or, in
355
+ local dev, you write the same thing as `"unlockedBy": ["pro"]` in
356
+ gemmein/collections/<name>.json (names, never keys; `npx gemmein check`
357
+ checks that the lock holds and names collections left open while the app
358
+ sells something) — and the
313
359
  engine refuses customers without it, under all seven rules. (Server
314
360
  secret keys and the owner's console are exempt by design; link/expand
315
361
  silently hide gated records rather than naming them.) Every plan and
@@ -473,10 +519,15 @@ REFERENCE.md) — copy it out, name your collections, run it in CI.
473
519
  metered. Safe limits exist as safety rails against runaway scripts and
474
520
  are never billed. Ceilings follow the app's verified people — they grow
475
521
  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.
522
+ never starts at a wall. Reaching a ceiling never stops your app: sign-ins,
523
+ writes and API requests keep working past it, the founder is told once
524
+ for the month, and next month's bill follows the band their people land
525
+ in. Storage is the one thing that can fill — files are accepted up to
526
+ twice the storage ceiling, then uploads return `usage_limit_exceeded`
527
+ (no `resetAt`; storage frees when files are deleted). The per-IP and
528
+ per-email rate limits underneath are safety rails, not ceilings, and
529
+ still answer 429 with `resetAt`. The dashboard's usage page shows what
530
+ crossed this month.
480
531
 
481
532
  ## Facts for citation
482
533
 
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.2",
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
+ }