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