@bounded-sh/core 0.0.2 → 0.0.4

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 CHANGED
@@ -63,7 +63,7 @@ function getConfig(): Promise<ClientConfig>;
63
63
  function get(path: string): Promise<any>;
64
64
  function getMany(paths: string[], options?: { bypassCache?: boolean }): Promise<GetManyResult[]>;
65
65
  function set(path: string, data: any, options?: SetOptions): Promise<any>;
66
- function setMany(paths: { [key: string]: any }, options?: SetOptions): Promise<any>;
66
+ function setMany(many: { path: string; document: any }[], options?: SetOptions): Promise<any>;
67
67
  function setFile(path: string, file: File, metadata?: any): Promise<any>;
68
68
  function getFiles(path: string): Promise<any>;
69
69
  function runQuery(queryString: string, variables?: any): Promise<any>;
@@ -75,4 +75,4 @@ function subscribe(path: string, options?: SubscriptionOptions): Promise<() => v
75
75
 
76
76
  ## Contributing
77
77
 
78
- Please see the main repository for contribution guidelines.
78
+ Please see the main repository for contribution guidelines.
@@ -12,6 +12,31 @@ export type LiveIntentOptions = {
12
12
  */
13
13
  fireAndForget?: boolean;
14
14
  };
15
+ export type LiveStatusOptions = {
16
+ /** Per-call timeout (ms) for the HTTP request to the realtime worker. */
17
+ timeoutMs?: number;
18
+ /** Extra headers (advanced/testing). */
19
+ headers?: Record<string, string>;
20
+ };
21
+ export type LiveStopReason = 'idle' | 'lifetime' | 'error' | 'manual' | 'evicted' | string;
22
+ export type LiveStatus = {
23
+ available: boolean;
24
+ started: boolean;
25
+ module?: string;
26
+ etag?: string;
27
+ running?: boolean;
28
+ tick?: number;
29
+ lastErr?: string | null;
30
+ stopReason?: LiveStopReason | null;
31
+ lastTickAt?: number | null;
32
+ nextTickAt?: number | null;
33
+ nextAlarmAt?: number | null;
34
+ generation?: number;
35
+ connections?: number;
36
+ startedAtMs?: number | null;
37
+ stoppedAtMs?: number | null;
38
+ reason?: string;
39
+ };
15
40
  export declare class LiveIntentError extends Error {
16
41
  statusCode?: number | undefined;
17
42
  details?: any | undefined;
@@ -33,8 +58,21 @@ export declare class LiveIntentError extends Error {
33
58
  export declare function intent(roomPath: string, intent: unknown, opts?: LiveIntentOptions): Promise<{
34
59
  ok: true;
35
60
  }>;
61
+ /**
62
+ * Fetch the runtime status for a live room.
63
+ *
64
+ * const s = await bounded.live.status('rooms/abc');
65
+ * console.log(s.running, s.stopReason, s.generation, s.etag);
66
+ *
67
+ * This is diagnostic/ops surface. It reports whether the room facet exists,
68
+ * whether it is currently ticking, why it last stopped, which module etag is
69
+ * loaded, and the current generation used after terminal restarts.
70
+ */
71
+ export declare function status(roomPath: string, opts?: LiveStatusOptions): Promise<LiveStatus>;
36
72
  export type SubscribeViewOptions = {
37
- /** Address whose view to read. Defaults to the logged-in user's address. */
73
+ /** User id whose view to read. Defaults to the logged-in user's @user.id. */
74
+ userId?: string;
75
+ /** Legacy alias for wallet-address keyed view docs. Prefer userId for new rooms. */
38
76
  address?: string;
39
77
  /** Called with the latest per-player view document. */
40
78
  onData: (view: any) => void;
@@ -44,10 +82,11 @@ export type SubscribeViewOptions = {
44
82
  /**
45
83
  * Subscribe to YOUR per-player view of a room. Thin sugar over:
46
84
  *
47
- * subscribe('<roomPath>/view/<myAddress>', { onData, onError })
85
+ * subscribe('<roomPath>/view/<myUserId>', { onData, onError })
48
86
  *
49
- * The address defaults to the logged-in user's address (from the session
50
- * token's claims); pass `opts.address` to override. Returns the unsubscribe
87
+ * The view id defaults to the logged-in user's @user.id (from the session token
88
+ * claims); pass `opts.userId` to override. `opts.address` is kept as a legacy
89
+ * alias for older wallet-address keyed policies. Returns the unsubscribe
51
90
  * function (a Promise<() => Promise<void>>, same as `subscribe`).
52
91
  *
53
92
  * Note: this is a browser-first helper (the WS subscription manager is
@@ -57,5 +96,6 @@ export declare function subscribeView(roomPath: string, opts: SubscribeViewOptio
57
96
  /** The `bounded.live` namespace surface. */
58
97
  export declare const live: {
59
98
  intent: typeof intent;
99
+ status: typeof status;
60
100
  subscribeView: typeof subscribeView;
61
101
  };
@@ -236,6 +236,13 @@ export declare function runExpressionMany(many: {
236
236
  returnType?: 'Bool' | 'String' | 'Int' | 'UInt';
237
237
  _overrides?: RequestOverrides;
238
238
  }[]): Promise<RunExpressionResult[]>;
239
+ /**
240
+ * Write a document at `path`. Sugar for a one-element {@link setMany}.
241
+ *
242
+ * **Delete:** pass `null` as the document to delete it — `set(path, null)` is
243
+ * the delete (there is no separate `del`/`remove`). It is routed through the
244
+ * collection's policy `delete` rule and broadcasts a delete to subscribers.
245
+ */
239
246
  export declare function set(path: string, document: any, options?: SetOptions): Promise<any>;
240
247
  export declare function setMany(many: {
241
248
  path: string;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { hasActiveConnection, wsGet, wsSet, wsQuery, wsDelete, wsGetMany } from
7
7
  export * from './types';
8
8
  export { increment, serverTimestamp } from './client/field-values';
9
9
  export type { FieldOperation } from './client/field-values';
10
+ export { now, toSeconds, toMillis } from './time';
10
11
  export { getIdToken, deriveUserIdentityFromIdToken } from './utils/utils';
11
12
  export type { UserIdentity } from './utils/utils';
12
13
  export { WebSessionManager } from './utils/web-session-manager';
@@ -23,7 +24,7 @@ export { RealtimeStore, getRealtimeStore, resetRealtimeStore } from './client/re
23
24
  export type { StorageTier, SubscriptionStatus, SubscriptionState, SubscribeOptions, DeltaChange } from './client/realtime-store';
24
25
  export { functions, invoke as invokeFunction, FunctionInvokeError } from './client/functions';
25
26
  export type { InvokeOptions } from './client/functions';
26
- export { live, intent as liveIntent, subscribeView as subscribeLiveView, LiveIntentError } from './client/live';
27
- export type { LiveIntentOptions, SubscribeViewOptions } from './client/live';
27
+ export { live, intent as liveIntent, status as liveStatus, subscribeView as subscribeLiveView, LiveIntentError } from './client/live';
28
+ export type { LiveIntentOptions, LiveStatus, LiveStatusOptions, SubscribeViewOptions } from './client/live';
28
29
  export { withEffects, isEffectResult, defineLiveModule, EFFECT_INTENT_ADDRESS } from './client/live-effects';
29
30
  export type { Effect, EffectKind, EffectResult, LiveIntent, LiveTickResult, LiveModule } from './client/live-effects';
package/dist/index.js CHANGED
@@ -4929,6 +4929,13 @@ async function runExpressionMany(many) {
4929
4929
  throw error;
4930
4930
  }
4931
4931
  }
4932
+ /**
4933
+ * Write a document at `path`. Sugar for a one-element {@link setMany}.
4934
+ *
4935
+ * **Delete:** pass `null` as the document to delete it — `set(path, null)` is
4936
+ * the delete (there is no separate `del`/`remove`). It is routed through the
4937
+ * collection's policy `delete` rule and broadcasts a delete to subscribers.
4938
+ */
4932
4939
  async function set(path, document, options) {
4933
4940
  const result = await setMany([{ path, document }], options);
4934
4941
  // Clear cache entries that might be affected by this update
@@ -6607,6 +6614,73 @@ function serverTimestamp() {
6607
6614
  return { operation: 'time', value: 'now' };
6608
6615
  }
6609
6616
 
6617
+ /**
6618
+ * Time units in Bounded — read this once and never get a 1000× timestamp bug.
6619
+ *
6620
+ * **Bounded's policy/proof layer is Unix SECONDS.** `@time.now` in a rule,
6621
+ * `rollingSum` `windowSeconds`, `scheduledAt`, and any timestamp *field your
6622
+ * policy compares against `@time.now`* are all **seconds**. (This is also what
6623
+ * the chain uses — Solana's on-chain clock is `unix_timestamp` in seconds — so a
6624
+ * single seconds unit works for both onchain and offchain rules.)
6625
+ *
6626
+ * **JavaScript is MILLISECONDS.** `Date.now()`, `new Date().getTime()`, and the
6627
+ * auto-stamped system fields `_createdAt` / `_updatedAt` are all **ms**.
6628
+ *
6629
+ * Comparing across the two (e.g. `@time.now - myField` where `myField` was set
6630
+ * from `Date.now()`) is 1000× off, so a freshness / TTL check silently treats
6631
+ * every row as ancient (or far-future) and drops it — which reads as "realtime
6632
+ * isn't delivering" when the data is actually fine.
6633
+ *
6634
+ * **The rules:**
6635
+ * - To **write** a timestamp a policy will read, prefer **`serverTimestamp()`**
6636
+ * (from this package) — the *server* stamps it in seconds, so it matches
6637
+ * `@time.now` AND can't be forged by the client (use it for TTLs, rate windows,
6638
+ * anti-cheat). Use `now()` only when you need the value in client code before
6639
+ * the write.
6640
+ * - To **compare** timestamps in client/render code, use `now()` (seconds), not
6641
+ * `Date.now()` (ms), and `toSeconds()` to convert the ms system fields
6642
+ * (`_createdAt`/`_updatedAt`) or any `Date.now()` value first.
6643
+ */
6644
+ /**
6645
+ * Current time as a **Unix timestamp in seconds** — the unit Bounded policy rules
6646
+ * use (`@time.now`). Use this (not `Date.now()`) when you need a timestamp in
6647
+ * client code (e.g. a freshness check). For a value you *store* and a policy
6648
+ * reads, prefer the server-authoritative {@link serverTimestamp} instead.
6649
+ *
6650
+ * ```ts
6651
+ * // stale if >15s old — seconds vs seconds ✓
6652
+ * if (now() - doc.lastSeenSeconds > 15) renderStale();
6653
+ * ```
6654
+ */
6655
+ function now() {
6656
+ return Math.floor(Date.now() / 1000);
6657
+ }
6658
+ /**
6659
+ * Convert a JavaScript millisecond timestamp to Bounded's **seconds**. Accepts a
6660
+ * `Date`, or an ms number such as `Date.now()` or a doc's `_createdAt` /
6661
+ * `_updatedAt` system field.
6662
+ *
6663
+ * ```ts
6664
+ * // doc is >15s old (compare the ms system field in seconds):
6665
+ * if (now() - toSeconds(doc._updatedAt) > 15) renderStale();
6666
+ * ```
6667
+ */
6668
+ function toSeconds(msOrDate) {
6669
+ const ms = msOrDate instanceof Date ? msOrDate.getTime() : msOrDate;
6670
+ return Math.floor(ms / 1000);
6671
+ }
6672
+ /**
6673
+ * Convert a Bounded **seconds** timestamp back to JavaScript **milliseconds** —
6674
+ * e.g. to build a `Date` or do client-side date math/formatting.
6675
+ *
6676
+ * ```ts
6677
+ * new Date(toMillis(doc.createdAtSeconds)).toLocaleString();
6678
+ * ```
6679
+ */
6680
+ function toMillis(seconds) {
6681
+ return seconds * 1000;
6682
+ }
6683
+
6610
6684
  // ---------------------------------------------------------------------------
6611
6685
  // realtime-store.ts — Client-side state manager for realtime apps.
6612
6686
  //
@@ -7510,6 +7584,7 @@ const functions = { invoke };
7510
7584
  // collection. This file is the consumer surface around that runtime:
7511
7585
  //
7512
7586
  // bounded.live.intent(roomPath, intent) -> POST {realtime}/live/intent
7587
+ // bounded.live.status(roomPath) -> GET {realtime}/live/status
7513
7588
  //
7514
7589
  // It mirrors `functions.invoke` exactly: it attaches the caller's session token
7515
7590
  // AUTOMATICALLY (the same token the SDK uses for data reads/writes), and throws
@@ -7522,18 +7597,14 @@ const functions = { invoke };
7522
7597
  // Addressing: clients address rooms BY PATH (the `path` field in the body). The
7523
7598
  // worker derives the roomId from `body.path` and sets X-Room-Id internally;
7524
7599
  // clients NEVER set X-Room-Id. The caller's address is taken server-side from
7525
- // the Authorization header (auth.userAddress) — it is NOT sent in the body.
7600
+ // the Authorization header (auth.userId/userAddress) — it is NOT sent in the
7601
+ // body.
7526
7602
  //
7527
7603
  // Subscribing to your view: a per-player view doc lives at
7528
- // `<roomPath>/view/<myAddress>` (the policy declares
7529
- // `rooms/$roomId/view/$addr` ephemeral with `read: $addr == @user.address`).
7530
- // That path has an EVEN segment count, so it is an ordinary document path —
7531
- // plain `subscribe('<roomPath>/view/<myAddress>', { onData })` already works.
7532
- // `subscribeView()` below is thin sugar over that (path construction + address
7533
- // defaulting); it adds zero new transport.
7534
- //
7535
- // Status polling (GET /live/status?path=<sessionCollection>/<roomId>) is out of
7536
- // scope for this piece — see follow-up.
7604
+ // `<roomPath>/view/<myUserId>` (the policy declares
7605
+ // `rooms/$roomId/view/$userId` ephemeral with `read: $userId == @user.id`).
7606
+ // Wallet-address keyed view paths remain supported through opts.address for
7607
+ // older policies, but new live rooms should key views by @user.id.
7537
7608
  // ---------------------------------------------------------------------------
7538
7609
  class LiveIntentError extends Error {
7539
7610
  constructor(message, statusCode, details) {
@@ -7614,7 +7685,7 @@ async function intent(roomPath, intent, opts = {}) {
7614
7685
  res = await fetch(`${base}/live/intent`, {
7615
7686
  method: 'POST',
7616
7687
  headers,
7617
- body: JSON.stringify({ path: roomPath, intent }),
7688
+ body: JSON.stringify({ path: normalizedRoomPath, intent }),
7618
7689
  signal: controller.signal,
7619
7690
  });
7620
7691
  }
@@ -7642,43 +7713,95 @@ async function intent(roomPath, intent, opts = {}) {
7642
7713
  }
7643
7714
  return (body !== null && body !== void 0 ? body : { ok: true });
7644
7715
  }
7716
+ /**
7717
+ * Fetch the runtime status for a live room.
7718
+ *
7719
+ * const s = await bounded.live.status('rooms/abc');
7720
+ * console.log(s.running, s.stopReason, s.generation, s.etag);
7721
+ *
7722
+ * This is diagnostic/ops surface. It reports whether the room facet exists,
7723
+ * whether it is currently ticking, why it last stopped, which module etag is
7724
+ * loaded, and the current generation used after terminal restarts.
7725
+ */
7726
+ async function status(roomPath, opts = {}) {
7727
+ var _a, _b, _c, _d, _e;
7728
+ if (!roomPath || typeof roomPath !== 'string') {
7729
+ throw new LiveIntentError('A room path is required');
7730
+ }
7731
+ const normalizedRoomPath = roomPath.replace(/\/$/, '');
7732
+ const config = await getConfig();
7733
+ const base = realtimeHttpBase(config.wsApiUrl);
7734
+ const headers = Object.assign({ 'X-App-Id': config.appId, 'X-Public-App-Id': config.appId }, ((_a = opts.headers) !== null && _a !== void 0 ? _a : {}));
7735
+ const controller = new AbortController();
7736
+ const timeoutMs = (_b = opts.timeoutMs) !== null && _b !== void 0 ? _b : 15000;
7737
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
7738
+ let res;
7739
+ try {
7740
+ res = await fetch(`${base}/live/status?path=${encodeURIComponent(normalizedRoomPath)}`, {
7741
+ method: 'GET',
7742
+ headers,
7743
+ signal: controller.signal,
7744
+ });
7745
+ }
7746
+ catch (err) {
7747
+ clearTimeout(timer);
7748
+ if ((err === null || err === void 0 ? void 0 : err.name) === 'AbortError') {
7749
+ throw new LiveIntentError(`Live status for "${normalizedRoomPath}" timed out after ${timeoutMs}ms`);
7750
+ }
7751
+ throw new LiveIntentError(`Failed to reach the realtime worker: ${(_c = err === null || err === void 0 ? void 0 : err.message) !== null && _c !== void 0 ? _c : String(err)}`);
7752
+ }
7753
+ clearTimeout(timer);
7754
+ let body = null;
7755
+ const text = await res.text();
7756
+ if (text) {
7757
+ try {
7758
+ body = JSON.parse(text);
7759
+ }
7760
+ catch (_f) {
7761
+ body = { raw: text };
7762
+ }
7763
+ }
7764
+ if (!res.ok) {
7765
+ const message = (_e = (_d = body === null || body === void 0 ? void 0 : body.error) !== null && _d !== void 0 ? _d : body === null || body === void 0 ? void 0 : body.message) !== null && _e !== void 0 ? _e : `Live status failed with HTTP ${res.status}`;
7766
+ throw new LiveIntentError(message, res.status, body);
7767
+ }
7768
+ return body;
7769
+ }
7645
7770
  /**
7646
7771
  * Subscribe to YOUR per-player view of a room. Thin sugar over:
7647
7772
  *
7648
- * subscribe('<roomPath>/view/<myAddress>', { onData, onError })
7773
+ * subscribe('<roomPath>/view/<myUserId>', { onData, onError })
7649
7774
  *
7650
- * The address defaults to the logged-in user's address (from the session
7651
- * token's claims); pass `opts.address` to override. Returns the unsubscribe
7775
+ * The view id defaults to the logged-in user's @user.id (from the session token
7776
+ * claims); pass `opts.userId` to override. `opts.address` is kept as a legacy
7777
+ * alias for older wallet-address keyed policies. Returns the unsubscribe
7652
7778
  * function (a Promise<() => Promise<void>>, same as `subscribe`).
7653
7779
  *
7654
7780
  * Note: this is a browser-first helper (the WS subscription manager is
7655
7781
  * browser-oriented). Server consumers should use `live.intent`.
7656
7782
  */
7657
7783
  async function subscribeView(roomPath, opts) {
7658
- var _a;
7784
+ var _a, _b, _c;
7659
7785
  if (!roomPath || typeof roomPath !== 'string') {
7660
7786
  throw new LiveIntentError('A room path is required');
7661
7787
  }
7662
7788
  if (!opts || typeof opts.onData !== 'function') {
7663
7789
  throw new LiveIntentError('subscribeView requires an onData callback');
7664
7790
  }
7665
- let address = opts.address;
7666
- if (!address) {
7791
+ let viewUserId = (_a = opts.userId) !== null && _a !== void 0 ? _a : opts.address;
7792
+ if (!viewUserId) {
7667
7793
  const config = await getConfig();
7668
7794
  const info = await getUserInfo(config.isServer);
7669
- // getUserInfo returns the RAW idToken payload the wallet address lives in
7670
- // the `custom:walletAddress` claim, NOT a flat `address` field. Reading only
7671
- // `.address` meant auto-resolution NEVER worked, forcing every caller (browser
7672
- // AND server) to pass opts.address the client having to hand the backend an
7673
- // identity it already authenticated. Resolve it from the actual claim so a
7674
- // logged-in caller never needs to pass its own address.
7675
- address = (_a = info === null || info === void 0 ? void 0 : info.address) !== null && _a !== void 0 ? _a : info === null || info === void 0 ? void 0 : info['custom:walletAddress'];
7795
+ // getUserInfo returns the RAW idToken payload. The universal live view key
7796
+ // is @user.id (`custom:userId`); wallet-address keyed views are still
7797
+ // resolved as a compatibility fallback for older policies.
7798
+ viewUserId = (_c = (_b = info === null || info === void 0 ? void 0 : info['custom:userId']) !== null && _b !== void 0 ? _b : info === null || info === void 0 ? void 0 : info['custom:walletAddress']) !== null && _c !== void 0 ? _c : info === null || info === void 0 ? void 0 : info.address;
7676
7799
  }
7677
- if (!address || typeof address !== 'string') {
7678
- throw new LiveIntentError('Could not resolve a player address for subscribeView; pass opts.address or log in first');
7800
+ if (!viewUserId || typeof viewUserId !== 'string') {
7801
+ throw new LiveIntentError('Could not resolve a player view id for subscribeView; pass opts.userId or log in first');
7679
7802
  }
7680
7803
  const normalizedRoomPath = roomPath.replace(/\/$/, '');
7681
- const viewPath = `${normalizedRoomPath}/view/${address}`;
7804
+ const viewPath = `${normalizedRoomPath}/view/${viewUserId}`;
7682
7805
  // This is a LIVE/room subscription by construction (the caller hands us the
7683
7806
  // room path), so we route its connection to the per-room DO where the live
7684
7807
  // view fan-out runs. App code passes NO routing — the room path is the helper's
@@ -7689,7 +7812,7 @@ async function subscribeView(roomPath, opts) {
7689
7812
  return subscribeV2(viewPath, { onData: opts.onData, onError: opts.onError }, normalizedRoomPath);
7690
7813
  }
7691
7814
  /** The `bounded.live` namespace surface. */
7692
- const live = { intent, subscribeView };
7815
+ const live = { intent, status, subscribeView };
7693
7816
 
7694
7817
  // ---------------------------------------------------------------------------
7695
7818
  // live-effects.ts -- AUTHOR-facing types + helpers for writing a Bounded live
@@ -7778,6 +7901,8 @@ exports.invokeFunction = invoke;
7778
7901
  exports.isEffectResult = isEffectResult;
7779
7902
  exports.live = live;
7780
7903
  exports.liveIntent = intent;
7904
+ exports.liveStatus = status;
7905
+ exports.now = now;
7781
7906
  exports.queryAggregate = queryAggregate;
7782
7907
  exports.reconnectWithNewAuth = reconnectWithNewAuth;
7783
7908
  exports.refreshSession = refreshSession;
@@ -7798,6 +7923,8 @@ exports.signSessionCreateMessage = signSessionCreateMessage;
7798
7923
  exports.signTransaction = signTransaction;
7799
7924
  exports.subscribe = subscribe;
7800
7925
  exports.subscribeLiveView = subscribeView;
7926
+ exports.toMillis = toMillis;
7927
+ exports.toSeconds = toSeconds;
7801
7928
  exports.withEffects = withEffects;
7802
7929
  exports.wsDelete = wsDelete;
7803
7930
  exports.wsGet = wsGet;