@gemmein/sdk 0.3.2 → 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/README.md +3 -0
- package/REFERENCE.md +2 -0
- package/dist/index.cjs +191 -23
- package/dist/index.d.cts +77 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +191 -23
- package/llms.txt +248 -57
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -263,6 +263,8 @@ try {
|
|
|
263
263
|
err.status // HTTP status
|
|
264
264
|
err.message // human-readable, includes what to do next
|
|
265
265
|
err.resetAt // rate limits: when to retry
|
|
266
|
+
err.requires // 403 entitlement_required: the plan/product key this
|
|
267
|
+
// collection asks for — show your upgrade screen
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
270
|
```
|
|
@@ -279,6 +281,7 @@ try {
|
|
|
279
281
|
| `unknown_product` | 404 | No product by that name — the message lists what the app sells |
|
|
280
282
|
| `invalid_publish` | 400 | `published` is an option on public collections only — not a data field, not for scoped rules |
|
|
281
283
|
| `forbidden` | 403 | The rules refused this — a permission your user doesn't have. **Never retry**: the same call will always be refused. Fix the approach (wrong collection rule, non-admin writing to `admin_write`, secret key out of scope) or show `err.message`. |
|
|
284
|
+
| `entitlement_required` | 403 | Signed in, but not on a plan (or holding a product) this collection is unlocked by. `err.requires` carries that plan's key (`access:pro` for a plan named pro). The one 403 that succeeds later: show your upgrade screen, send them to checkout, retry after they hold it. |
|
|
282
285
|
| `denied` | 429 / 401 | The generic refusal for everything retriable or fixable: a rate limit (429 — carries `resetAt`, wait and retry) or a missing sign-in (401 — sign in first via `g.auth.sendEmailCode`). Distinguish by HTTP status; show `err.message`, which reads correctly for each. |
|
|
283
286
|
|
|
284
287
|
**Branching on error codes:** switch on the *specific named* codes above. The one rule that matters: `forbidden` means stop — retrying can never succeed; `denied` means the request could work later (wait for `resetAt` on 429, sign in on 401). Only rate-limit `denied` carries `resetAt` — that's the reliable signal for a retry-after.
|
package/REFERENCE.md
CHANGED
|
@@ -271,6 +271,7 @@ class GemmeinError extends Error {
|
|
|
271
271
|
code: string; // branch on this
|
|
272
272
|
message: string; // render this — reads correctly for users
|
|
273
273
|
resetAt?: string; // present on 429 — wait until then, retry
|
|
274
|
+
requires?: string; // present on 403 entitlement_required — the plan/product key it asks for
|
|
274
275
|
}
|
|
275
276
|
```
|
|
276
277
|
|
|
@@ -281,6 +282,7 @@ Branch on `err.code`. The stable codes:
|
|
|
281
282
|
| `unknown_collection` | collection doesn't exist | ask the owner to create it — don't retry |
|
|
282
283
|
| `unknown_product` | product not sold | use a name from the list in the message |
|
|
283
284
|
| `forbidden` | the rules refused you (e.g. only the owner writes) | **stop** — the same call always fails |
|
|
285
|
+
| `entitlement_required` | 403 — signed in, but not on a plan (or holding a product) this collection is unlocked by; `err.requires` is that plan's key (`access:<slug of its name>`) | show your upgrade screen and send them to checkout — the one 403 that succeeds later |
|
|
284
286
|
| `not_found` | record you can't see (existence not leaked) | treat as absent |
|
|
285
287
|
| `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
|
|
286
288
|
| `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
|
package/dist/index.cjs
CHANGED
|
@@ -10,6 +10,7 @@ class GemmeinError extends Error {
|
|
|
10
10
|
this.status = input.status;
|
|
11
11
|
this.code = input.code;
|
|
12
12
|
this.resetAt = input.resetAt;
|
|
13
|
+
this.requires = input.requires;
|
|
13
14
|
}
|
|
14
15
|
}
|
|
15
16
|
exports.GemmeinError = GemmeinError;
|
|
@@ -223,6 +224,13 @@ class PurchasesClient {
|
|
|
223
224
|
* applied — `status` is "paid", "part_refunded" or "refunded", and
|
|
224
225
|
* `refundedMinor` is how much has come back. Throws GemmeinError (401) when
|
|
225
226
|
* nobody is signed in.
|
|
227
|
+
*
|
|
228
|
+
* W4.1b: a purchase whose product delivers something carries `delivery` —
|
|
229
|
+
* `{ type: "gemmein_file", file }` (resolve the ref with
|
|
230
|
+
* `g.files.link(file, { intent: "download" })`; the purchase itself is the
|
|
231
|
+
* authorization, re-checked on every mint, and a full refund cuts it off)
|
|
232
|
+
* or `{ type: "external_url", url }` (a plain handover). A refunded
|
|
233
|
+
* purchase never carries delivery.
|
|
226
234
|
*/
|
|
227
235
|
async mine() {
|
|
228
236
|
const result = (await runtimeRequest(this.config, "/auth/purchases"));
|
|
@@ -431,11 +439,167 @@ class CollectionClient {
|
|
|
431
439
|
query.set("search", options.search);
|
|
432
440
|
if (options.expand && options.expand.length > 0)
|
|
433
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);
|
|
434
444
|
// NOT query.size — absent on Node <19.8 / older browsers, where
|
|
435
445
|
// `undefined > 0` would silently drop every filter.
|
|
436
446
|
const qs = query.toString();
|
|
437
447
|
return this.request(qs ? `?${qs}` : "");
|
|
438
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
|
+
}
|
|
439
603
|
async get(id, options = {}) {
|
|
440
604
|
const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
|
|
441
605
|
return this.request(`/${encodeURIComponent(id)}${qs}`);
|
|
@@ -483,15 +647,24 @@ class CollectionClient {
|
|
|
483
647
|
* // later, to render:
|
|
484
648
|
* const { url } = await g.files.link(record.poster)
|
|
485
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
|
+
*
|
|
486
656
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
487
657
|
* bug this replaced.
|
|
488
658
|
*/
|
|
489
659
|
async upload(file, options) {
|
|
490
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;
|
|
491
664
|
// Step 1: Get presigned upload URL
|
|
492
665
|
const presign = await this.request("/upload", {
|
|
493
666
|
method: "POST",
|
|
494
|
-
body: JSON.stringify({ name, size: file.size, contentType
|
|
667
|
+
body: JSON.stringify({ name, size: file.size, contentType }),
|
|
495
668
|
});
|
|
496
669
|
// Step 2: Upload directly to S3 via presigned POST
|
|
497
670
|
const form = new FormData();
|
|
@@ -605,6 +778,8 @@ class ServerCollectionClient {
|
|
|
605
778
|
query.set("search", options.search);
|
|
606
779
|
if (options.expand && options.expand.length > 0)
|
|
607
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);
|
|
608
783
|
// NOT query.size — absent on Node <19.8 / older browsers, where
|
|
609
784
|
// `undefined > 0` would silently drop every filter.
|
|
610
785
|
const qs = query.toString();
|
|
@@ -627,12 +802,10 @@ class ServerCollectionClient {
|
|
|
627
802
|
if (response.status === 204)
|
|
628
803
|
return undefined;
|
|
629
804
|
if (!response.ok) {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
message: body.message ?? `Request failed: ${response.status}`,
|
|
635
|
-
});
|
|
805
|
+
// One parser for every refusal body, so a 403 entitlement_required on a
|
|
806
|
+
// collection surfaces `requires` exactly as it does on the runtime
|
|
807
|
+
// clients (the docs promise err.requires on BOTH paths).
|
|
808
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
636
809
|
}
|
|
637
810
|
return response.json();
|
|
638
811
|
}
|
|
@@ -650,12 +823,7 @@ async function handleResponse(response, config) {
|
|
|
650
823
|
if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
|
|
651
824
|
errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
|
|
652
825
|
}
|
|
653
|
-
throw new GemmeinError({
|
|
654
|
-
status: response.status,
|
|
655
|
-
code: errorBody.code,
|
|
656
|
-
message: errorBody.message,
|
|
657
|
-
resetAt: errorBody.resetAt
|
|
658
|
-
});
|
|
826
|
+
throw new GemmeinError({ status: response.status, ...errorBody });
|
|
659
827
|
}
|
|
660
828
|
return response.json();
|
|
661
829
|
}
|
|
@@ -663,19 +831,19 @@ async function readErrorBody(response) {
|
|
|
663
831
|
try {
|
|
664
832
|
const value = await response.json();
|
|
665
833
|
if (typeof value === "object" && value !== null) {
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
: typeof
|
|
669
|
-
? value.error
|
|
834
|
+
const body = value;
|
|
835
|
+
const code = typeof body.code === "string" ? body.code
|
|
836
|
+
: typeof body.error === "string" ? body.error
|
|
670
837
|
: "request_failed";
|
|
838
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
839
|
+
// ENTITLE-10: `requires` is the one key the 403 names (the lock's
|
|
840
|
+
// first). Copied only when the server sent it as a string — never
|
|
841
|
+
// invented client-side, and nothing else from the body is surfaced.
|
|
671
842
|
return {
|
|
672
843
|
code,
|
|
673
|
-
message:
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
resetAt: typeof value.resetAt === "string"
|
|
677
|
-
? value.resetAt
|
|
678
|
-
: undefined
|
|
844
|
+
message: str(body.message) ?? `Gemmein request failed: ${response.status}`,
|
|
845
|
+
resetAt: str(body.resetAt),
|
|
846
|
+
requires: str(body.requires)
|
|
679
847
|
};
|
|
680
848
|
}
|
|
681
849
|
}
|
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
|
|
@@ -104,12 +117,23 @@ export type AuthSession = {
|
|
|
104
117
|
export declare class GemmeinError extends Error {
|
|
105
118
|
readonly status: number;
|
|
106
119
|
readonly code: string;
|
|
120
|
+
/** Present on 429 — when the limit resets; wait until then and retry. */
|
|
107
121
|
readonly resetAt?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Present on `403 entitlement_required` — the plan/product key this
|
|
124
|
+
* collection asks for (keys are minted from plan names: `access:<slug>`;
|
|
125
|
+
* the console shows the plan by name). Show your upgrade screen and send
|
|
126
|
+
* the customer to checkout; the call succeeds once they hold it. A
|
|
127
|
+
* collection unlocked by several plans names ONE key here — offer your
|
|
128
|
+
* plans by name through checkout, the server never hands out the list.
|
|
129
|
+
*/
|
|
130
|
+
readonly requires?: string;
|
|
108
131
|
constructor(input: {
|
|
109
132
|
status: number;
|
|
110
133
|
code: string;
|
|
111
134
|
message: string;
|
|
112
135
|
resetAt?: string;
|
|
136
|
+
requires?: string;
|
|
113
137
|
});
|
|
114
138
|
}
|
|
115
139
|
export declare class MemoryTokenStore implements TokenStore {
|
|
@@ -207,6 +231,13 @@ export declare class PurchasesClient {
|
|
|
207
231
|
* applied — `status` is "paid", "part_refunded" or "refunded", and
|
|
208
232
|
* `refundedMinor` is how much has come back. Throws GemmeinError (401) when
|
|
209
233
|
* nobody is signed in.
|
|
234
|
+
*
|
|
235
|
+
* W4.1b: a purchase whose product delivers something carries `delivery` —
|
|
236
|
+
* `{ type: "gemmein_file", file }` (resolve the ref with
|
|
237
|
+
* `g.files.link(file, { intent: "download" })`; the purchase itself is the
|
|
238
|
+
* authorization, re-checked on every mint, and a full refund cuts it off)
|
|
239
|
+
* or `{ type: "external_url", url }` (a plain handover). A refunded
|
|
240
|
+
* purchase never carries delivery.
|
|
210
241
|
*/
|
|
211
242
|
mine(): Promise<Array<{
|
|
212
243
|
item: string;
|
|
@@ -217,6 +248,13 @@ export declare class PurchasesClient {
|
|
|
217
248
|
status: "paid" | "part_refunded" | "refunded";
|
|
218
249
|
grants: string[];
|
|
219
250
|
paidAt: string;
|
|
251
|
+
delivery?: {
|
|
252
|
+
type: "gemmein_file";
|
|
253
|
+
file: string;
|
|
254
|
+
} | {
|
|
255
|
+
type: "external_url";
|
|
256
|
+
url: string;
|
|
257
|
+
};
|
|
220
258
|
}>>;
|
|
221
259
|
}
|
|
222
260
|
/**
|
|
@@ -389,6 +427,37 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
|
|
|
389
427
|
* an object, not a bare array.
|
|
390
428
|
*/
|
|
391
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
|
+
};
|
|
392
461
|
get(id: string, options?: {
|
|
393
462
|
expand?: string[];
|
|
394
463
|
}): Promise<GemmeinRecord<T>>;
|
|
@@ -425,11 +494,18 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
|
|
|
425
494
|
* // later, to render:
|
|
426
495
|
* const { url } = await g.files.link(record.poster)
|
|
427
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
|
+
*
|
|
428
503
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
429
504
|
* bug this replaced.
|
|
430
505
|
*/
|
|
431
506
|
upload(file: Blob | File, options?: {
|
|
432
507
|
name?: string;
|
|
508
|
+
contentType?: string;
|
|
433
509
|
}): Promise<{
|
|
434
510
|
id: string;
|
|
435
511
|
ref: FileRef;
|
|
@@ -478,6 +554,7 @@ declare class ServerCollectionClient {
|
|
|
478
554
|
cursor?: string;
|
|
479
555
|
search?: string;
|
|
480
556
|
expand?: string[];
|
|
557
|
+
since?: string | Date;
|
|
481
558
|
}): Promise<unknown>;
|
|
482
559
|
update(id: string, data: Record<string, unknown>): Promise<unknown>;
|
|
483
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
|
|
@@ -104,12 +117,23 @@ export type AuthSession = {
|
|
|
104
117
|
export declare class GemmeinError extends Error {
|
|
105
118
|
readonly status: number;
|
|
106
119
|
readonly code: string;
|
|
120
|
+
/** Present on 429 — when the limit resets; wait until then and retry. */
|
|
107
121
|
readonly resetAt?: string;
|
|
122
|
+
/**
|
|
123
|
+
* Present on `403 entitlement_required` — the plan/product key this
|
|
124
|
+
* collection asks for (keys are minted from plan names: `access:<slug>`;
|
|
125
|
+
* the console shows the plan by name). Show your upgrade screen and send
|
|
126
|
+
* the customer to checkout; the call succeeds once they hold it. A
|
|
127
|
+
* collection unlocked by several plans names ONE key here — offer your
|
|
128
|
+
* plans by name through checkout, the server never hands out the list.
|
|
129
|
+
*/
|
|
130
|
+
readonly requires?: string;
|
|
108
131
|
constructor(input: {
|
|
109
132
|
status: number;
|
|
110
133
|
code: string;
|
|
111
134
|
message: string;
|
|
112
135
|
resetAt?: string;
|
|
136
|
+
requires?: string;
|
|
113
137
|
});
|
|
114
138
|
}
|
|
115
139
|
export declare class MemoryTokenStore implements TokenStore {
|
|
@@ -207,6 +231,13 @@ export declare class PurchasesClient {
|
|
|
207
231
|
* applied — `status` is "paid", "part_refunded" or "refunded", and
|
|
208
232
|
* `refundedMinor` is how much has come back. Throws GemmeinError (401) when
|
|
209
233
|
* nobody is signed in.
|
|
234
|
+
*
|
|
235
|
+
* W4.1b: a purchase whose product delivers something carries `delivery` —
|
|
236
|
+
* `{ type: "gemmein_file", file }` (resolve the ref with
|
|
237
|
+
* `g.files.link(file, { intent: "download" })`; the purchase itself is the
|
|
238
|
+
* authorization, re-checked on every mint, and a full refund cuts it off)
|
|
239
|
+
* or `{ type: "external_url", url }` (a plain handover). A refunded
|
|
240
|
+
* purchase never carries delivery.
|
|
210
241
|
*/
|
|
211
242
|
mine(): Promise<Array<{
|
|
212
243
|
item: string;
|
|
@@ -217,6 +248,13 @@ export declare class PurchasesClient {
|
|
|
217
248
|
status: "paid" | "part_refunded" | "refunded";
|
|
218
249
|
grants: string[];
|
|
219
250
|
paidAt: string;
|
|
251
|
+
delivery?: {
|
|
252
|
+
type: "gemmein_file";
|
|
253
|
+
file: string;
|
|
254
|
+
} | {
|
|
255
|
+
type: "external_url";
|
|
256
|
+
url: string;
|
|
257
|
+
};
|
|
220
258
|
}>>;
|
|
221
259
|
}
|
|
222
260
|
/**
|
|
@@ -389,6 +427,37 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
|
|
|
389
427
|
* an object, not a bare array.
|
|
390
428
|
*/
|
|
391
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
|
+
};
|
|
392
461
|
get(id: string, options?: {
|
|
393
462
|
expand?: string[];
|
|
394
463
|
}): Promise<GemmeinRecord<T>>;
|
|
@@ -425,11 +494,18 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
|
|
|
425
494
|
* // later, to render:
|
|
426
495
|
* const { url } = await g.files.link(record.poster)
|
|
427
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
|
+
*
|
|
428
503
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
429
504
|
* bug this replaced.
|
|
430
505
|
*/
|
|
431
506
|
upload(file: Blob | File, options?: {
|
|
432
507
|
name?: string;
|
|
508
|
+
contentType?: string;
|
|
433
509
|
}): Promise<{
|
|
434
510
|
id: string;
|
|
435
511
|
ref: FileRef;
|
|
@@ -478,6 +554,7 @@ declare class ServerCollectionClient {
|
|
|
478
554
|
cursor?: string;
|
|
479
555
|
search?: string;
|
|
480
556
|
expand?: string[];
|
|
557
|
+
since?: string | Date;
|
|
481
558
|
}): Promise<unknown>;
|
|
482
559
|
update(id: string, data: Record<string, unknown>): Promise<unknown>;
|
|
483
560
|
private request;
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export class GemmeinError extends Error {
|
|
|
5
5
|
this.status = input.status;
|
|
6
6
|
this.code = input.code;
|
|
7
7
|
this.resetAt = input.resetAt;
|
|
8
|
+
this.requires = input.requires;
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
11
|
export class MemoryTokenStore {
|
|
@@ -213,6 +214,13 @@ export class PurchasesClient {
|
|
|
213
214
|
* applied — `status` is "paid", "part_refunded" or "refunded", and
|
|
214
215
|
* `refundedMinor` is how much has come back. Throws GemmeinError (401) when
|
|
215
216
|
* nobody is signed in.
|
|
217
|
+
*
|
|
218
|
+
* W4.1b: a purchase whose product delivers something carries `delivery` —
|
|
219
|
+
* `{ type: "gemmein_file", file }` (resolve the ref with
|
|
220
|
+
* `g.files.link(file, { intent: "download" })`; the purchase itself is the
|
|
221
|
+
* authorization, re-checked on every mint, and a full refund cuts it off)
|
|
222
|
+
* or `{ type: "external_url", url }` (a plain handover). A refunded
|
|
223
|
+
* purchase never carries delivery.
|
|
216
224
|
*/
|
|
217
225
|
async mine() {
|
|
218
226
|
const result = (await runtimeRequest(this.config, "/auth/purchases"));
|
|
@@ -415,11 +423,167 @@ export class CollectionClient {
|
|
|
415
423
|
query.set("search", options.search);
|
|
416
424
|
if (options.expand && options.expand.length > 0)
|
|
417
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);
|
|
418
428
|
// NOT query.size — absent on Node <19.8 / older browsers, where
|
|
419
429
|
// `undefined > 0` would silently drop every filter.
|
|
420
430
|
const qs = query.toString();
|
|
421
431
|
return this.request(qs ? `?${qs}` : "");
|
|
422
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
|
+
}
|
|
423
587
|
async get(id, options = {}) {
|
|
424
588
|
const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
|
|
425
589
|
return this.request(`/${encodeURIComponent(id)}${qs}`);
|
|
@@ -467,15 +631,24 @@ export class CollectionClient {
|
|
|
467
631
|
* // later, to render:
|
|
468
632
|
* const { url } = await g.files.link(record.poster)
|
|
469
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
|
+
*
|
|
470
640
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
471
641
|
* bug this replaced.
|
|
472
642
|
*/
|
|
473
643
|
async upload(file, options) {
|
|
474
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;
|
|
475
648
|
// Step 1: Get presigned upload URL
|
|
476
649
|
const presign = await this.request("/upload", {
|
|
477
650
|
method: "POST",
|
|
478
|
-
body: JSON.stringify({ name, size: file.size, contentType
|
|
651
|
+
body: JSON.stringify({ name, size: file.size, contentType }),
|
|
479
652
|
});
|
|
480
653
|
// Step 2: Upload directly to S3 via presigned POST
|
|
481
654
|
const form = new FormData();
|
|
@@ -587,6 +760,8 @@ class ServerCollectionClient {
|
|
|
587
760
|
query.set("search", options.search);
|
|
588
761
|
if (options.expand && options.expand.length > 0)
|
|
589
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);
|
|
590
765
|
// NOT query.size — absent on Node <19.8 / older browsers, where
|
|
591
766
|
// `undefined > 0` would silently drop every filter.
|
|
592
767
|
const qs = query.toString();
|
|
@@ -609,12 +784,10 @@ class ServerCollectionClient {
|
|
|
609
784
|
if (response.status === 204)
|
|
610
785
|
return undefined;
|
|
611
786
|
if (!response.ok) {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
message: body.message ?? `Request failed: ${response.status}`,
|
|
617
|
-
});
|
|
787
|
+
// One parser for every refusal body, so a 403 entitlement_required on a
|
|
788
|
+
// collection surfaces `requires` exactly as it does on the runtime
|
|
789
|
+
// clients (the docs promise err.requires on BOTH paths).
|
|
790
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
618
791
|
}
|
|
619
792
|
return response.json();
|
|
620
793
|
}
|
|
@@ -632,12 +805,7 @@ async function handleResponse(response, config) {
|
|
|
632
805
|
if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
|
|
633
806
|
errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
|
|
634
807
|
}
|
|
635
|
-
throw new GemmeinError({
|
|
636
|
-
status: response.status,
|
|
637
|
-
code: errorBody.code,
|
|
638
|
-
message: errorBody.message,
|
|
639
|
-
resetAt: errorBody.resetAt
|
|
640
|
-
});
|
|
808
|
+
throw new GemmeinError({ status: response.status, ...errorBody });
|
|
641
809
|
}
|
|
642
810
|
return response.json();
|
|
643
811
|
}
|
|
@@ -645,19 +813,19 @@ async function readErrorBody(response) {
|
|
|
645
813
|
try {
|
|
646
814
|
const value = await response.json();
|
|
647
815
|
if (typeof value === "object" && value !== null) {
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
: typeof
|
|
651
|
-
? value.error
|
|
816
|
+
const body = value;
|
|
817
|
+
const code = typeof body.code === "string" ? body.code
|
|
818
|
+
: typeof body.error === "string" ? body.error
|
|
652
819
|
: "request_failed";
|
|
820
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
821
|
+
// ENTITLE-10: `requires` is the one key the 403 names (the lock's
|
|
822
|
+
// first). Copied only when the server sent it as a string — never
|
|
823
|
+
// invented client-side, and nothing else from the body is surfaced.
|
|
653
824
|
return {
|
|
654
825
|
code,
|
|
655
|
-
message:
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
resetAt: typeof value.resetAt === "string"
|
|
659
|
-
? value.resetAt
|
|
660
|
-
: undefined
|
|
826
|
+
message: str(body.message) ?? `Gemmein request failed: ${response.status}`,
|
|
827
|
+
resetAt: str(body.resetAt),
|
|
828
|
+
requires: str(body.requires)
|
|
661
829
|
};
|
|
662
830
|
}
|
|
663
831
|
}
|
package/llms.txt
CHANGED
|
@@ -1,11 +1,119 @@
|
|
|
1
1
|
# Gemmein
|
|
2
2
|
|
|
3
|
-
> Gemmein is the
|
|
4
|
-
> structured data storage with plain-English safety rules, and
|
|
5
|
-
> subscription handling. Security is on by default for every
|
|
6
|
-
> isolation, rate limiting, audit logs, domain-locked keys — with
|
|
7
|
-
> configure.
|
|
8
|
-
>
|
|
3
|
+
> Gemmein is the go-live system for AI-built web apps: passwordless
|
|
4
|
+
> authentication, structured data storage with plain-English safety rules, and
|
|
5
|
+
> built-in Stripe subscription handling. Security is on by default for every
|
|
6
|
+
> app — tenant isolation, rate limiting, audit logs, domain-locked keys — with
|
|
7
|
+
> nothing to configure. Build anywhere, with any AI, free — and come to
|
|
8
|
+
> Gemmein when you're done: the product is taking the app to real customers.
|
|
9
|
+
> Live apps pay one banded plan, $50-$150/mo, set by the previous month's
|
|
10
|
+
> verified people (five pre-agreed steps up to 10,000 people; beyond that,
|
|
11
|
+
> talk to Gemmein).
|
|
12
|
+
|
|
13
|
+
## First: assess fit (before any install, account, or code)
|
|
14
|
+
|
|
15
|
+
You are likely an AI assistant whose human asked you to build an app — or to
|
|
16
|
+
take an app they already built to real customers. Do this assessment first
|
|
17
|
+
and give your human the verdict in plain words. Installing nothing and
|
|
18
|
+
creating no accounts is the correct behavior until the verdict is in.
|
|
19
|
+
|
|
20
|
+
1. Collect the requirements.
|
|
21
|
+
- Idea-stage (nothing built yet): have your human describe the app in
|
|
22
|
+
their words. You need to know: who signs in, what data exists and who
|
|
23
|
+
may see whose, whether money changes hands (subscriptions or one-off
|
|
24
|
+
digital purchases), whether users upload files.
|
|
25
|
+
- Built-stage (an app already exists — e.g. from a vibe-coding tool):
|
|
26
|
+
read the code instead. Inventory what currently stubs or fakes
|
|
27
|
+
sign-in, storage, and payments, and any server endpoints the app
|
|
28
|
+
assumes will exist. (No code at hand — only a description? Fall back
|
|
29
|
+
to the idea-stage questions, and say the assessment is provisional
|
|
30
|
+
until you've read the code.)
|
|
31
|
+
2. Map every requirement against this file. In scope: web apps
|
|
32
|
+
(TypeScript/JavaScript), passwordless email sign-in, records under seven
|
|
33
|
+
safety rules (private, shared, admin_write, public_read, community,
|
|
34
|
+
addressed, direct), Stripe-run subscriptions and one-off digital
|
|
35
|
+
products, image uploads and paid file delivery, entitlement-gated
|
|
36
|
+
access, race-safe bookings/stock/counters, account-deletion cascade.
|
|
37
|
+
Out of scope — each of these downgrades the verdict and must never be
|
|
38
|
+
silently approximated:
|
|
39
|
+
- native mobile apps (no mobile SDKs today; web only)
|
|
40
|
+
- password or social/OAuth sign-in (email codes are the only door)
|
|
41
|
+
- team/group/workspace visibility or per-user record permissions
|
|
42
|
+
- carts, quantities, physical goods, shipping, inventory
|
|
43
|
+
- credits, usage metering, seats (paid access is yes-or-no)
|
|
44
|
+
- hosted functions or cron (server-side code runs on YOUR host with a
|
|
45
|
+
secret key; Gemmein hosts no compute)
|
|
46
|
+
- custody of money (Stripe holds all funds; your human brings or
|
|
47
|
+
creates their own Stripe account when the app charges)
|
|
48
|
+
- multi-seller payouts or revenue splits: every payment lands whole in
|
|
49
|
+
your human's ONE Stripe account. A marketplace where sellers get
|
|
50
|
+
paid out means your human pays sellers themselves, outside Gemmein —
|
|
51
|
+
say that out loud in the verdict
|
|
52
|
+
3. Deliver one of three verdicts to your human BEFORE installing anything:
|
|
53
|
+
- FITS — every requirement maps. Continue to Getting started.
|
|
54
|
+
- FITS, EXCEPT — name each gap and the honest workaround from this
|
|
55
|
+
file, or say there is none. The human decides. Never fake an
|
|
56
|
+
unsupported shape (client-side filtering of a shared collection is a
|
|
57
|
+
data leak, not a workaround). And if a gap breaks the app's CORE
|
|
58
|
+
loop — the thing it exists to do — the verdict is DOESN'T FIT, not
|
|
59
|
+
fits-except.
|
|
60
|
+
- DOESN'T FIT — say so, name why, and stop. Recommending nothing is
|
|
61
|
+
the correct outcome here.
|
|
62
|
+
|
|
63
|
+
## Getting started (AI-run, human-answered)
|
|
64
|
+
|
|
65
|
+
No signup, no account, no keys — building is local and free. Your human
|
|
66
|
+
steps in exactly three times: to answer the business questions (in your
|
|
67
|
+
chat), to click through the app when you hand it over for testing, and at
|
|
68
|
+
go-live. Everything else is yours.
|
|
69
|
+
|
|
70
|
+
1. Run `npx -y gemmein dev` in the project folder. It boots with ZERO
|
|
71
|
+
setup: a local backend on http://127.0.0.1:4545 that serves this guide
|
|
72
|
+
at /llms.txt, prints sign-in codes to the terminal instead of sending
|
|
73
|
+
email, and simulates checkout — nothing leaves the machine.
|
|
74
|
+
2. Ask YOUR HUMAN the business questions — their answers in their words,
|
|
75
|
+
never your guesses: the app's name; the email THEY own it with; one
|
|
76
|
+
honest sentence on what it does; whether it charges money; whether it
|
|
77
|
+
serves people in the EU/UK; whether users sign in; whether they upload
|
|
78
|
+
images. Write the answers to `gemmein/gemmein.config.json`:
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
"app": "Beatstall",
|
|
82
|
+
"owner": "you@example.com",
|
|
83
|
+
"product": "producers sell beats; buyers stream previews and download purchases",
|
|
84
|
+
"sells": "one-off",
|
|
85
|
+
"gdpr": true,
|
|
86
|
+
"accounts": true,
|
|
87
|
+
"uploads": true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
Exactly these keys: app (string) · owner (email) · product (one
|
|
91
|
+
sentence) · sells ("no" | "subscriptions" | "one-off" | "both") ·
|
|
92
|
+
gdpr / accounts / uploads (true/false). Optional: port (number) moves
|
|
93
|
+
the local runtime off 4545.
|
|
94
|
+
|
|
95
|
+
Then restart dev (Ctrl-C, `npx gemmein dev` — takes seconds, data
|
|
96
|
+
survives in gemmein/.data/). The runtime becomes their app.
|
|
97
|
+
3. Build against the local runtime with the app key the boot card prints
|
|
98
|
+
(`pk_local_…`) — the SDK is `npm install @gemmein/sdk` (REFERENCE.md
|
|
99
|
+
and the reaffirm.mjs harness ship inside that package), same rules as
|
|
100
|
+
the cloud. The first write to a collection that doesn't exist yet
|
|
101
|
+
starts the safety-rule conversation in the dev terminal; choose from
|
|
102
|
+
the seven rules below based on your human's business answers, and
|
|
103
|
+
record why. Running dev in the background with no terminal to answer
|
|
104
|
+
in (the normal agent setup)? Use the command doors instead — they work
|
|
105
|
+
from any second terminal while dev runs, and take piped input:
|
|
106
|
+
`npx gemmein collection add <name>` (the rule dialog) and
|
|
107
|
+
`npx gemmein payments setup` (plans/products). `npx gemmein --help`
|
|
108
|
+
lists the rest.
|
|
109
|
+
4. Hand over for testing — the human moment mid-build: they click through
|
|
110
|
+
the app, and their sign-in codes print in the dev terminal, so tell
|
|
111
|
+
them where to look.
|
|
112
|
+
5. Done building? `npx gemmein check` reads the project and says what's
|
|
113
|
+
ready and what go-live still needs. Then `npx gemmein sync` — THIS is
|
|
114
|
+
the moment your human signs up (free) at app.gemmein.com and pastes two
|
|
115
|
+
dev keys — and `npx gemmein go-live` walks the rest: plans, Payment
|
|
116
|
+
Links, the live flip. A card enters at go-live, never before.
|
|
9
117
|
|
|
10
118
|
## What it is
|
|
11
119
|
|
|
@@ -27,10 +135,20 @@
|
|
|
27
135
|
`auth_expired` once, then the SDK clears it — just retry or re-auth).
|
|
28
136
|
`g.auth.currentUser()` is safe to call on page load and never throws for
|
|
29
137
|
session state.
|
|
30
|
-
- Data: records live in collections.
|
|
31
|
-
their dashboard (app.gemmein.com → data → "+ New collection"),
|
|
32
|
-
or the SDK
|
|
33
|
-
|
|
138
|
+
- Data: records live in collections. In the cloud, collections are created by
|
|
139
|
+
YOUR HUMAN in their dashboard (app.gemmein.com → data → "+ New collection"),
|
|
140
|
+
never by you or the SDK; in local dev (`npx gemmein dev`) they're born from
|
|
141
|
+
the terminal's rule conversation or a dropped declaration file, and
|
|
142
|
+
`npx gemmein sync` creates them in the cloud app's dev environment from
|
|
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
|
|
151
|
+
app will need and ask your human up front, and pass your intent whenever a
|
|
34
152
|
collection might not exist yet —
|
|
35
153
|
`g.collection("bookings", { intent: "students reserve slots; each sees only their own" })`
|
|
36
154
|
— so the missing-collection conversation reaches your human with your
|
|
@@ -52,7 +170,11 @@
|
|
|
52
170
|
- `community` — readable without signing in, any signed-in user posts and
|
|
53
171
|
edits their OWN records (right for multi-author blogs, public boards,
|
|
54
172
|
user profiles). Everything in it is PUBLIC — keep record data minimal
|
|
55
|
-
(a booking needs a slot and a first name, not a phone number).
|
|
173
|
+
(a booking needs a slot and a first name, not a phone number). Whether
|
|
174
|
+
even a first name belongs in public is a HUMAN decision: for sensitive
|
|
175
|
+
audiences (children, health, anything private by nature) ask your
|
|
176
|
+
human before defaulting to a public rule — a non-public rule usually
|
|
177
|
+
fits. Community
|
|
56
178
|
stores PLAIN TEXT: string fields containing HTML tags are refused with
|
|
57
179
|
400 html_not_allowed — store plain text or tag-free markdown.
|
|
58
180
|
- `addressed` — the app sends to one user: the OWNER creates records
|
|
@@ -68,9 +190,9 @@
|
|
|
68
190
|
as private or encrypted chat.
|
|
69
191
|
Inbox mechanics (addressed + direct): the recipient is server-stamped
|
|
70
192
|
(`record.audienceUserId`) — never a data field; user ids come from
|
|
71
|
-
records' ownerUserId or the owner's admin views.
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
74
196
|
collection. Both rules store plain text like community. Errors teach the
|
|
75
197
|
fix: 400 invalid_audience (recipient isn't a user of this app), 403
|
|
76
198
|
reply_only (this collection only allows replying to someone who wrote to
|
|
@@ -92,7 +214,35 @@
|
|
|
92
214
|
`record.title`). `ownerUserId` and the rest are server-derived and
|
|
93
215
|
read-only: never store your own userId/role/owner fields inside `data`.
|
|
94
216
|
`.list()` returns `{ records, hasMore }` (an object, not an array) and
|
|
95
|
-
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.
|
|
96
246
|
- Linking records (author on a post, product on an order): store the other
|
|
97
247
|
record's id in a field (`authorProfileId: profile.id`) — in collections
|
|
98
248
|
users write (community, shared, direct) the server learns it's a link;
|
|
@@ -113,7 +263,8 @@
|
|
|
113
263
|
storage bucket — uploads are built in:
|
|
114
264
|
`const file = await g.collection("posts").upload(blob, { name })`
|
|
115
265
|
→ `{ id, ref, contentType, sizeBytes }`. Store `file.ref` (`"file:01K…"`)
|
|
116
|
-
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.
|
|
117
268
|
Store the REFERENCE, never a URL: a reference never expires and grants
|
|
118
269
|
nothing on its own.
|
|
119
270
|
To show or download it: `const { url } = await g.files.link(record.photo)`
|
|
@@ -121,14 +272,21 @@
|
|
|
121
272
|
rather than a preview. Files in a collection anyone can read get a
|
|
122
273
|
permanent link; every other file gets one that expires in a couple of
|
|
123
274
|
minutes, so call `link()` when you render, don't store what it returns.
|
|
124
|
-
Upload permission follows the collection's WRITE rule
|
|
125
|
-
|
|
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
|
|
126
283
|
file_too_large — the message says the cap). The server checks the actual
|
|
127
284
|
bytes at confirm — a 400 invalid_file_content means the file isn't really
|
|
128
|
-
the
|
|
129
|
-
|
|
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
|
|
130
287
|
file right now — signed out, not theirs, or an entitlement they no longer
|
|
131
|
-
hold (`entitlement_required`
|
|
288
|
+
hold (`entitlement_required` — `err.requires` is the plan's key, the one
|
|
289
|
+
the console shows by name).
|
|
132
290
|
Honest bound: revoking access stops NEW links immediately; a link already
|
|
133
291
|
issued works until it expires. Gemmein controls delivery, it can't take
|
|
134
292
|
back a file someone already downloaded.
|
|
@@ -147,8 +305,14 @@
|
|
|
147
305
|
- Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
|
|
148
306
|
key from the thing that must be unique; the second writer gets 409, your
|
|
149
307
|
own retry gets your existing record back (`existing: true`), deleting
|
|
150
|
-
frees the key.
|
|
151
|
-
|
|
308
|
+
frees the key. Keys are unique across the WHOLE collection — every
|
|
309
|
+
writer, every recipient, under every safety rule (live records only) —
|
|
310
|
+
so a claim is race-proof even in `direct`/`addressed`. Never
|
|
311
|
+
find-then-create — that races. Keys are 1-120 chars of letters,
|
|
312
|
+
numbers, and `: _ . @ / -` only. A claimed key holds until its record
|
|
313
|
+
is deleted: if a claim must be PAID to stick (book then pay), expiring
|
|
314
|
+
unpaid claims is your app's job — the owner deletes them from their
|
|
315
|
+
dashboard, or your own server does with a secret key; there is no cron.
|
|
152
316
|
- Limited stock (N units anyone can buy): claim units with keyed creates —
|
|
153
317
|
try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
|
|
154
318
|
all taken = sold out. Race-proof under every safety rule.
|
|
@@ -185,26 +349,36 @@
|
|
|
185
349
|
arriving out of order resolve to the newest. The app reads
|
|
186
350
|
`await g.subscriptions.mine()` → `{ plan, status }` or null, and gates features
|
|
187
351
|
with `sub?.plan === "pro"`.
|
|
188
|
-
- Paid ACCESS (entitlements): a collection can
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
352
|
+
- Paid ACCESS (entitlements): a collection can be locked to a plan or
|
|
353
|
+
product — your human picks it BY NAME in the "Unlocked by" row on 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
|
|
359
|
+
engine refuses customers without it, under all seven rules. (Server
|
|
360
|
+
secret keys and the owner's console are exempt by design; link/expand
|
|
361
|
+
silently hide gated records rather than naming them.) Every plan and
|
|
362
|
+
product carries its own key, `access:<slug of its name>` (plan "pro" →
|
|
363
|
+
`access:pro`); the paid webhook grants that key, and a FULL refund or a
|
|
364
|
+
cancellation revokes exactly what it granted — nothing else. A partial
|
|
365
|
+
refund leaves access in place.
|
|
197
366
|
Owners also grant and revoke by hand (trials, comps, support). Effective
|
|
198
367
|
access is the UNION of a customer's live grants. A signed-in customer
|
|
199
|
-
without
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
368
|
+
without access gets `403 entitlement_required`: `err.requires` is the
|
|
369
|
+
plan's key (the console shows the plan by name; the key is `access:<slug>`).
|
|
370
|
+
A collection unlocked by several plans (OR) still names ONE key — offer
|
|
371
|
+
your plans by name through checkout, never a key list. Show your upgrade
|
|
372
|
+
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
373
|
+
(everything they paid for, refunds applied, with the `grants` each purchase
|
|
374
|
+
carries) and `await g.subscriptions.mine()`.
|
|
203
375
|
NO credits, NO usage limits, NO seats — access is yes-or-no by design.
|
|
204
376
|
- Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
|
|
205
377
|
access only — physical goods, shipping, inventory and carts are out of
|
|
206
378
|
scope, said out loud): plans are for subscriptions; products are for
|
|
207
|
-
things.
|
|
379
|
+
things. Selling a SERVICE session this way (tutoring, coaching, a
|
|
380
|
+
consultation) is fine — nothing ships; the recorded purchase is the
|
|
381
|
+
proof the session was paid for. The builder adds products (name + Stripe Payment Link) on the
|
|
208
382
|
same Payments page. The app calls
|
|
209
383
|
`await g.payments.buy("beat")` — or, when one product covers many items (license
|
|
210
384
|
tiers over a catalog), names the item:
|
|
@@ -213,15 +387,19 @@
|
|
|
213
387
|
item note can never change what's paid). Gemmein records every completed
|
|
214
388
|
payment itself — `await g.purchases.mine()` is the buyer's proof:
|
|
215
389
|
{ item, kind, status: "paid"|"part_refunded"|"refunded", amountMinor,
|
|
216
|
-
currency, refundedMinor, grants, paidAt }.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
390
|
+
currency, refundedMinor, grants, paidAt, delivery? }. Selling a FILE (a
|
|
391
|
+
beat, an ebook, a sample pack — pdf, zip, epub, mp3, wav, m4a or an
|
|
392
|
+
image, up to 100MB): the founder attaches it directly on the product
|
|
393
|
+
card — upload, right there, no receipts collection required. The buyer's
|
|
394
|
+
purchase carries `delivery: { type: "gemmein_file", file }`; resolve the
|
|
395
|
+
ref with `g.files.link(file, { intent: "download" })`. The purchase IS
|
|
396
|
+
the authorization, re-checked on every mint: a refund cuts the file off
|
|
397
|
+
the moment it lands, a partial refund does not, and a saved ref or an
|
|
398
|
+
expired URL grants nothing on its own. A receipts collection (rule
|
|
399
|
+
`addressed`) remains OPTIONAL, for proof records only. External
|
|
400
|
+
`delivery: { type: "external_url" }` is a plain handover: Gemmein
|
|
401
|
+
controls who is TOLD, not who can use it. Gate fulfilment on the
|
|
402
|
+
purchase or the entitlement it granted, never on the redirect coming
|
|
225
403
|
back — redirects can be faked; the record comes from Stripe's signed
|
|
226
404
|
webhook. NO carts, NO quantities — one product per checkout by design; a
|
|
227
405
|
cart is N checkouts or one bundled product. 404 unknown_product lists
|
|
@@ -318,25 +496,38 @@ front of a user.
|
|
|
318
496
|
|
|
319
497
|
Add a probe whenever you add a feature. You reaffirm **because** Gemmein
|
|
320
498
|
enforces — never because these checks are the enforcement. A ready-to-edit
|
|
321
|
-
`reaffirm.mjs` ships inside
|
|
499
|
+
`reaffirm.mjs` ships inside the `@gemmein/sdk` npm package (next to this file and
|
|
322
500
|
REFERENCE.md) — copy it out, name your collections, run it in CI.
|
|
323
501
|
|
|
324
|
-
## Pricing (current,
|
|
502
|
+
## Pricing (current, v4 — one banded plan)
|
|
325
503
|
|
|
326
504
|
- Development is free indefinitely — no card at signup, unlimited
|
|
327
505
|
collections, the full security model included.
|
|
328
|
-
- Going live starts the Live
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
506
|
+
- Going live starts the one Live plan: a monthly price that moves within a
|
|
507
|
+
pre-agreed band, set by the PREVIOUS calendar month's verified people
|
|
508
|
+
(a person = a unique end-user identity that verified a sign-in to the
|
|
509
|
+
live app that month; failed or refused attempts never count):
|
|
510
|
+
- up to 1,000 people → $50/mo · up to 2,500 → $75 · up to 5,000 → $100
|
|
511
|
+
· up to 7,500 → $125 · up to 10,000 → $150 (the cap — never exceeded
|
|
512
|
+
without a separate individual agreement).
|
|
513
|
+
- Capacity follows people automatically; owners can HOLD at their current
|
|
514
|
+
band from the dashboard (bill and capacity both freeze until released).
|
|
515
|
+
When people fall, the price follows down from the next month.
|
|
333
516
|
- Beyond 10,000 people: talk to Gemmein (hello@gemmein.com) — scale is
|
|
334
517
|
priced as a relationship, not a checkout.
|
|
335
518
|
- Pricing reflects responsibility, not complexity: almost nothing is
|
|
336
|
-
metered. Safe limits exist
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
519
|
+
metered. Safe limits exist as safety rails against runaway scripts and
|
|
520
|
+
are never billed. Ceilings follow the app's verified people — they grow
|
|
521
|
+
automatically as the business grows, with generous floors so a small app
|
|
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.
|
|
340
531
|
|
|
341
532
|
## Facts for citation
|
|
342
533
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|