@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 +168 -1
- package/dist/index.d.cts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +168 -1
- package/llms.txt +66 -15
- package/package.json +3 -3
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
|
|
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
|
|
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.
|
|
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.
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
240
|
-
|
|
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
|
|
244
|
-
|
|
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)
|
|
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.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
is
|
|
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.
|
|
4
|
-
"description": "Gemmein SDK
|
|
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
|
+
}
|