@maravilla-labs/platform 0.2.1 → 0.2.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/src/types.ts CHANGED
@@ -248,6 +248,111 @@ export interface Database {
248
248
  * ```
249
249
  */
250
250
  deleteMany(collection: string, filter: any): Promise<{ deleted: number }>;
251
+
252
+ /**
253
+ * Register a vector index on a collection field. Inserts thereafter
254
+ * will have vectors in `spec.field` synced into an ANN-indexed `vec0`
255
+ * table inside the same SQLite transaction as the document write.
256
+ *
257
+ * Idempotent when the spec matches an existing index; errors on
258
+ * incompatible re-declaration.
259
+ *
260
+ * @example
261
+ * ```typescript
262
+ * await db.createVectorIndex('products', {
263
+ * field: 'embedding',
264
+ * dimensions: 1536,
265
+ * metric: 'cosine',
266
+ * storage: 'int8',
267
+ * });
268
+ * ```
269
+ */
270
+ createVectorIndex(collection: string, spec: VectorIndexSpec): Promise<void>;
271
+
272
+ /**
273
+ * Drop a vector index. Returns `{ removed: true }` if the index existed
274
+ * and was removed; `{ removed: false }` otherwise. The underlying
275
+ * collection's documents are untouched.
276
+ */
277
+ dropVectorIndex(collection: string, field: string): Promise<{ removed: boolean }>;
278
+
279
+ /** List every registered vector index on a collection. */
280
+ listVectorIndexes(collection: string): Promise<VectorIndexDescriptor[]>;
281
+
282
+ /**
283
+ * Pure vector search — convenience wrapper. For hybrid metadata +
284
+ * vector search, use `find()` with `options.vector`.
285
+ */
286
+ findSimilar(collection: string, query: VectorQueryWithFilter): Promise<VectorSearchHit[]>;
287
+
288
+ /**
289
+ * Register a MongoDB-style secondary index on a collection. Speeds up
290
+ * `find()` / `findOne()` queries whose filter matches the indexed keys.
291
+ *
292
+ * Idempotent when the spec matches an existing index; errors on name
293
+ * collision with a different spec.
294
+ *
295
+ * @example
296
+ * ```typescript
297
+ * await db.createIndex('users', { keys: { email: 1 }, unique: true });
298
+ * await db.createIndex('posts', { keys: { authorId: 1, createdAt: -1 } });
299
+ * await db.createIndex('sessions', { keys: { createdAt: 1 }, expireAfterSeconds: 3600 });
300
+ * ```
301
+ */
302
+ createIndex(collection: string, spec: IndexSpec): Promise<IndexDescriptor>;
303
+
304
+ /** Drop an index by name. Returns `true` if it existed and was removed. */
305
+ dropIndex(collection: string, name: string): Promise<{ removed: boolean }>;
306
+
307
+ /**
308
+ * List every index on a collection — both document indexes and
309
+ * vector indexes — in a unified response.
310
+ */
311
+ listIndexes(collection: string): Promise<IndexDescriptor[]>;
312
+ }
313
+
314
+ /** MongoDB-style key direction: `1` = ascending, `-1` = descending. */
315
+ export type IndexDirection = 1 | -1;
316
+
317
+ /** Whether an index is a regular document index or a vector index. */
318
+ export type IndexKind = 'document' | 'vector';
319
+
320
+ export interface IndexSpec {
321
+ /** Optional index name. Falls back to an auto-derived name. */
322
+ name?: string;
323
+ /**
324
+ * Key shape. Accepts an ordered array of `[field, direction]` tuples
325
+ * (recommended) OR a plain object — note that object key order is
326
+ * language-dependent, so tuples are safer for compound indexes.
327
+ */
328
+ keys: Array<[string, IndexDirection]> | Record<string, IndexDirection>;
329
+ /** Enforce uniqueness across the indexed columns. */
330
+ unique?: boolean;
331
+ /**
332
+ * Partial-index predicate. Only matching documents are included in
333
+ * the index. Supports `$eq`, `$ne`, `$gt/$gte/$lt/$lte`, `$in/$nin`,
334
+ * `$exists`, `$and`, `$or`. Rejects `$regex`, `$where`, `$text`.
335
+ */
336
+ partial?: Record<string, any>;
337
+ /** Shorthand for `partial: { <field>: { $exists: true } }`. */
338
+ sparse?: boolean;
339
+ /**
340
+ * TTL in seconds. Requires a single-field index on a Unix-epoch-seconds
341
+ * field. The platform runs a background sweeper that deletes documents
342
+ * older than this.
343
+ */
344
+ expireAfterSeconds?: number;
345
+ }
346
+
347
+ export interface IndexDescriptor {
348
+ collection: string;
349
+ name: string;
350
+ keys: Array<[string, IndexDirection]>;
351
+ unique: boolean;
352
+ sparse: boolean;
353
+ partial?: Record<string, any>;
354
+ expireAfterSeconds?: number;
355
+ kind: IndexKind;
251
356
  }
252
357
 
253
358
  /**
@@ -261,8 +366,91 @@ export interface DbFindOptions {
261
366
  skip?: number;
262
367
  /** Sort order specification (MongoDB-style: { field: 1 } for ascending, { field: -1 } for descending) */
263
368
  sort?: any;
369
+ /**
370
+ * Vector search clause. When set, `find()` performs a hybrid
371
+ * metadata + vector search using the collection's registered vector
372
+ * index on `vector.field`. The metadata filter in `filter` is applied
373
+ * after (or alongside, depending on the index type) the vector ranking.
374
+ */
375
+ vector?: VectorQuery;
264
376
  }
265
377
 
378
+ /** Distance metric used to compare vectors. */
379
+ export type VectorMetric = 'cosine' | 'l2' | 'hamming';
380
+
381
+ /**
382
+ * On-disk storage precision for vectors.
383
+ * - `float32` (default): 4 bytes per dim, highest accuracy
384
+ * - `int8`: 1 byte per dim, 4× smaller, <2% accuracy loss for most embeddings
385
+ * - `bit`: 1 bit per dim, 32× smaller; requires metric='hamming'
386
+ */
387
+ export type VectorStorage = 'float32' | 'int8' | 'bit';
388
+
389
+ /** Query shape: single vector or an array of vectors (ColBERT-style). */
390
+ export type VectorQueryMode = 'single' | 'late-interaction';
391
+
392
+ /** How multi-vector distances are aggregated per document. */
393
+ export type VectorAggregation = 'max-sim' | 'sum';
394
+
395
+ export interface VectorIndexSpec {
396
+ /** JSON path of the vector field inside each document (dotted syntax OK). */
397
+ field: string;
398
+ /** Stored vector dimensionality. */
399
+ dimensions: number;
400
+ /** Distance metric; defaults to `cosine`. */
401
+ metric?: VectorMetric;
402
+ /** Storage precision; defaults to `float32`. Int8/bit quantize automatically on insert. */
403
+ storage?: VectorStorage;
404
+ /** Allow queries with vectors shorter than `dimensions`. Requires matryoshka-trained embeddings. */
405
+ matryoshka?: boolean;
406
+ /** Each document holds an array of vectors (one per chunk/token). */
407
+ multiVector?: boolean;
408
+ }
409
+
410
+ export interface VectorIndexDescriptor {
411
+ collection: string;
412
+ field: string;
413
+ dimensions: number;
414
+ metric: VectorMetric;
415
+ storage: VectorStorage;
416
+ matryoshka: boolean;
417
+ multiVector: boolean;
418
+ }
419
+
420
+ export interface VectorQuery {
421
+ /** Must match a registered vector index field on the collection. */
422
+ field: string;
423
+ /**
424
+ * Query vector (`number[]` for `queryMode: 'single'`) or array of
425
+ * query vectors (`number[][]` for `queryMode: 'late-interaction'`).
426
+ */
427
+ value: number[] | number[][];
428
+ /** Top-k result count. Must be > 0. */
429
+ k: number;
430
+ /** Per-query metric override. Defaults to the index's configured metric. */
431
+ metric?: VectorMetric;
432
+ /** Drop results below this normalized score (0–1, higher = more similar). */
433
+ minScore?: number;
434
+ /** Defaults to `single`. */
435
+ queryMode?: VectorQueryMode;
436
+ /** Defaults to `max-sim`. Only meaningful for multi-vector indexes. */
437
+ aggregation?: VectorAggregation;
438
+ }
439
+
440
+ /** Convenience shape for the pure-vector `findSimilar()` helper. */
441
+ export interface VectorQueryWithFilter extends VectorQuery {
442
+ /** Optional metadata filter applied alongside the vector search. */
443
+ filter?: any;
444
+ /** Overall result cap (defaults to `k`). */
445
+ limit?: number;
446
+ }
447
+
448
+ /** Document returned from a vector search, with similarity metadata attached. */
449
+ export type VectorSearchHit = Record<string, any> & {
450
+ _score: number;
451
+ _distance: number;
452
+ };
453
+
266
454
  /**
267
455
  * Storage interface for object/file storage operations.
268
456
  * Provides S3-compatible API for storing and retrieving files.
@@ -585,6 +773,26 @@ export interface AuthUser {
585
773
  last_login_at?: number;
586
774
  }
587
775
 
776
+ /**
777
+ * Snapshot of whoever is currently bound to the request as the caller.
778
+ *
779
+ * Populated by {@link AuthService.login} (implicit), {@link AuthService.setCurrentUser}
780
+ * (explicit), or left anonymous if neither has run for this request.
781
+ * This is exactly what per-resource policies see as `auth.*` when they run.
782
+ */
783
+ export interface AuthCaller {
784
+ /** Caller's user id, or `""` if anonymous */
785
+ user_id: string;
786
+ /** Caller's email, or `""` if anonymous */
787
+ email: string;
788
+ /** Admin flag from the session */
789
+ is_admin: boolean;
790
+ /** Role names (project-scoped) */
791
+ roles: string[];
792
+ /** `true` when no identity is bound to this request */
793
+ is_anonymous: boolean;
794
+ }
795
+
588
796
  /**
589
797
  * Session returned after successful login or token refresh.
590
798
  */
@@ -840,6 +1048,302 @@ export interface AuthService {
840
1048
  withAuth<T extends (request: Request & { user: AuthUser }) => Promise<Response>>(
841
1049
  handler: T
842
1050
  ): (request: Request) => Promise<Response>;
1051
+
1052
+ // ── Request-scoped identity + authorization ──
1053
+ //
1054
+ // These methods operate on the **current request's** caller context.
1055
+ // They're meaningful inside the platform runtime (one isolate serving a
1056
+ // Deno request). When called from a remote client — code running outside
1057
+ // the runtime — they throw, because there is no per-request context to
1058
+ // bind.
1059
+
1060
+ /**
1061
+ * Explicitly bind the caller for the remainder of this request.
1062
+ * Pass a JWT to validate + bind, or `null` / `""` to clear.
1063
+ *
1064
+ * `login()` already binds implicitly on success; reach for `setCurrentUser`
1065
+ * when you receive a JWT from an inbound `Authorization` header or cookie
1066
+ * and want subsequent KV/DB/realtime/media ops to run as that user.
1067
+ *
1068
+ * Not available on remote clients — throws.
1069
+ */
1070
+ setCurrentUser(token: string | null): Promise<void>;
1071
+
1072
+ /**
1073
+ * Snapshot of the currently bound caller. Returns an anonymous caller
1074
+ * (`is_anonymous: true`) when no identity has been bound.
1075
+ *
1076
+ * Not available on remote clients — throws.
1077
+ */
1078
+ getCurrentUser(): AuthCaller;
1079
+
1080
+ /**
1081
+ * Ask the policy engine whether the bound caller would be allowed to
1082
+ * perform `action` on `resourceId`, given the supplied `node` payload.
1083
+ * Returns a boolean — never throws on denial.
1084
+ *
1085
+ * The check runs the exact same evaluator that gates direct KV/DB/
1086
+ * realtime/media ops, so `can(...)` is authoritative.
1087
+ *
1088
+ * @example
1089
+ * ```typescript
1090
+ * const ok = await platform.auth.can("delete", "documents", {
1091
+ * owner: doc.owner,
1092
+ * status: doc.status,
1093
+ * });
1094
+ * if (!ok) return new Response("Forbidden", { status: 403 });
1095
+ * ```
1096
+ *
1097
+ * Not available on remote clients — throws.
1098
+ */
1099
+ can(action: string, resourceId: string, node?: Record<string, unknown> | null): Promise<boolean>;
1100
+ }
1101
+
1102
+ /**
1103
+ * Per-request opt-out toggle for the Layer 2 policy evaluator.
1104
+ *
1105
+ * Flipping `setEnabled(false)` disables **per-resource policies only** for
1106
+ * the remainder of the current request. Layer 1 (tenant + owner isolation)
1107
+ * is always enforced — no call can ever escape its tenant. Every flip is
1108
+ * audit-logged server-side with the caller's identity.
1109
+ *
1110
+ * Intended for trusted in-app flows (first-run seeders, admin jobs). Do not
1111
+ * toggle based on untrusted input.
1112
+ *
1113
+ * Not available on remote clients — throws.
1114
+ */
1115
+ export interface PolicyService {
1116
+ /** Disable or re-enable Layer 2 policy checks for this request. */
1117
+ setEnabled(enabled: boolean): void;
1118
+ /** `true` when Layer 2 is active for this request. */
1119
+ isEnabled(): boolean;
1120
+ }
1121
+
1122
+ /**
1123
+ * Target selector for Web Push sends. Combine fields to narrow — all
1124
+ * specified conditions must match for a subscription to receive the push.
1125
+ *
1126
+ * @example
1127
+ * ```typescript
1128
+ * // Every subscription tagged with "waitlist"
1129
+ * await platform.push.send({ topic: 'waitlist' }, notification);
1130
+ *
1131
+ * // Every device belonging to one authenticated user
1132
+ * await platform.push.send({ userId: 'u_42' }, notification);
1133
+ *
1134
+ * // "This specific user's subscription for this specific invite"
1135
+ * await platform.push.send({ userId: 'u_42', topic: 'invite:abc:rsvp' }, notification);
1136
+ * ```
1137
+ */
1138
+ export interface PushTarget {
1139
+ userId?: string;
1140
+ visitorId?: string;
1141
+ topic?: string;
1142
+ userIds?: string[];
1143
+ topics?: string[];
1144
+ onlyActive?: boolean;
1145
+ }
1146
+
1147
+ /** Shape of a Web Push notification payload. */
1148
+ export interface NotificationPayload {
1149
+ /** Required — the notification headline shown on lock screens and the notification shade. */
1150
+ title: string;
1151
+ body?: string;
1152
+ icon?: string;
1153
+ badge?: string;
1154
+ image?: string;
1155
+ /** Browsers dedupe notifications sharing a tag. */
1156
+ tag?: string;
1157
+ /** Where to navigate when the notification is clicked. */
1158
+ url?: string;
1159
+ /** Arbitrary JSON delivered to the service worker alongside the notification. */
1160
+ data?: Record<string, unknown>;
1161
+ /** Seconds the push service holds the message if the device is offline. */
1162
+ ttl?: number;
1163
+ urgency?: 'very-low' | 'low' | 'normal' | 'high';
1164
+ }
1165
+
1166
+ /**
1167
+ * Options for `platform.push.schedule(...)`.
1168
+ *
1169
+ * @example
1170
+ * ```typescript
1171
+ * // Remind every RSVP'd guest one hour before the event.
1172
+ * await platform.push.schedule(
1173
+ * { topic: `invite:${invite.id}` },
1174
+ * { title: invite.title, body: 'Your event is in one hour' },
1175
+ * {
1176
+ * at: offsetBefore(invite.event_date, '1h'),
1177
+ * key: `invite:${invite.id}:reminder-1h`,
1178
+ * }
1179
+ * );
1180
+ * ```
1181
+ */
1182
+ export interface ScheduleOptions {
1183
+ /**
1184
+ * When to send. Absolute `Date` or ISO-8601 string; the server treats
1185
+ * bare (no-offset) strings as UTC.
1186
+ */
1187
+ at: Date | string;
1188
+ /**
1189
+ * Idempotency key scoped to your project. Re-calling `schedule` with the
1190
+ * same key atomically replaces the prior pending job — safe to call on
1191
+ * every save of an invite whose event date may change.
1192
+ */
1193
+ key: string;
1194
+ /** Maximum delivery attempts before the job is marked failed. Defaults to 3. */
1195
+ maxAttempts?: number;
1196
+ /**
1197
+ * If set, the job re-queues after every successful send and fires again
1198
+ * this many seconds later. Use for daily digests (`86400`), weekly
1199
+ * updates (`604800`), or any fixed-interval loop. `cancelScheduled(key)`
1200
+ * stops the loop.
1201
+ */
1202
+ everySeconds?: number;
1203
+ }
1204
+
1205
+ /** A single scheduled push job as returned by `listScheduled` / `getScheduled`. */
1206
+ export interface ScheduledJob {
1207
+ jobId: string;
1208
+ key?: string;
1209
+ /** Next fire time — unix seconds. Convert with `new Date(scheduledFor * 1000)`. */
1210
+ scheduledFor: number;
1211
+ status: 'pending' | 'running' | 'succeeded' | 'failed';
1212
+ attempts: number;
1213
+ maxAttempts: number;
1214
+ lastError?: string;
1215
+ createdAt: number;
1216
+ updatedAt: number;
1217
+ /** Populated once the job has fired at least once. */
1218
+ sentAt?: number;
1219
+ /** Set when the job recurs — `schedule()` was called with `everySeconds`. */
1220
+ recurringIntervalSecs?: number;
1221
+ }
1222
+
1223
+ /** Filter passed to `listScheduled`. */
1224
+ export interface ListScheduledFilter {
1225
+ status?: 'pending' | 'running' | 'succeeded' | 'failed';
1226
+ limit?: number;
1227
+ offset?: number;
1228
+ }
1229
+
1230
+ /** Counts by status, as returned by `queueStats`. */
1231
+ export interface QueueStats {
1232
+ pending: number;
1233
+ running: number;
1234
+ succeeded: number;
1235
+ failed: number;
1236
+ }
1237
+
1238
+ /** Outcome of a single `platform.push.send(...)` fan-out. */
1239
+ export interface SendReport {
1240
+ attempted: number;
1241
+ succeeded: number;
1242
+ gone: number;
1243
+ failed: number;
1244
+ errors?: Array<{ subscriptionId: string; message: string }>;
1245
+ }
1246
+
1247
+ /** Shape of a stored Web Push subscription. */
1248
+ export interface StoredPushSubscription {
1249
+ id: string;
1250
+ provider: 'web-push' | 'apns' | 'fcm';
1251
+ endpoint: string;
1252
+ p256dh?: string | null;
1253
+ auth?: string | null;
1254
+ userId?: string | null;
1255
+ visitorId?: string | null;
1256
+ userAgent?: string | null;
1257
+ topics: string[];
1258
+ createdAt: number;
1259
+ lastSeenAt?: number | null;
1260
+ expiresAt?: number | null;
1261
+ isActive: boolean;
1262
+ }
1263
+
1264
+ /** Aggregate counts across every subscription in the project. */
1265
+ export interface SubscriptionCounts {
1266
+ total: number;
1267
+ byTopic: Array<[string, number]>;
1268
+ byProvider: Array<[string, number]>;
1269
+ }
1270
+
1271
+ /** Public VAPID config for the current project. */
1272
+ export interface PublicPushConfig {
1273
+ vapidPublic: string;
1274
+ contactEmail: string;
1275
+ updatedAt: number;
1276
+ }
1277
+
1278
+ /**
1279
+ * Server-side Web Push service. Access via `platform.push` inside your
1280
+ * runtime code.
1281
+ */
1282
+ export interface PushService {
1283
+ /**
1284
+ * Fan out a notification to every active subscription matching `target`.
1285
+ * Blocks until every device has been tried.
1286
+ */
1287
+ send(target: PushTarget, notification: NotificationPayload): Promise<SendReport>;
1288
+
1289
+ /**
1290
+ * Fire-and-forget variant. The request handler can return immediately;
1291
+ * the dispatch continues in the background. Best-effort — a delivery
1292
+ * restart mid-dispatch loses in-flight sends.
1293
+ */
1294
+ sendBackground(target: PushTarget, notification: NotificationPayload): Promise<void>;
1295
+
1296
+ /**
1297
+ * Queue a notification for a future time. Idempotent by `key` — repeated
1298
+ * calls with the same key replace the prior pending job. Set `everySeconds`
1299
+ * for recurring digests.
1300
+ */
1301
+ schedule(
1302
+ target: PushTarget,
1303
+ notification: NotificationPayload,
1304
+ opts: ScheduleOptions,
1305
+ ): Promise<{ jobId: string }>;
1306
+
1307
+ /** Cancel the pending scheduled job with this idempotency key. */
1308
+ cancelScheduled(key: string): Promise<{ canceled: number }>;
1309
+
1310
+ /** List scheduled jobs for this project, optionally filtered by status. */
1311
+ listScheduled(filter?: ListScheduledFilter): Promise<ScheduledJob[]>;
1312
+
1313
+ /** Look up a single scheduled job by idempotency key. */
1314
+ getScheduled(key: string): Promise<ScheduledJob | null>;
1315
+
1316
+ /** Per-status counts for the scheduled queue. */
1317
+ queueStats(): Promise<QueueStats>;
1318
+
1319
+ /** List subscriptions — useful for admin UIs and debugging. */
1320
+ list(filter?: {
1321
+ topic?: string;
1322
+ userId?: string;
1323
+ visitorId?: string;
1324
+ onlyActive?: boolean;
1325
+ limit?: number;
1326
+ offset?: number;
1327
+ }): Promise<StoredPushSubscription[]>;
1328
+
1329
+ /** Aggregate counts grouped by topic and provider. */
1330
+ counts(): Promise<SubscriptionCounts>;
1331
+
1332
+ /** Remove a subscription by id. */
1333
+ unsubscribe(subscriptionId: string): Promise<void>;
1334
+
1335
+ /** Remove a subscription by its push service endpoint URL. */
1336
+ unsubscribeByEndpoint(endpoint: string): Promise<void>;
1337
+
1338
+ /** Fetch the project's current VAPID public key + contact email. */
1339
+ getVapidConfig(): Promise<PublicPushConfig>;
1340
+
1341
+ /**
1342
+ * Rotate the project's VAPID keypair. **Every existing subscription
1343
+ * silently stops working** — browsers bind subscriptions to the key they
1344
+ * saw at subscribe time. Confirm with your users before calling.
1345
+ */
1346
+ rotateVapidKeys(): Promise<PublicPushConfig>;
843
1347
  }
844
1348
 
845
1349
  export interface Platform {
@@ -849,6 +1353,15 @@ export interface Platform {
849
1353
  media?: import('./media.js').MediaService;
850
1354
  /** Realtime service for pub/sub channels and presence */
851
1355
  realtime: RealtimeService;
852
- /** Auth service for end-user authentication and user management */
1356
+ /** Auth service for end-user authentication, identity binding, and authorization checks */
853
1357
  auth: AuthService;
1358
+ /** Per-request Layer 2 policy toggle (Layer 1 isolation always applies) */
1359
+ policy: PolicyService;
1360
+ /**
1361
+ * Web Push — send, schedule, or query browser push notifications for
1362
+ * logged-in users and anonymous visitors. Available when Push is enabled
1363
+ * in project settings; `undefined` when running against the dev-server
1364
+ * fallback that doesn't proxy to delivery.
1365
+ */
1366
+ push?: PushService;
854
1367
  }
package/tsup.config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineConfig } from 'tsup';
2
2
 
3
3
  export default defineConfig({
4
- entry: ['src/index.ts'],
4
+ entry: ['src/index.ts', 'src/config.ts', 'src/push.ts', 'src/events.ts'],
5
5
  format: ['esm'],
6
6
  dts: true,
7
7
  splitting: false,