@gemmein/sdk 0.4.4 → 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.
Files changed (3) hide show
  1. package/REFERENCE.md +83 -7
  2. package/llms.txt +10 -5
  3. 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, plus
25
- `testSession()` for CI self-tests (dev environments only see **Reaffirm**).
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> = { records: GemmeinRecord<T>[]; cursor?: string; hasMore: boolean };
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 image type | send the real image |
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 one of the allowed image types | send JPEG/PNG/WebP/GIF/HEIC |
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/llms.txt CHANGED
@@ -330,11 +330,16 @@ go-live. Everything else is yours.
330
330
  live it locks. Field kinds the shape learns: text, number, yes/no, list,
331
331
  link (a stored record id), json, and FILE — a field that held an
332
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). Once locked, the
334
- field accepts only a reference to a real, confirmed file of this app
335
- that the writer may read, of the right class a ZIP into a profile
336
- photo field is refused with the teach, as is a made-up or foreign ref
337
- ("file not found"). A 400 invalid_shape means the field isn't in the locked
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
338
343
  shape or the value doesn't fit its kind. A live shape is sealed and
339
344
  cannot take new fields — stop, tell your human which field you needed,
340
345
  and send only the fields the shape already has. Never rename fields to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.4.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
+ }