@gemmein/sdk 0.4.3 → 0.4.5
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/REFERENCE.md +83 -7
- package/dist/index.cjs +7 -1
- package/dist/index.d.cts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -1
- package/llms.txt +24 -6
- package/package.json +2 -2
package/REFERENCE.md
CHANGED
|
@@ -21,8 +21,10 @@ The browser client. `appKey` is your public `pk_...` key. `options` (optional):
|
|
|
21
21
|
|
|
22
22
|
### `gemmeinServer(secretKey, options?) → GemmeinServer`
|
|
23
23
|
Server-only client for a `sk_...` secret key — **never ship this to the
|
|
24
|
-
browser.** Exposes read/update on collections without a signed-in user,
|
|
25
|
-
`
|
|
24
|
+
browser.** Exposes read/update on collections without a signed-in user,
|
|
25
|
+
`notify()` to email one of your app's own verified people (see **Notify**),
|
|
26
|
+
plus `testSession()` for CI self-tests (dev environments only — see
|
|
27
|
+
**Reaffirm**).
|
|
26
28
|
|
|
27
29
|
The client has two layers. **Your app's collections** — `g.collection(name)`
|
|
28
30
|
(the canonical spelling; `g.storage.collection(name)` is the same client). And
|
|
@@ -88,10 +90,11 @@ certain the collection exists yet.
|
|
|
88
90
|
|--------|-----------|---------|
|
|
89
91
|
| `create` | `(data: T, options?: { key?: string; for?: string; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
|
|
90
92
|
| `list` | `(options?: ListOptions)` | `Promise<ListResult<T>>` |
|
|
93
|
+
| `watch` | `(onChange: (delta: { records; deleted; initial }) => void, options?: { every?: number; where?; search?; limit? })` | `{ stop(): void }` — see **Live data** |
|
|
91
94
|
| `get` | `(id: string, options?: { expand?: string[] })` | `Promise<GemmeinRecord<T>>` |
|
|
92
95
|
| `update` | `(id: string, data: Partial<T> \| { field: { increment\|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
|
|
93
96
|
| `delete` | `(id: string)` | `Promise<void>` |
|
|
94
|
-
| `upload` | `(file: Blob \| File, options?: { name?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
|
|
97
|
+
| `upload` | `(file: Blob \| File, options?: { name?: string; contentType?: string; for?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
|
|
95
98
|
|
|
96
99
|
### Files
|
|
97
100
|
|
|
@@ -126,6 +129,29 @@ immediately; a link already issued works until it expires, and a download that
|
|
|
126
129
|
started before expiry may finish after it. Controlled delivery, not DRM —
|
|
127
130
|
nothing takes back a file someone already downloaded.
|
|
128
131
|
|
|
132
|
+
**What uploads take.** Images — JPEG, PNG, WebP, GIF, HEIC — and documents —
|
|
133
|
+
PDF, ZIP, EPUB — at **25 MB per file**; nothing else (no video, audio, SVG,
|
|
134
|
+
HTML or office files — zip an office file). The server checks the actual
|
|
135
|
+
bytes, not the filename. A document always **downloads** (served as an
|
|
136
|
+
attachment; it never opens inside the page) — link it with
|
|
137
|
+
`{ intent: "download" }` — and pass `contentType` to `upload()` when a Blob
|
|
138
|
+
doesn't carry its own type.
|
|
139
|
+
|
|
140
|
+
**Handing a file to one person.** On `addressed`/`direct` collections,
|
|
141
|
+
`upload(file, { for: userId })` stamps that person as the file's audience at
|
|
142
|
+
birth — immutable, and only they (and you, the owner) can ever `link()` it.
|
|
143
|
+
That's how a direct message carries an attachment. A field holding file
|
|
144
|
+
references is learned as a **file field** — image or document, from the real
|
|
145
|
+
uploads. The file law is UNIVERSAL: on every rule, locked or not, any
|
|
146
|
+
top-level field value matching the ref grammar must name a real file you
|
|
147
|
+
could read — a made-up ref is refused (`unknown_file`). Once the shape is
|
|
148
|
+
sealed, the field's class is law too: the wrong kind teaches on the wire
|
|
149
|
+
(`invalid_shape`, the message names which kind the field takes); before the
|
|
150
|
+
seal, learning owns class (seen-both widens to "any"). A stored ref whose
|
|
151
|
+
file was later deleted refuses the same way on re-write — clear the field
|
|
152
|
+
(`null`) or upload a fresh file. Refs inside `list`/`json` values aren't
|
|
153
|
+
judged at write; they still grant nothing at read.
|
|
154
|
+
|
|
129
155
|
```ts
|
|
130
156
|
type GemmeinRecord<T> = {
|
|
131
157
|
id: string;
|
|
@@ -144,7 +170,11 @@ type GemmeinRecord<T> = {
|
|
|
144
170
|
existing?: true; // present only when a keyed create returned YOUR existing record
|
|
145
171
|
};
|
|
146
172
|
|
|
147
|
-
type ListResult<T> = {
|
|
173
|
+
type ListResult<T> = {
|
|
174
|
+
records: GemmeinRecord<T>[]; cursor?: string; hasMore: boolean;
|
|
175
|
+
deleted?: string[]; // on a `since` read: ids deleted after that instant — your rule scope only
|
|
176
|
+
watermark?: string; // pass as the next `since`; paging a plain list in full, adopt the FIRST page's
|
|
177
|
+
};
|
|
148
178
|
|
|
149
179
|
type ListOptions = {
|
|
150
180
|
limit?: number;
|
|
@@ -153,6 +183,8 @@ type ListOptions = {
|
|
|
153
183
|
cursor?: string; // from a previous ListResult
|
|
154
184
|
search?: string; // free-text across data
|
|
155
185
|
expand?: string[]; // link fields to embed (≤3), only on community/shared/direct
|
|
186
|
+
since?: string; // everything changed OR deleted after this instant, oldest first
|
|
187
|
+
// — the previous answer's watermark; incompatible with sort
|
|
156
188
|
};
|
|
157
189
|
```
|
|
158
190
|
|
|
@@ -162,6 +194,19 @@ recipient id on `addressed`/`direct`. `published: false` = draft on a public
|
|
|
162
194
|
rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
|
|
163
195
|
position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
|
|
164
196
|
on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
|
|
197
|
+
`since` fixes the order (oldest change first) — pairing it with `sort` is a 400
|
|
198
|
+
`invalid_since`. Deltas are at-least-once: apply by id.
|
|
199
|
+
|
|
200
|
+
### Live data — `watch()`
|
|
201
|
+
|
|
202
|
+
`watch(onChange, { every? })` keeps a list fresh by polling `list({ since })`
|
|
203
|
+
— every 10 s by default, clamped to 5 s–300 s. `onChange` receives
|
|
204
|
+
`{ records, deleted, initial }`: the first call is the full list
|
|
205
|
+
(`initial: true`); every later call only what changed or was deleted. It
|
|
206
|
+
sleeps while the tab is hidden and resyncs in full on return, backs off on
|
|
207
|
+
rate limits (honouring `resetAt`), never overlaps its own requests, and stops
|
|
208
|
+
itself on 401/403. Returns `{ stop() }` — call it when the view unmounts.
|
|
209
|
+
Never hand-roll a polling loop; this is the sanctioned one.
|
|
165
210
|
|
|
166
211
|
---
|
|
167
212
|
|
|
@@ -172,6 +217,7 @@ on `private`/`public_read`/`admin_write` (no link shape) — join in memory ther
|
|
|
172
217
|
| `subscriptions.mine` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
|
|
173
218
|
| `subscriptions.checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
|
|
174
219
|
| `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
|
|
220
|
+
| `purchases.mine` | `()` | `Promise<Purchase[]>` — the array itself (not wrapped): `{ item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }` — gate one-off fulfilment on this, never the redirect |
|
|
175
221
|
|
|
176
222
|
Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
177
223
|
`buy` self-navigate via `window.location` — just `await` them on the click;
|
|
@@ -181,6 +227,32 @@ gate one-off fulfilment on the receipt record, never the redirect.
|
|
|
181
227
|
|
|
182
228
|
---
|
|
183
229
|
|
|
230
|
+
## Notify — `gemmeinServer(sk).notify(personId, input)`
|
|
231
|
+
|
|
232
|
+
Your app's **server** emails one of its **own verified people** — by person
|
|
233
|
+
id, never an address. A missing, cross-environment, or suspended id is one
|
|
234
|
+
`404 not_a_customer` on purpose: existence is never leaked.
|
|
235
|
+
|
|
236
|
+
`input`: `{ subject: string; text: string; kind?: "event" | "account"; key?: string }`
|
|
237
|
+
→ `Promise<{ sent: boolean; deduped?: boolean; recorded?: boolean; id: string | null; threadId: string | null; replyRail?: boolean }>`
|
|
238
|
+
(`recorded: false` means the email went but Inbox recording failed — reported
|
|
239
|
+
honestly, not hidden.)
|
|
240
|
+
|
|
241
|
+
- `kind: "event"` (the default) — order shipped, booking confirmed. Capped at
|
|
242
|
+
**5 per person per day** so a bug can never flood an inbox, and 200 per app
|
|
243
|
+
per hour (`resetAt` rides the 429). `kind: "account"` — sign-in, access,
|
|
244
|
+
billing trouble — is exempt from the per-person cap (a security notice never
|
|
245
|
+
loses to order emails), same hourly app cap.
|
|
246
|
+
- `key` makes the send at-most-once through retries: a delivered send answers
|
|
247
|
+
idempotently (`deduped: true`); a concurrent twin gets `409 in_flight` —
|
|
248
|
+
retry in a moment.
|
|
249
|
+
- Plain text only — this is a notification, not a campaign. Every email
|
|
250
|
+
carries a footer naming why it arrived; replies land in the owner's Inbox
|
|
251
|
+
as a thread (`threadId`), and `replyRail` states honestly whether
|
|
252
|
+
reply-by-email is on. Every send is on the record.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
184
256
|
## Reaffirm — prove your app's boundaries in CI
|
|
185
257
|
|
|
186
258
|
Gemmein enforces the rules **server-side**, so your frontend is never the source
|
|
@@ -288,17 +360,21 @@ Branch on `err.code`. The stable codes:
|
|
|
288
360
|
| `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
|
|
289
361
|
| `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
|
|
290
362
|
| `invalid_publish` | `{ published }` on a non-public rule | drop it |
|
|
291
|
-
| `invalid_shape` | field not in a locked (live) collection's shape | ask the owner to add it |
|
|
363
|
+
| `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) | ask the owner to add it / send the kind the field takes |
|
|
364
|
+
| `unknown_file` | a ref-shaped value points at a file that doesn't exist or isn't yours to hand — every rule, every write | fix the ref — never invent one |
|
|
365
|
+
| `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
|
|
366
|
+
| `not_a_customer` (404) | `notify()`'s recipient isn't a verified person of this app and environment | fix the person id — one code on purpose |
|
|
367
|
+
| `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
|
|
292
368
|
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
293
369
|
| `unknown_record` | a link field points at a missing record (live only) | fix the id |
|
|
294
370
|
| `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
|
|
295
|
-
| `invalid_file_content` | uploaded bytes aren't the claimed
|
|
371
|
+
| `invalid_file_content` | uploaded bytes aren't the claimed type (usually a renamed file) | send the real file |
|
|
296
372
|
| `test_session_forbidden_live` | `testSession()` called on a live env / `sk_live` key | reaffirm's Tier B is dev-only — point it at your dev environment |
|
|
297
373
|
| `unknown_plan` | no plan by that name | use a name from the list in the message |
|
|
298
374
|
| `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
|
|
299
375
|
| `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
|
|
300
376
|
| `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
|
|
301
|
-
| `unsupported_file_type` (415) | upload isn't
|
|
377
|
+
| `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
|
|
302
378
|
| `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
|
|
303
379
|
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
304
380
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
package/dist/index.cjs
CHANGED
|
@@ -671,6 +671,12 @@ class CollectionClient {
|
|
|
671
671
|
* Pass `{ name }` so the download carries a real filename, and
|
|
672
672
|
* `{ contentType }` when the Blob has no type of its own.
|
|
673
673
|
*
|
|
674
|
+
* W7.4 — `{ for: userId }` names ONE other person who may read this file
|
|
675
|
+
* (addressed and direct collections only — the rules where recipients
|
|
676
|
+
* exist). The attachment on a direct message: upload with
|
|
677
|
+
* `{ for: recipientId }`, store the ref in the record you send them, and
|
|
678
|
+
* their `g.files.link(ref)` works. Without `for`, only you can link it.
|
|
679
|
+
*
|
|
674
680
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
675
681
|
* bug this replaced.
|
|
676
682
|
*/
|
|
@@ -682,7 +688,7 @@ class CollectionClient {
|
|
|
682
688
|
// Step 1: Get presigned upload URL
|
|
683
689
|
const presign = await this.request("/upload", {
|
|
684
690
|
method: "POST",
|
|
685
|
-
body: JSON.stringify({ name, size: file.size, contentType }),
|
|
691
|
+
body: JSON.stringify({ name, size: file.size, contentType, ...(options?.for ? { for: options.for } : {}) }),
|
|
686
692
|
});
|
|
687
693
|
// Step 2: Upload directly to S3 via presigned POST
|
|
688
694
|
const form = new FormData();
|
package/dist/index.d.cts
CHANGED
|
@@ -500,12 +500,19 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
|
|
|
500
500
|
* Pass `{ name }` so the download carries a real filename, and
|
|
501
501
|
* `{ contentType }` when the Blob has no type of its own.
|
|
502
502
|
*
|
|
503
|
+
* W7.4 — `{ for: userId }` names ONE other person who may read this file
|
|
504
|
+
* (addressed and direct collections only — the rules where recipients
|
|
505
|
+
* exist). The attachment on a direct message: upload with
|
|
506
|
+
* `{ for: recipientId }`, store the ref in the record you send them, and
|
|
507
|
+
* their `g.files.link(ref)` works. Without `for`, only you can link it.
|
|
508
|
+
*
|
|
503
509
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
504
510
|
* bug this replaced.
|
|
505
511
|
*/
|
|
506
512
|
upload(file: Blob | File, options?: {
|
|
507
513
|
name?: string;
|
|
508
514
|
contentType?: string;
|
|
515
|
+
for?: string;
|
|
509
516
|
}): Promise<{
|
|
510
517
|
id: string;
|
|
511
518
|
ref: FileRef;
|
package/dist/index.d.ts
CHANGED
|
@@ -500,12 +500,19 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
|
|
|
500
500
|
* Pass `{ name }` so the download carries a real filename, and
|
|
501
501
|
* `{ contentType }` when the Blob has no type of its own.
|
|
502
502
|
*
|
|
503
|
+
* W7.4 — `{ for: userId }` names ONE other person who may read this file
|
|
504
|
+
* (addressed and direct collections only — the rules where recipients
|
|
505
|
+
* exist). The attachment on a direct message: upload with
|
|
506
|
+
* `{ for: recipientId }`, store the ref in the record you send them, and
|
|
507
|
+
* their `g.files.link(ref)` works. Without `for`, only you can link it.
|
|
508
|
+
*
|
|
503
509
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
504
510
|
* bug this replaced.
|
|
505
511
|
*/
|
|
506
512
|
upload(file: Blob | File, options?: {
|
|
507
513
|
name?: string;
|
|
508
514
|
contentType?: string;
|
|
515
|
+
for?: string;
|
|
509
516
|
}): Promise<{
|
|
510
517
|
id: string;
|
|
511
518
|
ref: FileRef;
|
package/dist/index.js
CHANGED
|
@@ -655,6 +655,12 @@ export class CollectionClient {
|
|
|
655
655
|
* Pass `{ name }` so the download carries a real filename, and
|
|
656
656
|
* `{ contentType }` when the Blob has no type of its own.
|
|
657
657
|
*
|
|
658
|
+
* W7.4 — `{ for: userId }` names ONE other person who may read this file
|
|
659
|
+
* (addressed and direct collections only — the rules where recipients
|
|
660
|
+
* exist). The attachment on a direct message: upload with
|
|
661
|
+
* `{ for: recipientId }`, store the ref in the record you send them, and
|
|
662
|
+
* their `g.files.link(ref)` works. Without `for`, only you can link it.
|
|
663
|
+
*
|
|
658
664
|
* There is deliberately no `url` here. A URL that outlives a refund is the
|
|
659
665
|
* bug this replaced.
|
|
660
666
|
*/
|
|
@@ -666,7 +672,7 @@ export class CollectionClient {
|
|
|
666
672
|
// Step 1: Get presigned upload URL
|
|
667
673
|
const presign = await this.request("/upload", {
|
|
668
674
|
method: "POST",
|
|
669
|
-
body: JSON.stringify({ name, size: file.size, contentType }),
|
|
675
|
+
body: JSON.stringify({ name, size: file.size, contentType, ...(options?.for ? { for: options.for } : {}) }),
|
|
670
676
|
});
|
|
671
677
|
// Step 2: Upload directly to S3 via presigned POST
|
|
672
678
|
const form = new FormData();
|
package/llms.txt
CHANGED
|
@@ -291,7 +291,7 @@ go-live. Everything else is yours.
|
|
|
291
291
|
- Images & files: NEVER base64 into record data and NEVER wire up your own
|
|
292
292
|
storage bucket — uploads are built in:
|
|
293
293
|
`const file = await g.collection("posts").upload(blob, { name })`
|
|
294
|
-
→ `{ id, ref, contentType, sizeBytes }`. Store `file.ref` (`"file:
|
|
294
|
+
→ `{ id, ref, contentType, sizeBytes }`. Store `file.ref` (`"file:9f2c…"` — a UUID form)
|
|
295
295
|
in a record field like any text — that's how a record "has" an image or
|
|
296
296
|
a document.
|
|
297
297
|
Store the REFERENCE, never a URL: a reference never expires and grants
|
|
@@ -318,14 +318,32 @@ go-live. Everything else is yours.
|
|
|
318
318
|
the console shows by name).
|
|
319
319
|
Honest bound: revoking access stops NEW links immediately; a link already
|
|
320
320
|
issued works until it expires. Gemmein controls delivery, it can't take
|
|
321
|
-
back a file someone already downloaded.
|
|
321
|
+
back a file someone already downloaded. A file can name ONE other reader:
|
|
322
|
+
`upload(blob, { for: userId })` (addressed/direct collections only) — the
|
|
323
|
+
direct message's attachment; the recipient's `files.link` works, everyone
|
|
324
|
+
else's still refuses. Without `for`, only the uploader can link it —
|
|
325
|
+
including in a client portal, where the OWNER hands a client a document
|
|
326
|
+
from the dashboard (attach it on the client's record there).
|
|
322
327
|
- Shapes: collections your users write (community, shared, direct) have a
|
|
323
328
|
SHAPE — the set of fields allowed. While your human builds, the server
|
|
324
329
|
learns it from your writes automatically (nothing to do); when they go
|
|
325
|
-
live it locks.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
330
|
+
live it locks. Field kinds the shape learns: text, number, yes/no, list,
|
|
331
|
+
link (a stored record id), json, and FILE — a field that held an
|
|
332
|
+
upload()'s ref learns as a file field — and WHICH class (an image field
|
|
333
|
+
vs a document field, from what you actually uploaded). The file law is
|
|
334
|
+
UNIVERSAL: on every rule, locked or not, any top-level field value
|
|
335
|
+
matching the ref grammar must name a real, confirmed file of this app
|
|
336
|
+
that the writer may read — a made-up or foreign ref is refused ("file
|
|
337
|
+
not found", 400 unknown_file). Never invent a ref. (Refs inside list or
|
|
338
|
+
json values aren't judged at write — they still grant nothing at read.)
|
|
339
|
+
If a stored ref's file was later deleted, re-sending it refuses the same
|
|
340
|
+
way: clear the field (null) or upload a fresh file. Once sealed, class
|
|
341
|
+
is law too: a ZIP into a profile photo field is refused with the teach;
|
|
342
|
+
before the seal, what you upload is what the field learns. A 400 invalid_shape means the field isn't in the locked
|
|
343
|
+
shape or the value doesn't fit its kind. A live shape is sealed and
|
|
344
|
+
cannot take new fields — stop, tell your human which field you needed,
|
|
345
|
+
and send only the fields the shape already has. Never rename fields to
|
|
346
|
+
dodge it.
|
|
329
347
|
- Contention (bookings, slugs, stock, shared edits): when two users can race
|
|
330
348
|
for the same thing, a permission model can't save you — preconditions do,
|
|
331
349
|
and they're just arguments on calls you already make. A 409 `conflict` from
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
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",
|
|
@@ -53,4 +53,4 @@
|
|
|
53
53
|
"bugs": {
|
|
54
54
|
"email": "hello@gemmein.com"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|