diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad8187bffa..0ff76418f9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,6 +450,74 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-posthog-ts: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-posthog-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-posthog-ts/example/spacetimedb: + dependencies: + '@spacetimedb/posthog': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-rate-limit-ts: devDependencies: '@types/node': diff --git a/spacetime-posthog-ts/.npmrc b/spacetime-posthog-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-posthog-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-posthog-ts/LICENSE.txt b/spacetime-posthog-ts/LICENSE.txt new file mode 100644 index 00000000000..4e61a2122e7 --- /dev/null +++ b/spacetime-posthog-ts/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/spacetime-posthog-ts/README.md b/spacetime-posthog-ts/README.md new file mode 100644 index 00000000000..3b778508aa5 --- /dev/null +++ b/spacetime-posthog-ts/README.md @@ -0,0 +1,171 @@ +# @spacetimedb/posthog + +A SpacetimeDB submodule for server-side PostHog analytics: direct capture, +durable queued events, explicit batch flush, feature flag evaluation, and +admin-scoped delivery state. Procedures call PostHog through `ctx.http.fetch`. + +--- + +## Install + +```bash +npm install @spacetimedb/posthog spacetimedb +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +This submodule can be published directly as its own SpacetimeDB module from the root entry point. + +## Usage + +### Integrate into an application + +Register PostHog in the host schema. Configure its private credentials through an +administrator-only startup path, enqueue events from reducers, and perform +network delivery from procedures: + +```ts +import { schema, t } from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +const spacetimedb = schema({ posthog }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + posthog.installPostHog(ctx.as.posthog); +}); + +export const complete_order = spacetimedb.reducer( + { orderId: t.string(), totalCents: t.u64() }, + (ctx, args) => { + // Apply the application's order mutation in this reducer transaction. + posthog.enqueueEventInTx(ctx.as.posthog, { + distinctId: ctx.sender.toHexString(), + event: 'order_completed', + propertiesJson: JSON.stringify({ + orderId: args.orderId, + totalCents: args.totalCents.toString(), + }), + idempotencyKey: `order_completed:${args.orderId}`, + }); + } +); +``` + +The host must decide which events and delivery controls a caller may use. See +the +[Context Cafe host module](./example/spacetimedb/) +for reducer-safe queueing, procedure delivery, and admin-scoped observability. + +### Standalone configuration + +PostHog credentials live in a private `posthog_config` singleton. During +`init`, a fresh database seeds the owner into the private +`posthog_admin_identity` table. + +```bash +spacetime call --server http://127.0.0.1:3000 posthog-ts set_posthog_config \ + '"https://us.i.posthog.com"' \ + '"phc_..."' +``` + +Verify: + +```bash +spacetime call --server http://127.0.0.1:3000 posthog-ts get_posthog_config_status '{}' +``` + +The project token stays in private module state. + +## Public views + +The submodule stores operational state in private tables and exposes admin-gated subscribable views: + +| View | Notes | +| ---------------------------- | ---------------------------------------------------------- | +| `posthog_outbox_admin` | up to 500 queued events waiting for explicit delivery | +| `posthog_delivery_log_admin` | recent direct capture, flush, and flag evaluation attempts | + +## API + +**Setup** + +- `set_posthog_config({ host, projectApiKey })` +- `get_posthog_config_status()` +- `add_admin_identity(identity)` / `remove_admin_identity(identity)` + +**Analytics** + +- `capture_now({ distinctId, event, propertiesJson })` sends one event immediately + through PostHog `/batch` and returns a JSON result string. +- `flush_outbox({ limit })` sends queued events in one `/batch` request, updates + delivery state, and returns a JSON result string. +- `get_feature_flag({ key, distinctId, personPropertiesJson, groupsJson })` calls + PostHog `/flags?v=2` and returns a JSON result string with the requested flag + value when present: a boolean or the multivariate flag's variant string. + +**Maintenance** + +- `clearAnalytics(ctx, maxRows)` removes a bounded set of outbox and delivery + rows for operator-controlled resets. +- `posthog_outbox_admin` and `posthog_delivery_log_admin` expose bounded, + administrator-scoped operational views. + +Submodule state exports include `posthogOutbox`, `posthogDeliveryLog`, +`posthogDeliveryStats`, and `OutboxStatus` for host-defined views and operator +workflows. + +These submodule operations are admin-only because they can spend provider quota. +Expose product-specific host operations that derive the distinct ID and event or +flag name from authorized application state. + +**Reducer-safe queueing** + +- `enqueue_event({ distinctId, event, propertiesJson, idempotencyKey })` writes a + durable event intent inside a reducer transaction. The submodule reducer is + admin-only; host reducers should call `enqueueEventInTx` after authorization. + +For host modules, import `@spacetimedb/posthog/submodule` and call `enqueueEventInTx(ctx.as.posthog, ...)` from reducers or `captureEvent(ctx.as.posthog, ...)` / `deliverOutbox(ctx.as.posthog, ...)` from procedures. + +The client calls the business operation. Analytics remain a server-side +concern: + +```ts +await conn.reducers.completeOrder({ orderId, totalCents }); +``` + +An operator-owned procedure or scheduled workflow should call +`posthog.deliverOutbox(ctx.as.posthog, { limit })`. Keep provider credentials and +generic event names inside the module. + +Package entrypoints: + +- `@spacetimedb/posthog` can run as a standalone analytics database. +- `@spacetimedb/posthog/submodule` supplies submodule state, configuration, + delivery helpers, and admin views. + +## Architecture notes + +- **Synchronous HTTP API.** Module procedures call PostHog's HTTP endpoints + directly through `ctx.http.fetch`. +- **Direct plus outbox.** Immediate capture is useful for important events. The outbox is for reducer-safe transactional queueing and explicit flush. +- **Browser analytics.** Applications can add `posthog-js` in the frontend for + autocapture and session replay. + +## Testing + +```bash +pnpm test +pnpm exec tsc --noEmit +pnpm run build +npm pack --dry-run --json +``` + +The example app in `example/` mounts the submodule under the `posthog` namespace and subscribes to the admin views. + +## License + +[Apache-2.0](./LICENSE.txt). diff --git a/spacetime-posthog-ts/example/.env.example b/spacetime-posthog-ts/example/.env.example new file mode 100644 index 00000000000..cac3b0b94ae --- /dev/null +++ b/spacetime-posthog-ts/example/.env.example @@ -0,0 +1,10 @@ +PORT=8796 +HOST=127.0.0.1 +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-posthog-example +# Optional. When unset, the server creates a persistent local identity token in +# .stdb-server-token and the logged-in publishing identity authorizes it. +# STDB_SERVER_TOKEN= +POSTHOG_HOST=https://us.i.posthog.com +POSTHOG_PROJECT_API_KEY=phc_replace_me diff --git a/spacetime-posthog-ts/example/.npmrc b/spacetime-posthog-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-posthog-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-posthog-ts/example/README.md b/spacetime-posthog-ts/example/README.md new file mode 100644 index 00000000000..c9e467b2b95 --- /dev/null +++ b/spacetime-posthog-ts/example/README.md @@ -0,0 +1,164 @@ +# Context Cafe + +Context Cafe is a small robot café simulator that demonstrates the +`@spacetimedb/posthog/submodule`. SpacetimeDB owns the catalog, simulation, +per-browser café state, metrics, and analytics outbox. A dedicated local server +identity delivers queued events to PostHog; the browser never receives submodule +administrator privileges or the PostHog project key. + +## What this demonstrates + +- Mounting the PostHog submodule under the `posthog` namespace. +- Enqueuing analytics in deterministic reducers for delivery outside + transactions. +- Delivering the submodule outbox from an authorized server connection. +- Caller-scoped café state and safe public aggregate delivery metrics. +- Editing prices and availability while watching simulated conversion change. +- Synchronizing a TypeScript-authored catalog from `catalog/catalog.ts`. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- A SpacetimeDB CLI and server built from this checkout, with the CLI available as `spacetime`. +- A local SpacetimeDB server reachable as `local`. +- A logged-in CLI identity. The identity that publishes the fresh database becomes + its initial submodule administrator. +- Optional: a PostHog project API key for real event delivery. + +This example uses the workspace SDK. Keep the matching local server running in +a separate terminal: + +```powershell +spacetime start +``` + +Confirm the local server before continuing: + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-posthog-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Set `POSTHOG_PROJECT_API_KEY` in `.env` before starting if you want events delivered +to PostHog. Open , press **Run**, and watch the café and the +`→ PostHog` counter update. + +`build:module:fresh` deletes and recreates only the local `spacetime-posthog-example` +database. Use `pnpm run build:module` when existing data must be preserved. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer +applications install the published release: + +```bash +npm install @spacetimedb/posthog spacetimedb +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +enqueue, delivery, and admin-observability boundaries; the cafe simulator and +its event catalog are demonstration code. + +## Configuration + +| Variable | Default | Purpose | +| ------------------------- | --------------------------- | ------------------------------------------------------------------------ | +| `POSTHOG_PROJECT_API_KEY` | empty | Enables real PostHog delivery. Kept outside the browser. | +| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser and server WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | CLI administration endpoint. Must address the same server as `STDB_URI`. | +| `SPACETIMEDB_DB_NAME` | `spacetime-posthog-example` | Published database name. | +| `STDB_SERVER_TOKEN` | generated locally | Optional pre-provisioned server identity token. | +| `HOST` | `127.0.0.1` | Static-server bind address. | +| `PORT` | `8796` | Static-server port. | + +When `STDB_SERVER_TOKEN` is unset, the server stores its generated identity token in +the ignored `.stdb-server-token` file. On startup, the logged-in CLI publisher calls +`posthog.add_admin_identity` for that identity. This keeps the browser unprivileged +and preserves the delivery identity across restarts. + +## Architecture + +```text +Browser + -> caller-scoped café reducers and views + -> analytics events queued in the posthog submodule namespace + +Authorized example server + -> subscribes to the admin-scoped outbox view + -> calls flush_analytics in bounded batches + -> PostHog ingestion API +``` + +The public `cafe_analytics_summary` view exposes counts only. The detailed outbox +and delivery-log views return rows only to registered PostHog administrators. + +The Node server exposes only: + +| Route | Purpose | +| ----------------- | ---------------------------------------------------------- | +| `GET /api/health` | Local health probe. | +| `GET /api/config` | Browser-safe database and PostHog dashboard configuration. | + +Submodule administrator grants are available only through module operations. + +## Security and deployment boundaries + +- `POSTHOG_PROJECT_API_KEY` is loaded by the server and written to the submodule's + private configuration table through the authenticated CLI owner. +- `.stdb-server-token`, `.env`, and logs are ignored and must not be committed. +- The development server binds to loopback by default. Setting `HOST` to another + address expands its network exposure. +- The example server is scoped to local development. Production deployments + should provision service identities and + lifecycle supervision explicitly. + +## Verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a complete local smoke test, fresh-publish the database, start the server, load +the UI, press **Run**, and confirm that ticks, queued activity, and the PostHog count +advance through the authorized server identity. + +## Troubleshooting + +- **Connection targets disagree:** `STDB_URI`, `STDB_HTTP`, and the server selected + by the publish script must refer to the same SpacetimeDB instance. +- **Server identity cannot be authorized:** publish with the currently logged-in + CLI identity, then restart. Remove `.stdb-server-token` only when + replacing the local server identity. +- **Events stay queued:** verify `POSTHOG_PROJECT_API_KEY`, inspect server output, + and confirm the PostHog host is reachable. +- **Stored browser identity is rejected after a reset:** reload once; the client + discards the rejected browser token and obtains a fresh caller identity automatically. + +## Important files + +- `spacetimedb/src/index.ts`: host schema, scoped views, reducers, and PostHog + delegation. +- `spacetimedb/src/economy.ts`: simulation tuning, capacity rules, pricing, and + deterministic purchase behavior. +- `catalog/catalog.ts`: product, recipe, and scenario source data. +- `scripts/test-economy.ts`: focused tests for the simulator's economy rules. +- `server.ts`: safe startup configuration, server identity, and outbox delivery. +- `src/app.ts`: browser connection and café UI behavior. +- `public/index.html`: café interface structure. +- `public/styles.css`: café presentation. diff --git a/spacetime-posthog-ts/example/catalog/catalog.ts b/spacetime-posthog-ts/example/catalog/catalog.ts new file mode 100644 index 00000000000..b0ae2159bbd --- /dev/null +++ b/spacetime-posthog-ts/example/catalog/catalog.ts @@ -0,0 +1,238 @@ +export interface VariantSeed { + variantId: string; + productId: string; + name: string; + flavor: string; + contextTokens: number; + reasoning: number; + latency: number; + priceCents: number; + discountBps?: number; + active?: boolean; + featured?: boolean; +} + +export interface ProductSeed { + productId: string; + name: string; + category: string; + description: string; + baseAppeal: number; + active?: boolean; + variants: VariantSeed[]; +} + +export interface ScenarioSeed { + scenarioId: string; + name: string; + description: string; + trafficPerTick: number; + priceSensitivity: number; + rushBias: number; + researchBias: number; + visualBias: number; + memoryBias: number; + premiumBias: number; + volatility: number; +} + +export const PRODUCTS: ProductSeed[] = [ + { + productId: 'context_cooler', + name: 'Context Cooler', + category: 'context', + description: + 'A tall glass of extra working memory for agents with long prompts.', + baseAppeal: 68, + variants: [ + { + variantId: 'context_cooler_classic', + productId: 'context_cooler', + name: 'Classic Context', + flavor: 'vanilla', + contextTokens: 64000, + reasoning: 4, + latency: 5, + priceCents: 900, + }, + { + variantId: 'context_cooler_raspberry', + productId: 'context_cooler', + name: 'Raspberry Long Context', + flavor: 'raspberry', + contextTokens: 180000, + reasoning: 6, + latency: 4, + priceCents: 1900, + discountBps: 500, + featured: true, + }, + ], + }, + { + productId: 'reasoning_refresher', + name: 'Reasoning Refresher', + category: 'quality', + description: + 'Extra thinking syrup for bots that refuse to be wrong in public.', + baseAppeal: 72, + variants: [ + { + variantId: 'reasoning_refresher_smart', + productId: 'reasoning_refresher', + name: 'Smart Syrup', + flavor: 'blueberry', + contextTokens: 96000, + reasoning: 8, + latency: 3, + priceCents: 2400, + }, + { + variantId: 'reasoning_refresher_deep', + productId: 'reasoning_refresher', + name: 'Deep Thought Double', + flavor: 'espresso', + contextTokens: 220000, + reasoning: 10, + latency: 2, + priceCents: 3900, + }, + ], + }, + { + productId: 'speed_spritz', + name: 'Speed Spritz', + category: 'latency', + description: 'Cold, fizzy priority inference for bots in a hurry.', + baseAppeal: 66, + variants: [ + { + variantId: 'speed_spritz_priority', + productId: 'speed_spritz', + name: 'Priority Lime', + flavor: 'lime', + contextTokens: 48000, + reasoning: 4, + latency: 9, + priceCents: 1400, + }, + ], + }, + { + productId: 'vision_fizz', + name: 'Vision Fizz', + category: 'multimodal', + description: 'Sparkling image support for bots staring at screenshots.', + baseAppeal: 62, + variants: [ + { + variantId: 'vision_fizz_snapshot', + productId: 'vision_fizz', + name: 'Snapshot Soda', + flavor: 'grape', + contextTokens: 80000, + reasoning: 5, + latency: 5, + priceCents: 1700, + }, + ], + }, + { + productId: 'memory_mint', + name: 'Memory Mint', + category: 'memory', + description: + 'Persistent memory with a clean finish and fewer repeated questions.', + baseAppeal: 58, + variants: [ + { + variantId: 'memory_mint_sticky', + productId: 'memory_mint', + name: 'Sticky Mint', + flavor: 'mint', + contextTokens: 120000, + reasoning: 5, + latency: 4, + priceCents: 2100, + }, + ], + }, + { + productId: 'tool_tonic', + name: 'Tool Tonic', + category: 'tools', + description: 'Function-calling bubbles for agents with things to do.', + baseAppeal: 64, + variants: [ + { + variantId: 'tool_tonic_fizz', + productId: 'tool_tonic', + name: 'Tool Fizz', + flavor: 'ginger', + contextTokens: 90000, + reasoning: 6, + latency: 6, + priceCents: 1600, + }, + ], + }, +]; + +export const SCENARIOS: ScenarioSeed[] = [ + { + scenarioId: 'steady_shift', + name: 'Steady Shift', + description: + 'A normal cafe shift with mixed robot traffic and balanced preferences.', + trafficPerTick: 4, + priceSensitivity: 45, + rushBias: 35, + researchBias: 35, + visualBias: 22, + memoryBias: 24, + premiumBias: 24, + volatility: 18, + }, + { + scenarioId: 'launch_rush', + name: 'Launch Rush', + description: + 'A product launch sends impatient agents sprinting for priority inference.', + trafficPerTick: 8, + priceSensitivity: 28, + rushBias: 70, + researchBias: 34, + visualBias: 24, + memoryBias: 20, + premiumBias: 42, + volatility: 32, + }, + { + scenarioId: 'budget_bots', + name: 'Budget Bots', + description: + 'A coupon crowd wants lots of compute and hates sticker shock.', + trafficPerTick: 6, + priceSensitivity: 82, + rushBias: 26, + researchBias: 28, + visualBias: 18, + memoryBias: 22, + premiumBias: 10, + volatility: 24, + }, + { + scenarioId: 'research_lab', + name: 'Research Lab', + description: + 'Deep-work agents prefer long context, high reasoning, memory, and quality.', + trafficPerTick: 5, + priceSensitivity: 25, + rushBias: 18, + researchBias: 76, + visualBias: 28, + memoryBias: 62, + premiumBias: 58, + volatility: 16, + }, +]; diff --git a/spacetime-posthog-ts/example/package.json b/spacetime-posthog-ts/example/package.json new file mode 100644 index 00000000000..83c60d7dc03 --- /dev/null +++ b/spacetime-posthog-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-posthog-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts", + "test": "tsx scripts/test-economy.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-posthog-ts/example/public/index.html b/spacetime-posthog-ts/example/public/index.html new file mode 100644 index 00000000000..da01d87fa5c --- /dev/null +++ b/spacetime-posthog-ts/example/public/index.html @@ -0,0 +1,314 @@ + + + + + + + + Context Cafe + + + +
+
+
+ +
+

Context Cafe

+
+
+
+ + View in PostHog + + +
+ + + + + + +
+
+
+ +
+
+ Cash on hand + $0.00 +
▲ $0.00 profit
+
+ $0.00 sales + $0.00 supplies +
+
+ ★★★☆☆ + reputation 50 +
+
+
+
+ Workers1/tick +
+ +
+
+
+ Machinesstandard +
+ +
+
+
+ Counterholds 6 +
+ +
+
+
+ Storeroomstandard +
+ +
+
+
+ +
+
+

Today's menu

+ tap a drink to price it +
+ +
+ +
+
+ 🔥 Rush hour: bots are pouring in +
+
+
+
+
+ Compute0/150 +
+
+
+
+
+ GPU-seconds +
+
+
+
+ Context0/250 +
+
+
+
+
+ tokens +
+
+
+
+ Memory0/100 +
+
+
+
+
+ GB +
+
+
+
+
+
+
+
+
+
+
+ +
+ 0 ticks + + 0.0% conv + $0.00 avg order + 0 stockouts + 0 gave up + 0 → PostHog +
+
+ +
+ Built on + SpacetimeDB +
+ +
+
+ + +
+ + + + + diff --git a/spacetime-posthog-ts/example/public/styles.css b/spacetime-posthog-ts/example/public/styles.css new file mode 100644 index 00000000000..d7374b10bc4 --- /dev/null +++ b/spacetime-posthog-ts/example/public/styles.css @@ -0,0 +1,1159 @@ +:root { + color-scheme: dark; + + --bg: #0a1013; + --panel-top: #14201f; + --panel-bot: #0f1819; + --inset: #0c1517; + --line: #25393b; + --line-soft: #1a2829; + + --text: #edf4f1; + --muted: #97aab0; + --faint: #65797f; + + --amber: #f6b94e; + --amber-deep: #e29f2c; + --teal: #3ac9c4; + --green: #5fd08a; + --red: #f37e76; + + --radius: 13px; + --radius-sm: 9px; + --shadow: 0 18px 40px -28px rgba(0, 0, 0, 0.9); + --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient( + 1100px 480px at 78% -8%, + rgba(246, 185, 78, 0.08), + transparent 60% + ), + radial-gradient( + 900px 520px at 8% 0%, + rgba(58, 201, 196, 0.06), + transparent 55% + ), + var(--bg); + background-attachment: fixed; + color: var(--text); + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + -webkit-font-smoothing: antialiased; + font-size: 14px; +} + +button, +input, +select { + font: inherit; + color: var(--text); +} + +button { + height: 36px; + padding: 0 14px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: linear-gradient(180deg, #18272a, #122023); + cursor: pointer; + font-weight: 600; + transition: + border-color 0.14s ease, + background 0.14s ease, + transform 0.08s ease; +} +button:hover { + border-color: #36545a; +} +button:active { + transform: translateY(1px); +} +button.primary { + background: linear-gradient(180deg, var(--amber), var(--amber-deep)); + color: #1d1503; + border-color: transparent; + font-weight: 700; +} +button.primary:hover { + box-shadow: 0 0 0 1px rgba(246, 185, 78, 0.4); +} +button.running { + background: linear-gradient(180deg, #1d3331, #16292a); + color: var(--text); + border-color: #2f5a52; +} +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--teal); + outline-offset: 1px; +} + +input, +select { + width: 100%; + height: 38px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 0 11px; +} + +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 19px; + letter-spacing: -0.01em; +} +h2 { + font-size: 14px; + font-weight: 650; +} +h3 { + font-size: 14px; +} +p, +small { + color: var(--muted); + font-size: 12.5px; + line-height: 1.5; +} + +label { + display: block; + color: var(--muted); + font: 600 10.5px/1.2 var(--mono); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.app { + max-width: 1320px; + margin: 0 auto; + padding: 18px 22px 30px; +} + +/* ---- header ---- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 13px 16px; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: linear-gradient(180deg, var(--panel-top), var(--panel-bot)); + box-shadow: var(--shadow); +} +.brand { + display: flex; + align-items: center; + gap: 13px; + min-width: 0; +} +.brand-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 12px; + font-size: 21px; + line-height: 1; + background: radial-gradient( + 120% 120% at 30% 20%, + rgba(246, 185, 78, 0.22), + rgba(58, 201, 196, 0.1) 70%, + transparent + ); + border: 1px solid var(--line); +} +.brand-text h1 { + margin: 0; + font-size: 18px; + display: block; + letter-spacing: -0.01em; +} + +.page-foot { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--line-soft); +} +.page-foot .by { + font: 600 11px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.12em; +} +.page-foot img { + height: 28px; + width: auto; + opacity: 0.9; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} +.ph-link { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + color: var(--teal); + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: + border-color 0.14s ease, + background 0.14s ease; +} +.ph-link:hover { + border-color: var(--teal); + background: rgba(58, 201, 196, 0.08); +} +.transport { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 7px; + border: 1px solid var(--line-soft); + border-radius: 11px; + background: var(--inset); +} +.transport button { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.run-btn { + min-width: 88px; + gap: 7px; +} +.run-btn .run-ico { + display: inline-flex; +} +.run-btn .i-pause { + display: none; +} +.run-btn.running .i-play { + display: none; +} +.run-btn.running .i-pause { + display: inline; +} +.t-div { + width: 1px; + height: 20px; + background: var(--line-soft); +} +.speed-field { + display: inline-flex; + align-items: center; + gap: 7px; + margin: 0; +} +.speed-field > span { + font: 600 9.5px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.speed-field select { + width: 92px; + height: 30px; + border-color: var(--line-soft); +} +.transport .ghost { + color: var(--muted); +} +.transport .ghost:hover { + border-color: var(--red); + color: var(--red); +} + +/* ---- the stand (scene) ---- */ +.stage { + position: relative; + margin-top: 16px; + display: grid; + grid-template-columns: 256px minmax(0, 1fr); + gap: 20px 24px; + align-items: start; + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: + radial-gradient( + 820px 280px at 50% -30%, + rgba(246, 185, 78, 0.13), + transparent 70% + ), + radial-gradient( + 640px 260px at 10% 130%, + rgba(58, 201, 196, 0.08), + transparent 60% + ), + linear-gradient(180deg, #16231f 0%, #101a1b 54%, #0c1517 100%); + box-shadow: var(--shadow); + padding: 22px; +} + +/* wallet HUD */ +.wallet { + grid-column: 1; +} +.wallet-label { + font: 700 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.12em; +} +.wallet-cash { + display: block; + font-size: 42px; + font-weight: 800; + letter-spacing: -0.025em; + color: var(--green); + margin: 7px 0 5px; + line-height: 1; + font-variant-numeric: tabular-nums; +} +.wallet-profit { + font: 700 12px/1 var(--mono); + color: var(--muted); +} +.wallet-profit.up { + color: var(--green); +} +.wallet-profit.down { + color: var(--red); +} +.wallet-break { + display: flex; + gap: 14px; + margin-top: 9px; + font: 600 10.5px/1 var(--mono); + color: var(--faint); +} +.wallet-break b { + font-variant-numeric: tabular-nums; +} +.wallet-break .up b { + color: var(--green); +} +.wallet-break .down b { + color: var(--amber); +} +.rep { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 14px; +} +.rep-stars { + font-size: 15px; + letter-spacing: 1px; + color: var(--amber); +} +.rep-meta { + font: 600 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.rep-meta b { + color: var(--text); +} +.upgrades { + display: grid; + gap: 7px; + margin-top: 14px; +} +.upgrade { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 8px 10px; +} +.up-name { + display: block; + font: 700 9px/1 var(--mono); + color: var(--amber); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.up-state { + display: block; + font-size: 12px; + color: var(--muted); + margin-top: 3px; +} +.upgrade button { + height: 28px; + padding: 0 10px; + font: 700 11px/1 var(--mono); + flex: none; +} +.upgrade button:disabled { + opacity: 0.4; + cursor: not-allowed; + border-color: var(--line-soft); +} + +/* menu board */ +.board { + grid-column: 2; + min-width: 0; +} +.board-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 11px; +} +.board-head h2 { + font-size: 15px; +} +.board-hint { + font: 600 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* counter scene */ +.counter-scene { + grid-column: 1 / -1; +} +.rush-banner { + display: none; + margin-bottom: 8px; + padding: 7px 12px; + border: 1px solid var(--amber-deep); + border-radius: var(--radius-sm); + background: linear-gradient( + 90deg, + rgba(246, 185, 78, 0.18), + rgba(246, 185, 78, 0.05) + ); + color: var(--amber); + font: 700 11px/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.08em; + animation: rushPulse 1.6s ease-in-out infinite; +} +.rush-banner.on { + display: block; +} +@keyframes rushPulse { + 0%, + 100% { + opacity: 0.85; + } + 50% { + opacity: 1; + box-shadow: 0 0 0 1px rgba(246, 185, 78, 0.35); + } +} +.counter-top { + position: relative; + padding: 13px 14px; + border: 1px solid var(--line); + border-bottom: none; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: linear-gradient(180deg, #1d2d2d, #152323); +} +.counter-top::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 4px; + background: linear-gradient( + 180deg, + rgba(246, 185, 78, 0.4), + rgba(246, 185, 78, 0.04) + ); +} +.dispensers { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} +.tank { + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 10px 12px; +} +.tank-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.tank-name { + font: 700 9.5px/1 var(--mono); + color: var(--amber); + text-transform: uppercase; + letter-spacing: 0.1em; +} +.tank-amt { + display: inline-flex; + align-items: baseline; + gap: 1px; +} +.tank-units { + font-size: 18px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} +.tank-units.low { + color: var(--red); +} +.tank-cap { + font: 600 11px/1 var(--mono); + color: var(--faint); +} +.tank-track { + height: 7px; + margin: 9px 0; + border-radius: 999px; + background: #0a1416; + border: 1px solid var(--line-soft); + overflow: hidden; +} +.tank-fill { + height: 100%; + width: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--teal), var(--green)); + transition: width 0.35s ease; +} +.tank-fill.low { + background: linear-gradient(90deg, #b8554f, var(--red)); +} +.tank-row2 { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.tank-sub { + font-size: 10.5px; + color: var(--faint); +} +.tank button { + height: 28px; + padding: 0 10px; + font: 700 11px/1 var(--mono); + flex: none; +} +.tank button:disabled { + opacity: 0.4; + cursor: not-allowed; + border-color: var(--line-soft); +} + +.floor { + position: relative; + padding: 22px 14px 12px; + min-height: 124px; + border: 1px solid var(--line); + border-top: none; + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + background: + radial-gradient( + 130% 150% at 50% 130%, + rgba(58, 201, 196, 0.07), + transparent 60% + ), + linear-gradient(180deg, #0e1719, #0a1213); +} + +/* ticker */ +.ticker { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; + margin-top: 2px; + padding-top: 14px; + border-top: 1px solid var(--line-soft); + font: 600 11px/1 var(--mono); + color: var(--faint); +} +.ticker > span { + display: inline-flex; + align-items: baseline; + gap: 5px; +} +.ticker b { + color: var(--text); + font-variant-numeric: tabular-nums; +} +.ticker .warn b { + color: var(--red); +} +.ticker .ph b { + color: var(--green); +} + +.funnel { + display: flex; + gap: 16px; + flex-wrap: wrap; +} +.f { + display: inline-flex; + align-items: baseline; + gap: 5px; + font: 600 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.f b { + font-size: 16px; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.f.buy b { + color: var(--green); +} +.f.off b { + color: var(--red); +} + +.block-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} +.block-head p { + margin-top: 3px; +} + +/* ---- the counter (gamified bot line) ---- */ +.counter { + position: relative; +} +.counter .bots { + display: flex; + gap: 6px; + align-items: flex-end; + min-height: 96px; + overflow: hidden; +} +.counter .pops { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} +.bot { + position: relative; + width: 74px; + display: grid; + justify-items: center; + gap: 7px; + padding: 6px 0; + cursor: default; +} +.bot::before { + content: ''; + position: absolute; + bottom: 26px; + width: 50px; + height: 50px; + border-radius: 50%; + background: radial-gradient(circle, var(--stage, #5f7882), transparent 68%); + opacity: 0.3; + z-index: 0; +} +.bot-emoji { + font-size: 30px; + line-height: 1; + z-index: 1; + animation: bob 3.4s ease-in-out infinite; +} +.bot-profile { + font: 600 10px/1 var(--mono); + color: var(--muted); + text-transform: capitalize; + z-index: 1; +} +.bot-wants { + font: 600 8.5px/1.1 var(--mono); + color: var(--faint); + text-align: center; + z-index: 1; + max-width: 72px; +} +.bot.waiting { + --stage: #6f8790; +} +.bot.thrifty { + --stage: var(--amber); +} +.bot.thrifty .bot-wants { + color: var(--amber); +} +.bot.viewed { + --stage: #6f8790; +} +.bot.cart { + --stage: var(--teal); +} +.bot.checkout { + --stage: var(--amber); +} +.bot.purchased { + --stage: var(--green); +} +.bot.purchased .bot-emoji { + animation: pop 0.5s ease; +} +.bot.abandoned { + --stage: var(--red); + opacity: 0.6; +} +.bot.abandoned .bot-emoji { + transform: rotate(-10deg); +} +.bot-tip { + position: absolute; + bottom: calc(100% - 4px); + left: 50%; + transform: translate(-50%, 4px); + width: max-content; + max-width: 200px; + background: #0a1417; + border: 1px solid var(--line); + border-radius: 8px; + padding: 7px 10px; + font: 600 11px/1.4 var(--mono); + color: var(--text); + text-align: center; + opacity: 0; + pointer-events: none; + transition: + opacity 0.12s ease, + transform 0.12s ease; + z-index: 5; + box-shadow: 0 12px 26px -14px rgba(0, 0, 0, 0.95); +} +.bot-tip b { + color: var(--text); +} +.bot:hover { + z-index: 6; +} +.bot:hover .bot-tip { + opacity: 1; + transform: translate(-50%, 0); +} +.pop { + position: absolute; + font: 800 15px/1 var(--mono); + text-shadow: 0 2px 6px rgba(0, 0, 0, 0.7); + animation: floatUp 1.3s ease forwards; + white-space: nowrap; +} +.pop.sale { + color: var(--green); +} +.pop.miss { + color: var(--red); + font-size: 12px; + font-weight: 700; +} +.bot.entering { + animation: walkIn 0.45s ease both; +} +.bot.leaving { + animation: walkOut 0.45s ease both; +} +@keyframes walkIn { + from { + opacity: 0; + transform: translateX(46px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +@keyframes walkOut { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(-34px); + } +} +@keyframes bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} +@keyframes pop { + 0% { + transform: scale(1); + } + 40% { + transform: scale(1.35); + } + 100% { + transform: scale(1); + } +} +@keyframes floatUp { + 0% { + opacity: 0; + transform: translate(-50%, 8px) scale(0.9); + } + 18% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate(-50%, -50px) scale(1.05); + } +} + +.menu-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(186px, 1fr)); + gap: 10px; +} +.menu-card { + display: grid; + gap: 4px; + text-align: left; + align-content: start; + height: auto; + min-height: 96px; + padding: 12px 13px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + cursor: pointer; +} +.menu-card:hover { + transform: translateY(-2px); + border-color: var(--teal); +} +.menu-card.active { + border-color: var(--amber); + box-shadow: 0 0 0 1px var(--amber); +} +.menu-card.muted { + opacity: 0.5; +} +.menu-card-cat { + font: 700 9.5px/1 var(--mono); + color: var(--amber); + text-transform: uppercase; + letter-spacing: 0.1em; +} +.menu-card-name { + font-size: 14.5px; + font-weight: 650; + color: var(--text); + line-height: 1.25; +} +.menu-card-flavor { + font-size: 12px; + color: var(--faint); + text-transform: capitalize; +} +.menu-card-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; +} +.menu-card-price { + font: 700 15px/1 var(--mono); + color: var(--text); +} +.menu-card-badges { + display: flex; + gap: 5px; + flex-wrap: wrap; + justify-content: flex-end; +} +.chip { + font: 700 9px/1 var(--mono); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 4px 7px; + border-radius: 999px; + border: 1px solid currentColor; +} +.chip.featured { + color: var(--amber); +} +.chip.discount { + color: var(--green); +} +.chip.off { + color: var(--faint); +} + +/* ---- drawer ---- */ +.scrim { + position: fixed; + inset: 0; + background: rgba(4, 8, 9, 0.55); + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; + z-index: 40; +} +.scrim.open { + opacity: 1; + pointer-events: auto; +} +.drawer { + position: fixed; + top: 0; + right: 0; + height: 100%; + width: 392px; + max-width: 92vw; + transform: translateX(102%); + transition: transform 0.2s ease; + z-index: 50; + background: linear-gradient(180deg, var(--panel-top), var(--panel-bot)); + border-left: 1px solid var(--line); + box-shadow: -24px 0 50px -30px rgba(0, 0, 0, 0.9); + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 20px; +} +.drawer.open { + transform: translateX(0); +} +.drawer-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} +.drawer-close { + height: 30px; + width: 30px; + padding: 0; + border-radius: 8px; + flex: none; + display: grid; + place-items: center; + font-size: 16px; + color: var(--muted); +} +#drawerSub { + font: 600 11px/1.2 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-top: 5px; +} +.drawer h3 { + font-size: 18px; +} + +.recipe-grid { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 9px 12px; + border: 1px solid var(--line-soft); + border-radius: var(--radius-sm); + background: var(--inset); + padding: 14px; + margin-top: 16px; + font-size: 12.5px; +} +.recipe-grid span { + color: var(--faint); + font: 600 10.5px/1.3 var(--mono); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.recipe-grid strong { + overflow-wrap: anywhere; + font-weight: 600; +} +.recipe-grid strong.margin.pos { + color: var(--green); +} +.recipe-grid strong.margin.neg { + color: var(--red); +} +.recipe-head { + margin: 16px 0 -8px; + font: 700 10px/1 var(--mono); + color: var(--faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.field { + display: grid; + gap: 7px; + margin-top: 15px; +} +.inline { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} +.drawer-actions { + display: grid; + gap: 8px; + margin-top: 18px; +} +.drawer-actions .row2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +#featureVariant.is-on { + background: linear-gradient(180deg, var(--amber), var(--amber-deep)); + color: #1d1503; + border-color: transparent; +} + +.empty { + border: 1px dashed var(--line-soft); + border-radius: var(--radius-sm); + color: var(--faint); + padding: 24px; + text-align: center; + font-size: 12.5px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 22px; + z-index: 60; + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--line); + border-radius: 999px; + padding: 9px 16px; + color: var(--muted); + background: rgba(12, 21, 23, 0.94); + font: 600 12px/1.3 var(--mono); + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.95); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 14px); + transition: + opacity 0.2s ease, + transform 0.2s ease; +} +.toast.show { + opacity: 1; + pointer-events: auto; + transform: translate(-50%, 0); +} +.toast::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + flex: none; +} +.toast.ok { + color: var(--green); +} +.toast.error { + border-color: #6e3331; + color: #ffb0ab; + background: rgba(32, 16, 16, 0.96); +} + +@media (max-width: 820px) { + .stage { + grid-template-columns: 1fr; + } + .wallet { + grid-column: 1; + } + .board { + grid-column: 1; + } +} +@media (max-width: 560px) { + .app { + padding: 12px; + } + .topbar { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + .transport { + justify-content: space-between; + flex-wrap: wrap; + } + .dispensers { + grid-template-columns: 1fr; + } + .menu-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/spacetime-posthog-ts/example/scripts/test-economy.ts b/spacetime-posthog-ts/example/scripts/test-economy.ts new file mode 100644 index 00000000000..f73a88b1b92 --- /dev/null +++ b/spacetime-posthog-ts/example/scripts/test-economy.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; + +import { + arrivalDemand, + maximumQueueLength, + seededRandom, + serviceCapacity, + storageCapacity, + upgradeCost, +} from '../spacetimedb/src/economy'; +import type { EconRow } from '../spacetimedb/src/schema'; + +const economy = { + workers: 2, + machineLevel: 1, + storageLevel: 2, + seats: 3, +} as EconRow; + +assert.equal(storageCapacity('context', 0), 250); +assert.equal(storageCapacity('context', 2), 500); +assert.equal(serviceCapacity(economy), 2); +assert.equal(maximumQueueLength(economy), 12); +assert.equal(arrivalDemand(10, 50), 10); +assert.equal(upgradeCost('worker', economy), 12_000n); +assert.equal(upgradeCost('machine', economy), 16_000n); +assert.equal(upgradeCost('storage', economy), 21_000n); +assert.equal(upgradeCost('counter', economy), 20_000n); + +const first = seededRandom('stable-seed'); +const second = seededRandom('stable-seed'); +for (let index = 0; index < 10; index++) { + const value = first(); + assert.equal(value, second()); + assert.ok(value >= 0 && value < 1); +} + +console.log('posthog economy tests passed'); diff --git a/spacetime-posthog-ts/example/server.ts b/spacetime-posthog-ts/example/server.ts new file mode 100644 index 00000000000..409fc308cf2 --- /dev/null +++ b/spacetime-posthog-ts/example/server.ts @@ -0,0 +1,279 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { + discardStoredServerToken, + exampleUiAssetsDir, + grantServerIdentity, + loadServerToken, + saveServerToken, +} from '@spacetimedb/submodule-shared/server'; +import { DbConnection, tables, type ErrorContext } from './src/module_bindings'; +import { PRODUCTS, SCENARIOS } from './catalog/catalog'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8796', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-posthog-example'; +const POSTHOG_HOST = process.env.POSTHOG_HOST ?? 'https://us.i.posthog.com'; +const POSTHOG_PROJECT_API_KEY = process.env.POSTHOG_PROJECT_API_KEY ?? ''; +const SPACETIME_BIN = process.env.SPACETIME_BIN?.trim() || 'spacetime'; +const SERVER_TOKEN_PATH = path.resolve(__dirname, '.stdb-server-token'); + +let stdb: DbConnection | null = null; +let flushTimer: ReturnType | undefined; +let flushDueAt = 0; +let flushing = false; + +type ConnectedServer = { + connection: DbConnection; + identity: string; +}; + +function connectAttempt(token: string | undefined): Promise { + return new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(STDB_URI) + .withDatabaseName(DB_NAME) + .onConnect((connection, identity, nextToken) => { + if (!process.env.STDB_SERVER_TOKEN?.trim()) { + saveServerToken(SERVER_TOKEN_PATH, nextToken); + } + resolve({ connection, identity: identity.toHexString() }); + }) + .onDisconnect((_ctx, err) => { + console.error( + `[stdb] disconnected: ${err?.message ?? 'unknown'} - exiting for supervisor restart` + ); + process.exit(1); + }) + .onConnectError((_ctx: ErrorContext, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); +} + +async function connect(): Promise { + const stored = loadServerToken( + SERVER_TOKEN_PATH, + process.env.STDB_SERVER_TOKEN + ); + try { + return await connectAttempt(stored.token); + } catch (error) { + if (stored.source !== 'file') throw error; + discardStoredServerToken(SERVER_TOKEN_PATH); + console.warn( + '[stdb] stored server token was rejected; creating a new identity' + ); + return connectAttempt(undefined); + } +} + +function callSpacetime(procedureName: string, ...args: unknown[]): void { + const result = spawnSync( + SPACETIME_BIN, + [ + 'call', + '--server', + STDB_HTTP, + DB_NAME, + procedureName, + ...args.map(arg => JSON.stringify(arg)), + ], + { encoding: 'utf8', shell: false } + ); + if (result.status !== 0) { + throw new Error( + result.stderr.trim() || + result.stdout.trim() || + `spacetime exited ${result.status}` + ); + } +} + +function configurePostHogFromEnv(): void { + if (!POSTHOG_PROJECT_API_KEY) { + throw new Error("POSTHOG_PROJECT_API_KEY not set in this server's .env."); + } + callSpacetime( + 'posthog.set_posthog_config', + POSTHOG_HOST, + POSTHOG_PROJECT_API_KEY + ); +} + +function syncCatalog(): void { + callSpacetime( + 'sync_catalog', + JSON.stringify(PRODUCTS), + JSON.stringify(SCENARIOS) + ); +} + +function scheduleAnalyticsFlush(): void { + if (!stdb || flushing) return; + let nextAttemptMs = Number.POSITIVE_INFINITY; + for (const row of stdb.db.posthogOutboxAdmin.iter()) { + const value = + row.status.tag === 'Queued' + ? Number(row.nextAttemptAt.microsSinceUnixEpoch / 1000n) + : row.status.tag === 'Processing' + ? Number(row.claimExpiresAtMicros / 1000n) + : Number.POSITIVE_INFINITY; + if (value < nextAttemptMs) nextAttemptMs = value; + } + if (!Number.isFinite(nextAttemptMs)) return; + const delay = Math.max(0, nextAttemptMs - Date.now()); + const dueAt = Date.now() + delay; + if (flushTimer && dueAt >= flushDueAt - 5) return; + if (flushTimer) clearTimeout(flushTimer); + flushDueAt = dueAt; + flushTimer = setTimeout(() => { + flushTimer = undefined; + flushDueAt = 0; + void flushAnalytics(); + }, delay); +} + +async function flushAnalytics(): Promise { + if (!stdb || flushing) return; + flushing = true; + try { + await stdb.procedures.flushAnalytics({ limit: 50 }); + } catch (error) { + console.error( + `[posthog] delivery failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + flushing = false; + scheduleAnalyticsFlush(); + } +} + +function startAnalyticsDelivery(connection: DbConnection): void { + connection.db.posthogOutboxAdmin.onInsert(scheduleAnalyticsFlush); + connection.db.posthogOutboxAdmin.onUpdate(scheduleAnalyticsFlush); + connection + .subscriptionBuilder() + .onApplied(scheduleAnalyticsFlush) + .onError(ctx => + console.error(`[posthog] outbox subscription failed: ${ctx.event}`) + ) + .subscribe([tables.posthogOutboxAdmin]); +} + +// Derive the PostHog app (dashboard) URL from the ingestion host, e.g. +// https://us.i.posthog.com -> https://us.posthog.com. Self-hosted hosts are +// already the app host, so they pass through unchanged. +function posthogAppUrl(): string { + try { + const u = new URL(POSTHOG_HOST); + const host = u.hostname.endsWith('.i.posthog.com') + ? u.hostname.replace('.i.posthog.com', '.posthog.com') + : u.hostname; + return `${u.protocol}//${host}${u.port ? `:${u.port}` : ''}`; + } catch { + return 'https://us.posthog.com'; + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + posthogAppUrl: POSTHOG_PROJECT_API_KEY ? posthogAppUrl() : null, + }); +}); + +(async () => { + console.log(`[stdb] connecting to ${STDB_URI}/${DB_NAME} ...`); + try { + const connected = await connect(); + stdb = connected.connection; + grantServerIdentity({ + spacetimeBin: SPACETIME_BIN, + server: STDB_HTTP, + database: DB_NAME, + procedure: 'posthog.add_admin_identity', + identity: connected.identity, + }); + console.log(`[stdb] connected as authorized server ${connected.identity}`); + } catch (err) { + console.error( + `[stdb] connection failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[stdb] is the SpacetimeDB host running and the module published?' + ); + process.exit(1); + } + + try { + syncCatalog(); + console.log('[catalog] Context Cafe catalog synced'); + } catch (err) { + console.warn( + `[catalog] sync failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + + if (POSTHOG_PROJECT_API_KEY) { + try { + configurePostHogFromEnv(); + console.log('[posthog] config loaded from .env'); + } catch (err) { + console.warn( + `[posthog] automatic config failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + startAnalyticsDelivery(stdb); + + app.listen(PORT, HOST, () => { + process.stdout.write( + `\nspacetime-posthog-example listening on http://${HOST}:${PORT}\n` + ); + if (!POSTHOG_PROJECT_API_KEY) { + process.stdout.write( + ' ! POSTHOG_PROJECT_API_KEY not set - configure PostHog in .env and restart\n' + ); + } + process.stdout.write(` spacetime: ${SPACETIME_BIN}\n`); + process.stdout.write(` database: ${STDB_URI}/${DB_NAME}\n\n`); + }); +})(); diff --git a/spacetime-posthog-ts/example/spacetimedb/.npmrc b/spacetime-posthog-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-posthog-ts/example/spacetimedb/package.json b/spacetime-posthog-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..275b7be1f80 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-posthog-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-posthog-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-posthog-example" + }, + "dependencies": { + "@spacetimedb/posthog": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/catalog.ts b/spacetime-posthog-ts/example/spacetimedb/src/catalog.ts new file mode 100644 index 00000000000..64a35c31389 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/catalog.ts @@ -0,0 +1,149 @@ +import { t } from 'spacetimedb/server'; + +import { + MAX_SYNC_ROWS, + spacetimedb, + type ProductInput, + type ScenarioInput, + type VariantInput, +} from './schema'; +import { clampU32, fail, requireId } from './validation'; + +function parseArray(json: string, field: string): T[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + fail(`invalid_${field}_json`); + } + if (!Array.isArray(parsed)) fail(`invalid_${field}_json`); + if (parsed.length > MAX_SYNC_ROWS) fail(`${field}_too_large`); + return parsed as T[]; +} + +export const syncCatalog = spacetimedb.reducer( + { productsJson: t.string(), scenariosJson: t.string() }, + (ctx, args) => { + const products = parseArray( + args.productsJson, + 'products' + ); + const scenarios = parseArray( + args.scenariosJson, + 'scenarios' + ); + + const keepProducts = new Set(); + const keepVariants = new Set(); + for (const item of products) { + const productId = requireId(item.productId, 'product_id'); + keepProducts.add(productId); + const row = { + productId, + name: requireId(item.name, 'product_name'), + category: requireId(item.category, 'product_category'), + description: + typeof item.description === 'string' ? item.description : '', + baseAppeal: clampU32(item.baseAppeal, 'base_appeal', 1, 100), + active: item.active ?? true, + }; + const existing = ctx.db.productTemplate.productId.find(productId); + if (existing) { + ctx.db.productTemplate.productId.update({ ...existing, ...row }); + } else { + ctx.db.productTemplate.insert(row); + } + + for (const rawVariant of item.variants ?? []) { + const variantId = requireId(rawVariant.variantId, 'variant_id'); + keepVariants.add(variantId); + const variantRow = { + variantId, + productId, + name: requireId(rawVariant.name, 'variant_name'), + flavor: requireId(rawVariant.flavor, 'flavor'), + contextTokens: clampU32( + rawVariant.contextTokens, + 'context_tokens', + 1_000, + 2_000_000 + ), + reasoning: clampU32(rawVariant.reasoning, 'reasoning', 1, 10), + latency: clampU32(rawVariant.latency, 'latency', 1, 10), + priceCents: clampU32( + rawVariant.priceCents, + 'price_cents', + 0, + 250_000 + ), + discountBps: clampU32( + rawVariant.discountBps ?? 0, + 'discount_bps', + 0, + 9000 + ), + active: rawVariant.active ?? true, + featured: rawVariant.featured ?? false, + }; + const existingVariant = + ctx.db.variantTemplate.variantId.find(variantId); + if (existingVariant) { + ctx.db.variantTemplate.variantId.update({ + ...existingVariant, + ...variantRow, + }); + } else { + ctx.db.variantTemplate.insert(variantRow); + } + } + } + + for (const row of [...ctx.db.variantTemplate.iter()]) { + if (!keepVariants.has(row.variantId)) ctx.db.variantTemplate.delete(row); + } + for (const row of [...ctx.db.productTemplate.iter()]) { + if (!keepProducts.has(row.productId)) ctx.db.productTemplate.delete(row); + } + + for (const rawScenario of scenarios) { + const scenarioId = requireId(rawScenario.scenarioId, 'scenario_id'); + const row = { + scenarioId, + name: requireId(rawScenario.name, 'scenario_name'), + description: + typeof rawScenario.description === 'string' + ? rawScenario.description + : '', + trafficPerTick: clampU32( + rawScenario.trafficPerTick, + 'traffic_per_tick', + 1, + 30 + ), + priceSensitivity: clampU32( + rawScenario.priceSensitivity, + 'price_sensitivity', + 1, + 100 + ), + rushBias: clampU32(rawScenario.rushBias, 'rush_bias', 1, 100), + researchBias: clampU32( + rawScenario.researchBias, + 'research_bias', + 1, + 100 + ), + visualBias: clampU32(rawScenario.visualBias, 'visual_bias', 1, 100), + memoryBias: clampU32(rawScenario.memoryBias, 'memory_bias', 1, 100), + premiumBias: clampU32(rawScenario.premiumBias, 'premium_bias', 1, 100), + volatility: clampU32(rawScenario.volatility, 'volatility', 0, 100), + }; + const existing = ctx.db.scenario.scenarioId.find(scenarioId); + if (existing) { + ctx.db.scenario.scenarioId.update({ ...existing, ...row }); + } else { + ctx.db.scenario.insert(row); + } + } + } +); diff --git a/spacetime-posthog-ts/example/spacetimedb/src/economy.ts b/spacetime-posthog-ts/example/spacetimedb/src/economy.ts new file mode 100644 index 00000000000..2d0c80ee3f3 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/economy.ts @@ -0,0 +1,246 @@ +import type { + EconRow, + ProductRow, + ScenarioRow, + VariantRow, + WriteCtx, +} from './schema'; + +export const SUPPLY_PRICE = { + compute: 50, + context: 20, + memory: 40, +} as const; +export type SupplyKind = keyof typeof SUPPLY_PRICE; + +export const START_CASH = 20_000n; +export const START_INVENTORY = { + compute: 120, + context: 200, + memory: 60, +}; +export const START_REPUTATION = 50; +export const MAX_MACHINE_LEVEL = 5; +export const PATIENCE_TICKS = 3; +export const RUSH_CYCLE_TICKS = 45; +export const RUSH_LENGTH_TICKS = 12; +export const RUSH_MULTIPLIER = 2.3; +export const REPUTATION_ON_SALE = 1; +export const REPUTATION_ON_STOCKOUT = 2; +export const REPUTATION_ON_RENEGE = 3; + +type ServiceEconomy = Pick; +type QueueEconomy = Pick; +type UpgradeEconomy = Pick< + EconRow, + 'workers' | 'machineLevel' | 'storageLevel' | 'seats' +>; +type SupplyProduct = Pick; +type SupplyVariant = Pick; + +const COUNTER_BASE = 6; +const UPGRADE_PRICE = { + worker: 6_000, + machine: 8_000, + counter: 5_000, + storage: 7_000, +}; +const STORAGE_BASE = { compute: 150, context: 250, memory: 100 }; + +export function storageCapacity(kind: SupplyKind, level: number): number { + return Math.round(STORAGE_BASE[kind] * (1 + 0.5 * level)); +} + +export function clampReputation(value: number): number { + return Math.max(0, Math.min(100, value)); +} + +export function serviceCapacity(economy: ServiceEconomy): number { + return Math.max(1, economy.workers); +} + +export function maximumQueueLength(economy: QueueEconomy): number { + return COUNTER_BASE + economy.seats * 2; +} + +export function arrivalDemand(baseTraffic: number, reputation: number): number { + return Math.max(1, Math.round(baseTraffic * (0.5 + reputation / 100))); +} + +export function upgradeCost(kind: string, economy: UpgradeEconomy): bigint { + if (kind === 'worker') return BigInt(UPGRADE_PRICE.worker * economy.workers); + if (kind === 'machine') + return BigInt(UPGRADE_PRICE.machine * (economy.machineLevel + 1)); + if (kind === 'storage') + return BigInt(UPGRADE_PRICE.storage * (economy.storageLevel + 1)); + return BigInt(UPGRADE_PRICE.counter * (economy.seats + 1)); +} + +export function ensureEconomy(ctx: WriteCtx, owner: string): EconRow { + const existing = ctx.db.econ.owner.find(owner); + if (existing) return existing; + const row = { + owner, + cashCents: START_CASH, + computeUnits: START_INVENTORY.compute, + contextUnits: START_INVENTORY.context, + memoryUnits: START_INVENTORY.memory, + suppliesSpentCents: 0n, + stockouts: 0, + reputation: START_REPUTATION, + workers: 1, + machineLevel: 0, + seats: 0, + storageLevel: 0, + reneged: 0, + updatedAt: ctx.timestamp, + }; + ctx.db.econ.insert(row); + return row; +} + +export function supplyCost( + product: SupplyProduct, + variant: SupplyVariant, + machineLevel = 0 +): { compute: number; context: number; memory: number } { + const efficiency = Math.max(0.6, 1 - 0.08 * machineLevel); + return { + context: Math.max( + 1, + Math.round(Math.ceil(variant.contextTokens / 20_000) * efficiency) + ), + compute: Math.max( + 1, + Math.round((1 + Math.ceil(variant.reasoning / 3)) * efficiency) + ), + memory: Math.max( + 1, + Math.round((product.category === 'memory' ? 6 : 1) * efficiency) + ), + }; +} + +function hashSeed(input: string): number { + let hash = 2_166_136_261; + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return hash >>> 0; +} + +export function seededRandom(seed: string): () => number { + let value = hashSeed(seed) || 1; + return () => { + value ^= value << 13; + value ^= value >>> 17; + value ^= value << 5; + return ((value >>> 0) % 10_000) / 10_000; + }; +} + +export function chooseProfile( + random: () => number, + scenario: ScenarioRow +): string { + const weighted = [ + ['cheap', Math.max(10, scenario.priceSensitivity)], + ['rushed', Math.max(10, scenario.rushBias)], + ['research', Math.max(10, scenario.researchBias)], + ['visual', Math.max(10, scenario.visualBias)], + ['memory', Math.max(10, scenario.memoryBias)], + ['premium', Math.max(10, scenario.premiumBias)], + ] as const; + const total = weighted.reduce((sum, row) => sum + row[1], 0); + let selection = random() * total; + for (const [profile, weight] of weighted) { + selection -= weight; + if (selection <= 0) return profile; + } + return 'cheap'; +} + +export function variantScore( + product: ProductRow, + variant: VariantRow, + scenario: ScenarioRow, + profile: string +): number { + let score = product.baseAppeal; + score += variant.featured ? 24 : 0; + score += Math.floor(variant.discountBps / 250); + + if (profile === 'cheap') + score += Math.max(0, 55 - Math.floor(variant.priceCents / 120)); + if (profile === 'rushed') score += variant.latency * 12; + if (profile === 'research') + score += Math.floor(variant.contextTokens / 6_000) + variant.reasoning * 7; + if (profile === 'visual' && product.category === 'multimodal') score += 44; + if (profile === 'memory' && product.category === 'memory') score += 44; + if (profile === 'premium') + score += variant.reasoning * 8 + variant.latency * 5; + + const pricePenalty = Math.floor( + (variant.priceCents * scenario.priceSensitivity) / 16_000 + ); + return Math.max(1, score - pricePenalty); +} + +export function purchaseProbability( + selected: { productRow: ProductRow; variantRow: VariantRow; score: number }, + scenario: ScenarioRow, + profile: string +): number { + let probability = 18 + Math.floor(selected.score / 4); + if (selected.variantRow.featured) probability += 8; + if (selected.variantRow.discountBps > 0) + probability += Math.floor(selected.variantRow.discountBps / 200); + if (profile === 'cheap') + probability -= Math.floor(selected.variantRow.priceCents / 250); + if (profile === 'premium') probability += 10; + probability -= Math.floor(scenario.volatility / 5); + return Math.max(5, Math.min(92, probability)); +} + +export function pricePaid(variant: VariantRow): number { + return Math.max( + 0, + Math.floor((variant.priceCents * (10_000 - variant.discountBps)) / 10_000) + ); +} + +export function nonSaleReason( + product: ProductRow, + variant: VariantRow, + scenario: ScenarioRow, + profile: string, + inStock: boolean, + shortage: string +): string { + if (!inStock) return `short_${shortage}`; + + const price = + Math.floor((variant.priceCents * scenario.priceSensitivity) / 16_000) + + (profile === 'cheap' ? Math.floor(variant.priceCents / 250) : 0); + const speedWeight = + profile === 'rushed' ? 8 : scenario.rushBias >= 55 ? 4 : 0; + const speed = (10 - variant.latency) * speedWeight; + + let preference = ''; + if (profile === 'visual' && product.category !== 'multimodal') + preference = 'want_vision'; + else if (profile === 'memory' && product.category !== 'memory') + preference = 'want_memory'; + else if (profile === 'research' && variant.reasoning < 6) + preference = 'want_smart'; + else if (profile === 'premium' && variant.reasoning < 6) + preference = 'want_premium'; + + const preferenceWeight = preference ? 30 : 0; + const dominant = Math.max(price, speed, preferenceWeight); + if (dominant < 8) return 'meh'; + if (dominant === preferenceWeight) return preference; + if (dominant === speed) return 'slow'; + return 'price'; +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/index.ts b/spacetime-posthog-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..9f3aa2b98f0 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1006 @@ +import { t } from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +import { + MAX_TICKS_PER_CALL, + MAX_ACTIVITY_ROWS, + MAX_SESSION_ROWS, + MAX_PURCHASE_ROWS, + spacetimedb, + type WriteCtx, + type VariantRow, + type ProductRow, + type ScenarioRow, + type MetricRow, +} from './schema'; +import { + MAX_MACHINE_LEVEL, + PATIENCE_TICKS, + REPUTATION_ON_RENEGE, + REPUTATION_ON_SALE, + REPUTATION_ON_STOCKOUT, + RUSH_CYCLE_TICKS, + RUSH_LENGTH_TICKS, + RUSH_MULTIPLIER, + START_CASH, + START_INVENTORY, + START_REPUTATION, + SUPPLY_PRICE, + arrivalDemand, + chooseProfile, + clampReputation, + ensureEconomy, + maximumQueueLength, + nonSaleReason, + pricePaid, + purchaseProbability, + seededRandom, + serviceCapacity, + storageCapacity, + supplyCost, + upgradeCost, + variantScore, + type SupplyKind, +} from './economy'; +import { clampU32, fail, requireId } from './validation'; +export { default } from './schema'; +export * from './catalog'; + +function keyFor(owner: string, id: string): string { + return `${owner}|${id}`; +} + +function findProduct(ctx: WriteCtx, owner: string, productId: string) { + return ctx.db.product.key.find(keyFor(owner, productId)); +} + +function findVariant(ctx: WriteCtx, owner: string, variantId: string) { + return ctx.db.variant.key.find(keyFor(owner, variantId)); +} + +function ensureConfig(ctx: WriteCtx, owner: string) { + const existing = ctx.db.simConfig.owner.find(owner); + if (existing) return existing; + const firstScenario = [...ctx.db.scenario.iter()][0]; + const scenarioId = firstScenario?.scenarioId ?? 'steady'; + const row = { + owner, + scenarioId, + tick: 0n, + experimentKey: 'context-cafe-offer', + experimentVariant: undefined, + updatedAt: ctx.timestamp, + }; + ctx.db.simConfig.insert(row); + return row; +} + +function ensureMetrics(ctx: WriteCtx, owner: string): MetricRow { + const existing = ctx.db.metrics.owner.find(owner); + if (existing) return existing; + const row = { + owner, + tick: 0n, + views: 0n, + carts: 0n, + checkouts: 0n, + purchases: 0n, + abandons: 0n, + revenueCents: 0n, + updatedAt: ctx.timestamp, + }; + ctx.db.metrics.insert(row); + return row; +} + +function addActivity( + ctx: WriteCtx, + owner: string, + tick: bigint, + kind: string, + message: string, + detail: { + profile?: string; + productId?: string; + variantId?: string; + amountCents?: number; + } = {} +): void { + ctx.db.activity.insert({ + activityId: 0n, + owner, + tick, + kind, + message, + profile: detail.profile, + productId: detail.productId, + variantId: detail.variantId, + amountCents: detail.amountCents, + createdAt: ctx.timestamp, + }); +} + +function enqueueCafeEvent( + ctx: WriteCtx, + distinctId: string, + event: string, + props: Record +): void { + posthog.enqueueEventInTx(ctx.as.posthog, { + distinctId, + event, + propertiesJson: JSON.stringify({ + source: 'context_cafe', + ...props, + }), + idempotencyKey: undefined, + }); +} + +function selectVariant( + ctx: WriteCtx, + owner: string, + rand: () => number, + scenarioRow: ScenarioRow, + profile: string +) { + const candidates = [...ctx.db.variant.byOwner.filter(owner)] + .filter((variantRow: VariantRow) => variantRow.active) + .map((variantRow: VariantRow) => { + const productRow = findProduct(ctx, owner, variantRow.productId); + if (!productRow || !productRow.active) return undefined; + return { + productRow, + variantRow, + score: variantScore(productRow, variantRow, scenarioRow, profile), + }; + }) + .filter(Boolean) as Array<{ + productRow: ProductRow; + variantRow: VariantRow; + score: number; + }>; + + if (candidates.length === 0) return undefined; + const total = candidates.reduce((sum, row) => sum + row.score, 0); + let pick = rand() * total; + for (const candidate of candidates) { + pick -= candidate.score; + if (pick <= 0) return candidate; + } + return candidates[0]; +} + +import { newestFirst } from './recent'; + +function trimRecent(ctx: WriteCtx, owner: string): void { + for (const row of newestFirst([...ctx.db.activity.owner.filter(owner)]).slice( + MAX_ACTIVITY_ROWS + )) + ctx.db.activity.delete(row); + for (const row of newestFirst([ + ...ctx.db.botSession.owner.filter(owner), + ]).slice(MAX_SESSION_ROWS)) + ctx.db.botSession.delete(row); + for (const row of newestFirst([...ctx.db.purchase.owner.filter(owner)]).slice( + MAX_PURCHASE_ROWS + )) + ctx.db.purchase.delete(row); +} + +// Seed this caller's per-session catalog + config/metrics. Idempotent. +export const initSession = spacetimedb.reducer({}, ctx => { + const owner = ctx.sender.toHexString(); + const alreadySeeded = [...ctx.db.product.byOwner.filter(owner)].length > 0; + if (!alreadySeeded) { + for (const tpl of [...ctx.db.productTemplate.iter()]) { + ctx.db.product.insert({ + key: keyFor(owner, tpl.productId), + owner, + productId: tpl.productId, + name: tpl.name, + category: tpl.category, + description: tpl.description, + baseAppeal: tpl.baseAppeal, + active: tpl.active, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + for (const tpl of [...ctx.db.variantTemplate.iter()]) { + ctx.db.variant.insert({ + key: keyFor(owner, tpl.variantId), + owner, + variantId: tpl.variantId, + productId: tpl.productId, + name: tpl.name, + flavor: tpl.flavor, + contextTokens: tpl.contextTokens, + reasoning: tpl.reasoning, + latency: tpl.latency, + priceCents: tpl.priceCents, + baselinePriceCents: tpl.priceCents, + discountBps: tpl.discountBps, + active: tpl.active, + featured: tpl.featured, + updatedAt: ctx.timestamp, + }); + } + } + ensureConfig(ctx, owner); + ensureMetrics(ctx, owner); + ensureEconomy(ctx, owner); +}); + +export const resetSimulation = spacetimedb.reducer( + { scenarioId: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const scenarioId = requireId(args.scenarioId, 'scenario_id'); + if (!ctx.db.scenario.scenarioId.find(scenarioId)) fail('unknown_scenario'); + for (const row of [...ctx.db.botSession.owner.filter(owner)]) + ctx.db.botSession.delete(row); + for (const row of [...ctx.db.purchase.owner.filter(owner)]) + ctx.db.purchase.delete(row); + for (const row of [...ctx.db.activity.owner.filter(owner)]) + ctx.db.activity.delete(row); + for (const row of [...ctx.db.waitingBot.owner.filter(owner)]) + ctx.db.waitingBot.delete(row); + const config = ensureConfig(ctx, owner); + ctx.db.simConfig.owner.update({ + ...config, + scenarioId, + tick: 0n, + updatedAt: ctx.timestamp, + }); + const metric = ensureMetrics(ctx, owner); + ctx.db.metrics.owner.update({ + ...metric, + tick: 0n, + views: 0n, + carts: 0n, + checkouts: 0n, + purchases: 0n, + abandons: 0n, + revenueCents: 0n, + updatedAt: ctx.timestamp, + }); + const money = ensureEconomy(ctx, owner); + ctx.db.econ.owner.update({ + ...money, + cashCents: START_CASH, + computeUnits: START_INVENTORY.compute, + contextUnits: START_INVENTORY.context, + memoryUnits: START_INVENTORY.memory, + suppliesSpentCents: 0n, + stockouts: 0, + reputation: START_REPUTATION, + workers: 1, + machineLevel: 0, + seats: 0, + storageLevel: 0, + reneged: 0, + updatedAt: ctx.timestamp, + }); + addActivity(ctx, owner, 0n, 'reset', 'Simulation reset.'); + } +); + +export const selectScenario = spacetimedb.reducer( + { scenarioId: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const scenarioId = requireId(args.scenarioId, 'scenario_id'); + const scenarioRow = ctx.db.scenario.scenarioId.find(scenarioId); + if (!scenarioRow) fail('unknown_scenario'); + const config = ensureConfig(ctx, owner); + ctx.db.simConfig.owner.update({ + ...config, + scenarioId, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + config.tick, + 'scenario_selected', + `Scenario set to ${scenarioRow.name}.` + ); + } +); + +export const setProductActive = spacetimedb.reducer( + { productId: t.string(), active: t.bool() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const productId = requireId(args.productId, 'product_id'); + const row = findProduct(ctx, owner, productId); + if (!row) fail('unknown_product'); + ctx.db.product.key.update({ + ...row, + active: args.active, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + args.active ? 'product_enabled' : 'product_disabled', + `${row.name} ${args.active ? 'enabled' : 'disabled'}.`, + { productId } + ); + enqueueCafeEvent( + ctx, + owner, + args.active ? 'product_enabled' : 'product_disabled', + { product_id: productId, product_name: row.name } + ); + } +); + +export const setVariantActive = spacetimedb.reducer( + { variantId: t.string(), active: t.bool() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const variantId = requireId(args.variantId, 'variant_id'); + const row = findVariant(ctx, owner, variantId); + if (!row) fail('unknown_variant'); + ctx.db.variant.key.update({ + ...row, + active: args.active, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + args.active ? 'variant_enabled' : 'variant_disabled', + `${row.name} ${args.active ? 'enabled' : 'disabled'}.`, + { productId: row.productId, variantId } + ); + } +); + +export const setVariantPrice = spacetimedb.reducer( + { variantId: t.string(), priceCents: t.u32() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const row = findVariant( + ctx, + owner, + requireId(args.variantId, 'variant_id') + ); + if (!row) fail('unknown_variant'); + const priceCents = clampU32(args.priceCents, 'price_cents', 0, 250_000); + ctx.db.variant.key.update({ ...row, priceCents, updatedAt: ctx.timestamp }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'price_changed', + `${row.name} price changed to $${(priceCents / 100).toFixed(2)}.`, + { + productId: row.productId, + variantId: row.variantId, + amountCents: priceCents, + } + ); + enqueueCafeEvent(ctx, owner, 'price_changed', { + variant_id: row.variantId, + product_id: row.productId, + price_cents: priceCents, + old_price_cents: row.priceCents, + }); + } +); + +export const setVariantDiscount = spacetimedb.reducer( + { variantId: t.string(), discountBps: t.u32() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const row = findVariant( + ctx, + owner, + requireId(args.variantId, 'variant_id') + ); + if (!row) fail('unknown_variant'); + const discountBps = clampU32(args.discountBps, 'discount_bps', 0, 9000); + ctx.db.variant.key.update({ + ...row, + discountBps, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'discount_changed', + `${row.name} discount set to ${discountBps / 100}%.`, + { productId: row.productId, variantId: row.variantId } + ); + enqueueCafeEvent(ctx, owner, 'discount_changed', { + variant_id: row.variantId, + product_id: row.productId, + discount_bps: discountBps, + }); + } +); + +export const setFeaturedVariant = spacetimedb.reducer( + { variantId: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const variantId = requireId(args.variantId, 'variant_id'); + const selected = findVariant(ctx, owner, variantId); + if (!selected) fail('unknown_variant'); + for (const row of [...ctx.db.variant.byOwner.filter(owner)]) { + ctx.db.variant.key.update({ + ...row, + featured: row.variantId === variantId, + updatedAt: ctx.timestamp, + }); + } + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'featured_variant_changed', + `${selected.name} is now the featured recipe.`, + { productId: selected.productId, variantId } + ); + enqueueCafeEvent(ctx, owner, 'featured_variant_changed', { + variant_id: variantId, + product_id: selected.productId, + }); + } +); + +export const setExperimentVariant = spacetimedb.reducer( + { key: t.string(), variant: t.option(t.string()) }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const config = ensureConfig(ctx, owner); + ctx.db.simConfig.owner.update({ + ...config, + experimentKey: args.key.trim() || config.experimentKey, + experimentVariant: args.variant, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + config.tick, + 'experiment_variant_checked', + `Experiment ${args.key || config.experimentKey}: ${args.variant ?? 'control'}.` + ); + } +); + +export const buySupply = spacetimedb.reducer( + { kind: t.string(), units: t.u32() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const kind = requireId(args.kind, 'supply_kind'); + if (kind !== 'compute' && kind !== 'context' && kind !== 'memory') + fail('invalid_supply_kind'); + const money = ensureEconomy(ctx, owner); + const field = + kind === 'compute' + ? 'computeUnits' + : kind === 'context' + ? 'contextUnits' + : 'memoryUnits'; + // Cap the purchase at the storeroom's available capacity. + const headroom = + storageCapacity(kind as SupplyKind, money.storageLevel) - money[field]; + if (headroom <= 0) fail('storage_full'); + const units = Math.min(clampU32(args.units, 'units', 1, 1000), headroom); + const cost = BigInt(units * SUPPLY_PRICE[kind as SupplyKind]); + if (money.cashCents < cost) fail('insufficient_cash'); + ctx.db.econ.owner.update({ + ...money, + cashCents: money.cashCents - cost, + [field]: money[field] + units, + suppliesSpentCents: money.suppliesSpentCents + cost, + updatedAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'supply_purchased', + `Bought ${units} ${kind} ($${(Number(cost) / 100).toFixed(2)}).`, + { amountCents: Number(cost) } + ); + enqueueCafeEvent(ctx, owner, 'supply_purchased', { + kind, + units, + cost_cents: Number(cost), + }); + } +); + +export const buyUpgrade = spacetimedb.reducer( + { kind: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const kind = requireId(args.kind, 'upgrade_kind'); + if ( + kind !== 'worker' && + kind !== 'machine' && + kind !== 'counter' && + kind !== 'storage' + ) + fail('invalid_upgrade_kind'); + const money = ensureEconomy(ctx, owner); + if (kind === 'machine' && money.machineLevel >= MAX_MACHINE_LEVEL) + fail('machine_maxed'); + const cost = upgradeCost(kind, money); + if (money.cashCents < cost) fail('insufficient_cash'); + const next = { + ...money, + cashCents: money.cashCents - cost, + workers: money.workers + (kind === 'worker' ? 1 : 0), + machineLevel: money.machineLevel + (kind === 'machine' ? 1 : 0), + seats: money.seats + (kind === 'counter' ? 1 : 0), + storageLevel: money.storageLevel + (kind === 'storage' ? 1 : 0), + updatedAt: ctx.timestamp, + }; + ctx.db.econ.owner.update(next); + const label = + kind === 'worker' + ? `Hired a worker (now ${next.workers} serving/tick)` + : kind === 'machine' + ? `Upgraded machines (level ${next.machineLevel})` + : kind === 'storage' + ? `Expanded storeroom (holds ${storageCapacity('context', next.storageLevel)} context)` + : `Added counter space (holds ${maximumQueueLength(next)})`; + addActivity( + ctx, + owner, + ensureConfig(ctx, owner).tick, + 'upgrade_purchased', + `${label}: $${(Number(cost) / 100).toFixed(2)}.`, + { amountCents: Number(cost) } + ); + enqueueCafeEvent(ctx, owner, 'upgrade_purchased', { + kind, + cost_cents: Number(cost), + workers: next.workers, + machine_level: next.machineLevel, + seats: next.seats, + storage_level: next.storageLevel, + }); + } +); + +export const simulateTick = spacetimedb.reducer( + { ticks: t.u32(), seed: t.string() }, + (ctx, args) => { + const owner = ctx.sender.toHexString(); + const tickCount = clampU32(args.ticks, 'ticks', 1, MAX_TICKS_PER_CALL); + const config = ensureConfig(ctx, owner); + const scenarioRow = ctx.db.scenario.scenarioId.find(config.scenarioId); + if (!scenarioRow) fail('unknown_scenario'); + let metric = ensureMetrics(ctx, owner); + const money = { ...ensureEconomy(ctx, owner) }; + let finalTick = config.tick; + + const WANTS: Record = { + cheap: 'a deal', + rushed: 'speed', + research: 'long context', + visual: 'vision', + memory: 'memory', + premium: 'top quality', + }; + + for (let i = 0; i < tickCount; i++) { + finalTick += 1n; + const rand = seededRandom( + `${args.seed}:${finalTick.toString()}:${config.scenarioId}` + ); + const baseTraffic = scenarioRow.trafficPerTick; + let tickViews = 0n; + let tickCarts = 0n; + let tickCheckouts = 0n; + let tickPurchases = 0n; + let tickAbandons = 0n; + let tickRevenue = 0n; + + // Serve up to capacity bots from the front; each leaves the queue either way. + const queue = [...ctx.db.waitingBot.owner.filter(owner)].sort((a, b) => + a.queueId < b.queueId ? -1 : a.queueId > b.queueId ? 1 : 0 + ); + const capacity = serviceCapacity(money); + let served = 0; + for (const front of queue.slice(0, capacity)) { + served++; + ctx.db.waitingBot.delete(front); + const variantRow = findVariant(ctx, owner, front.variantId); + const productRow = variantRow + ? findProduct(ctx, owner, variantRow.productId) + : undefined; + + if ( + !variantRow || + !variantRow.active || + !productRow || + !productRow.active + ) { + // The selected item is unavailable when the customer reaches the counter. + tickAbandons++; + ctx.db.botSession.insert({ + sessionId: 0n, + owner, + botId: front.botId, + tick: finalTick, + profile: front.profile, + scenarioId: scenarioRow.scenarioId, + productId: front.productId, + variantId: front.variantId, + stage: 'abandoned', + revenueCents: 0, + reason: 'unavailable', + createdAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + finalTick, + 'checkout_abandoned', + `${front.profile} bot left because its selection was unavailable.`, + { + profile: front.profile, + productId: front.productId, + variantId: front.variantId, + } + ); + enqueueCafeEvent(ctx, front.botId, 'checkout_abandoned', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: front.productId, + variant_id: front.variantId, + price_cents: 0, + reason: 'unavailable', + }); + } else { + const selected = { + productRow, + variantRow, + score: variantScore( + productRow, + variantRow, + scenarioRow, + front.profile + ), + }; + const paid = pricePaid(variantRow); + const cartChance = Math.min(94, 35 + Math.floor(selected.score / 3)); + const didCart = rand() * 100 < cartChance; + const didCheckout = didCart && rand() * 100 < 82; + const wouldBuy = + didCheckout && + rand() * 100 < + purchaseProbability(selected, scenarioRow, front.profile); + // Complete a sale only when the required supplies are available. + const cost = supplyCost(productRow, variantRow, money.machineLevel); + const inStock = + money.computeUnits >= cost.compute && + money.contextUnits >= cost.context && + money.memoryUnits >= cost.memory; + const didPurchase = wouldBuy && inStock; + const stockedOut = wouldBuy && !inStock; + let stage = 'viewed'; + if (didCart) { + tickCarts++; + stage = 'cart'; + enqueueCafeEvent(ctx, front.botId, 'product_added_to_cart', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + price_cents: variantRow.priceCents, + discounted_price_cents: paid, + }); + } + if (didCheckout) { + tickCheckouts++; + stage = 'checkout'; + enqueueCafeEvent(ctx, front.botId, 'checkout_started', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + price_cents: paid, + }); + } + if (didPurchase) { + tickPurchases++; + tickRevenue += BigInt(paid); + stage = 'purchased'; + // Consume supplies + bank the cash + a happy customer lifts reputation. + money.computeUnits -= cost.compute; + money.contextUnits -= cost.context; + money.memoryUnits -= cost.memory; + money.cashCents += BigInt(paid); + money.reputation = clampReputation( + money.reputation + REPUTATION_ON_SALE + ); + } else if (didCheckout || didCart) { + tickAbandons++; + stage = 'abandoned'; + } + if (stockedOut) { + money.stockouts += 1; + money.reputation = clampReputation( + money.reputation - REPUTATION_ON_STOCKOUT + ); + } + + // Show the purchase decision at the counter. + const short = + money.contextUnits < cost.context + ? 'context' + : money.computeUnits < cost.compute + ? 'compute' + : 'memory'; + const reason = didPurchase + ? '' + : nonSaleReason( + productRow, + variantRow, + scenarioRow, + front.profile, + inStock, + short + ); + + ctx.db.botSession.insert({ + sessionId: 0n, + owner, + botId: front.botId, + tick: finalTick, + profile: front.profile, + scenarioId: scenarioRow.scenarioId, + productId: productRow.productId, + variantId: variantRow.variantId, + stage, + revenueCents: didPurchase ? paid : 0, + reason, + createdAt: ctx.timestamp, + }); + + if (didPurchase) { + ctx.db.purchase.insert({ + purchaseId: 0n, + owner, + sessionId: 0n, + tick: finalTick, + botId: front.botId, + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + pricePaidCents: paid, + createdAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + finalTick, + 'purchase_completed', + `${front.profile} bot bought ${variantRow.name}.`, + { + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + amountCents: paid, + } + ); + enqueueCafeEvent(ctx, front.botId, 'purchase_completed', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + product_name: productRow.name, + variant_id: variantRow.variantId, + variant_name: variantRow.name, + price_cents: paid, + discount_bps: variantRow.discountBps, + }); + } else if (stockedOut) { + addActivity( + ctx, + owner, + finalTick, + 'stockout', + `${front.profile} bot left because ${short} was out of stock.`, + { + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + } + ); + enqueueCafeEvent(ctx, front.botId, 'stockout', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + short_supply: short, + price_cents: paid, + }); + } else if (stage === 'abandoned') { + addActivity( + ctx, + owner, + finalTick, + 'checkout_abandoned', + `${front.profile} bot bailed on ${variantRow.name}.`, + { + profile: front.profile, + productId: productRow.productId, + variantId: variantRow.variantId, + } + ); + enqueueCafeEvent(ctx, front.botId, 'checkout_abandoned', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: front.profile, + product_id: productRow.productId, + variant_id: variantRow.variantId, + price_cents: paid, + reason, + }); + } + } + } + + // Customers who exceed their patience limit leave and reduce reputation. + for (const waiting of [...ctx.db.waitingBot.owner.filter(owner)]) { + if (finalTick - waiting.arrivedTick <= BigInt(PATIENCE_TICKS)) continue; + ctx.db.waitingBot.delete(waiting); + money.reneged += 1; + money.reputation = clampReputation( + money.reputation - REPUTATION_ON_RENEGE + ); + tickAbandons++; + ctx.db.botSession.insert({ + sessionId: 0n, + owner, + botId: waiting.botId, + tick: finalTick, + profile: waiting.profile, + scenarioId: scenarioRow.scenarioId, + productId: waiting.productId, + variantId: waiting.variantId, + stage: 'abandoned', + revenueCents: 0, + reason: 'waited', + createdAt: ctx.timestamp, + }); + addActivity( + ctx, + owner, + finalTick, + 'reneged', + `${waiting.profile} bot gave up waiting.`, + { + profile: waiting.profile, + productId: waiting.productId, + variantId: waiting.variantId, + } + ); + enqueueCafeEvent(ctx, waiting.botId, 'reneged', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: waiting.profile, + waited_ticks: Number(finalTick - waiting.arrivedTick), + reason: 'waited', + }); + } + + // New arrivals scale with reputation (and surge during a rush), but the counter only holds so many. + const rushing = + finalTick % BigInt(RUSH_CYCLE_TICKS) < BigInt(RUSH_LENGTH_TICKS); + const effTraffic = rushing ? baseTraffic * RUSH_MULTIPLIER : baseTraffic; + const lineCap = maximumQueueLength(money); + const demand = Math.min( + arrivalDemand(effTraffic, money.reputation), + lineCap + ); + let admit = 0; + while ( + [...ctx.db.waitingBot.owner.filter(owner)].length < demand && + admit < lineCap + ) { + admit++; + const profile = chooseProfile(rand, scenarioRow); + const botId = `bot-${finalTick.toString()}-${admit}`; + const selected = selectVariant(ctx, owner, rand, scenarioRow, profile); + if (!selected) { + addActivity( + ctx, + owner, + finalTick, + 'no_inventory', + 'A bot found no active recipes.' + ); + break; + } + const { productRow, variantRow } = selected; + tickViews++; + enqueueCafeEvent(ctx, botId, 'product_viewed', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + bot_profile: profile, + product_id: productRow.productId, + product_name: productRow.name, + variant_id: variantRow.variantId, + variant_name: variantRow.name, + price_cents: variantRow.priceCents, + discount_bps: variantRow.discountBps, + featured: variantRow.featured, + context_tokens: variantRow.contextTokens, + reasoning: variantRow.reasoning, + latency: variantRow.latency, + }); + ctx.db.waitingBot.insert({ + queueId: 0n, + owner, + botId, + profile, + scenarioId: scenarioRow.scenarioId, + productId: productRow.productId, + variantId: variantRow.variantId, + wants: WANTS[profile] ?? 'a good drink', + thrifty: profile === 'cheap' || scenarioRow.priceSensitivity >= 60, + arrivedTick: finalTick, + createdAt: ctx.timestamp, + }); + } + + metric = { + ...metric, + tick: finalTick, + views: metric.views + tickViews, + carts: metric.carts + tickCarts, + checkouts: metric.checkouts + tickCheckouts, + purchases: metric.purchases + tickPurchases, + abandons: metric.abandons + tickAbandons, + revenueCents: metric.revenueCents + tickRevenue, + updatedAt: ctx.timestamp, + }; + ctx.db.metrics.owner.update(metric); + ctx.db.econ.owner.update({ ...money, updatedAt: ctx.timestamp }); + enqueueCafeEvent(ctx, `sim:${owner}`, 'serve_tick_summary', { + tick: finalTick.toString(), + scenario_id: scenarioRow.scenarioId, + served, + capacity, + rush: rushing, + reputation: money.reputation, + queue_length: [...ctx.db.waitingBot.owner.filter(owner)].length, + purchases: tickPurchases.toString(), + abandons: tickAbandons.toString(), + revenue_cents: tickRevenue.toString(), + }); + } + + ctx.db.simConfig.owner.update({ + ...config, + tick: finalTick, + updatedAt: ctx.timestamp, + }); + trimRecent(ctx, owner); + } +); + +export * from './views'; + +export const init = spacetimedb.init(ctx => { + posthog.installPostHog(ctx.as.posthog); +}); diff --git a/spacetime-posthog-ts/example/spacetimedb/src/recent.ts b/spacetime-posthog-ts/example/spacetimedb/src/recent.ts new file mode 100644 index 00000000000..6d2cef4a94a --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/recent.ts @@ -0,0 +1,9 @@ +export function newestFirst< + T extends { createdAt: { microsSinceUnixEpoch: bigint } }, +>(rows: T[]): T[] { + return rows.sort((a, b) => { + const av = a.createdAt.microsSinceUnixEpoch as bigint; + const bv = b.createdAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/schema.ts b/spacetime-posthog-ts/example/spacetimedb/src/schema.ts new file mode 100644 index 00000000000..23c88eb6384 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/schema.ts @@ -0,0 +1,331 @@ +import { + schema, + table, + t, + type Infer, + type InferSchema, + type ReducerCtx, +} from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +export const MAX_SYNC_ROWS = 100; +export const MAX_TICKS_PER_CALL = 25; +export const MAX_ACTIVITY_ROWS = 120; +export const MAX_SESSION_ROWS = 250; +export const MAX_PURCHASE_ROWS = 120; + +// Template catalog. init_session copies these into per-session rows. +export const productTemplate = table( + { name: 'product_template', public: false }, + { + productId: t.string().primaryKey(), + name: t.string(), + category: t.string(), + description: t.string(), + baseAppeal: t.u32(), + active: t.bool(), + } +); + +export const variantTemplate = table( + { name: 'variant_template', public: false }, + { + variantId: t.string().primaryKey(), + productId: t.string().index(), + name: t.string(), + flavor: t.string(), + contextTokens: t.u32(), + reasoning: t.u32(), + latency: t.u32(), + priceCents: t.u32(), + discountBps: t.u32(), + active: t.bool(), + featured: t.bool(), + } +); + +// Per-session catalog; key = `${owner}|${id}` so ids can repeat across sessions. +export const product = table( + { + name: 'product', + public: false, + indexes: [{ accessor: 'byOwner', algorithm: 'btree', columns: ['owner'] }], + }, + { + key: t.string().primaryKey(), + owner: t.string(), + productId: t.string().index(), + name: t.string(), + category: t.string(), + description: t.string(), + baseAppeal: t.u32(), + active: t.bool(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +export const variant = table( + { + name: 'variant', + public: false, + indexes: [{ accessor: 'byOwner', algorithm: 'btree', columns: ['owner'] }], + }, + { + key: t.string().primaryKey(), + owner: t.string(), + variantId: t.string().index(), + productId: t.string().index(), + name: t.string(), + flavor: t.string(), + contextTokens: t.u32(), + reasoning: t.u32(), + latency: t.u32(), + priceCents: t.u32(), + baselinePriceCents: t.u32(), + discountBps: t.u32(), + active: t.bool(), + featured: t.bool(), + updatedAt: t.timestamp(), + } +); + +export const scenario = table( + { name: 'scenario', public: false }, + { + scenarioId: t.string().primaryKey(), + name: t.string().index(), + description: t.string(), + trafficPerTick: t.u32(), + priceSensitivity: t.u32(), + rushBias: t.u32(), + researchBias: t.u32(), + visualBias: t.u32(), + memoryBias: t.u32(), + premiumBias: t.u32(), + volatility: t.u32(), + } +); + +export const simConfig = table( + { name: 'sim_config', public: false }, + { + owner: t.string().primaryKey(), + scenarioId: t.string(), + tick: t.u64(), + experimentKey: t.string(), + experimentVariant: t.option(t.string()), + updatedAt: t.timestamp(), + } +); + +export const metrics = table( + { name: 'metrics', public: false }, + { + owner: t.string().primaryKey(), + tick: t.u64(), + views: t.u64(), + carts: t.u64(), + checkouts: t.u64(), + purchases: t.u64(), + abandons: t.u64(), + revenueCents: t.u64(), + updatedAt: t.timestamp(), + } +); + +// Per-session cash, supply inventory, reputation, and capacity upgrades. +export const econ = table( + { name: 'econ', public: false }, + { + owner: t.string().primaryKey(), + cashCents: t.u64(), + computeUnits: t.u32(), + contextUnits: t.u32(), + memoryUnits: t.u32(), + suppliesSpentCents: t.u64(), + stockouts: t.u32(), + reputation: t.u32(), + workers: t.u32(), + machineLevel: t.u32(), + seats: t.u32(), + storageLevel: t.u32(), + reneged: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const botSession = table( + { + name: 'bot_session', + public: false, + indexes: [ + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + sessionId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + botId: t.string().index(), + tick: t.u64().index(), + profile: t.string().index(), + scenarioId: t.string().index(), + productId: t.option(t.string()), + variantId: t.option(t.string()), + stage: t.string().index(), + revenueCents: t.u32(), + reason: t.string(), + createdAt: t.timestamp(), + } +); + +export const purchase = table( + { + name: 'purchase', + public: false, + indexes: [ + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + purchaseId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + sessionId: t.u64().index(), + tick: t.u64().index(), + botId: t.string().index(), + profile: t.string().index(), + productId: t.string().index(), + variantId: t.string().index(), + pricePaidCents: t.u32(), + createdAt: t.timestamp(), + } +); + +export const activity = table( + { + name: 'activity', + public: false, + indexes: [ + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + ], + }, + { + activityId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + tick: t.u64().index(), + kind: t.string().index(), + message: t.string(), + profile: t.option(t.string()), + productId: t.option(t.string()), + variantId: t.option(t.string()), + amountCents: t.option(t.u32()), + createdAt: t.timestamp(), + } +); + +// Waiting queue; lowest queueId is served first. +export const waitingBot = table( + { + name: 'waiting_bot', + public: false, + }, + { + queueId: t.u64().primaryKey().autoInc(), + owner: t.string().index(), + botId: t.string(), + profile: t.string().index(), + scenarioId: t.string().index(), + productId: t.string(), + variantId: t.string(), + wants: t.string(), + thrifty: t.bool(), + arrivedTick: t.u64(), + createdAt: t.timestamp(), + } +); + +export const cafeDeliveryLogViewRow = posthog.t.object( + 'ContextCafeDeliveryLogRow', + { + deliveryId: posthog.t.string(), + source: posthog.t.string(), + distinctId: posthog.t.string(), + event: posthog.t.string(), + ok: posthog.t.bool(), + statusCode: posthog.t.u16(), + responseBody: posthog.t.string(), + attemptedAt: posthog.t.timestamp(), + } +); + +export const cafeAnalyticsSummaryRow = posthog.t.object( + 'ContextCafeAnalyticsSummaryRow', + { + queued: posthog.t.u64(), + delivered: posthog.t.u64(), + failed: posthog.t.u64(), + } +); + +export const spacetimedb = schema({ + posthog, + productTemplate, + variantTemplate, + product, + variant, + scenario, + simConfig, + metrics, + econ, + botSession, + purchase, + activity, + waitingBot, +}); + +export type ProductInput = { + productId: string; + name: string; + category: string; + description: string; + baseAppeal: number; + active?: boolean; +}; + +export type VariantInput = { + variantId: string; + productId: string; + name: string; + flavor: string; + contextTokens: number; + reasoning: number; + latency: number; + priceCents: number; + discountBps?: number; + active?: boolean; + featured?: boolean; +}; + +export type ScenarioInput = { + scenarioId: string; + name: string; + description: string; + trafficPerTick: number; + priceSensitivity: number; + rushBias: number; + researchBias: number; + visualBias: number; + memoryBias: number; + premiumBias: number; + volatility: number; +}; + +export type Schema = InferSchema; +export type WriteCtx = ReducerCtx; +export type VariantRow = Infer; +export type ProductRow = Infer; +export type ScenarioRow = Infer; +export type MetricRow = Infer; +export type EconRow = Infer; + +export default spacetimedb; diff --git a/spacetime-posthog-ts/example/spacetimedb/src/validation.ts b/spacetime-posthog-ts/example/spacetimedb/src/validation.ts new file mode 100644 index 00000000000..11463dab319 --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/validation.ts @@ -0,0 +1,26 @@ +import { SenderError } from 'spacetimedb/server'; + +export function fail(message: string): never { + throw new SenderError(`context_cafe.${message}`); +} + +export function requireId(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + fail(`invalid_${field}`); + } + return value.trim(); +} + +export function clampU32( + value: unknown, + field: string, + min: number, + max: number +): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + fail(`invalid_${field}`); + } + const result = Math.round(value); + if (result < min || result > max) fail(`invalid_${field}`); + return result; +} diff --git a/spacetime-posthog-ts/example/spacetimedb/src/views.ts b/spacetime-posthog-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..c0cde95682c --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,169 @@ +import { Range, t } from 'spacetimedb/server'; +import * as posthog from '@spacetimedb/posthog/submodule'; + +import { + product, + variant, + scenario, + simConfig, + metrics, + econ, + botSession, + purchase, + activity, + waitingBot, + cafeDeliveryLogViewRow, + cafeAnalyticsSummaryRow, + spacetimedb, +} from './schema'; +export { default } from './schema'; + +import { newestFirst } from './recent'; + +export const flushAnalytics = spacetimedb.procedure( + { limit: t.u32() }, + t.string(), + (ctx, args) => + JSON.stringify(posthog.deliverOutbox(ctx.as.posthog, { limit: args.limit })) +); + +export const cafeProducts = spacetimedb.view( + { name: 'cafe_products', public: true }, + t.array(product.rowType), + ctx => [...ctx.db.product.byOwner.filter(ctx.sender.toHexString())] +); + +export const cafeVariants = spacetimedb.view( + { name: 'cafe_variants', public: true }, + t.array(variant.rowType), + ctx => [...ctx.db.variant.byOwner.filter(ctx.sender.toHexString())] +); + +export const cafeScenarios = spacetimedb.view( + { name: 'cafe_scenarios', public: true }, + t.array(scenario.rowType), + ctx => [...ctx.db.scenario.iter()] +); + +export const cafeConfig = spacetimedb.view( + { name: 'cafe_config', public: true }, + t.array(simConfig.rowType), + ctx => { + const row = ctx.db.simConfig.owner.find(ctx.sender.toHexString()); + return row ? [row] : []; + } +); + +export const cafeMetrics = spacetimedb.view( + { name: 'cafe_metrics', public: true }, + t.array(metrics.rowType), + ctx => { + const row = ctx.db.metrics.owner.find(ctx.sender.toHexString()); + return row ? [row] : []; + } +); + +export const cafeEcon = spacetimedb.view( + { name: 'cafe_econ', public: true }, + t.array(econ.rowType), + ctx => { + const row = ctx.db.econ.owner.find(ctx.sender.toHexString()); + return row ? [row] : []; + } +); + +export const cafeQueue = spacetimedb.view( + { name: 'cafe_queue', public: true }, + t.array(waitingBot.rowType), + ctx => { + const rows = [...ctx.db.waitingBot.owner.filter(ctx.sender.toHexString())]; + rows.sort((a, b) => + a.queueId < b.queueId ? -1 : a.queueId > b.queueId ? 1 : 0 + ); + return rows; + } +); + +export const cafeRecentSessions = spacetimedb.view( + { name: 'cafe_recent_sessions', public: true }, + t.array(botSession.rowType), + ctx => + newestFirst([ + ...ctx.db.botSession.owner.filter(ctx.sender.toHexString()), + ]).slice(0, 80) +); + +export const cafeRecentPurchases = spacetimedb.view( + { name: 'cafe_recent_purchases', public: true }, + t.array(purchase.rowType), + ctx => + newestFirst([ + ...ctx.db.purchase.owner.filter(ctx.sender.toHexString()), + ]).slice(0, 50) +); + +export const cafeRecentActivity = spacetimedb.view( + { name: 'cafe_recent_activity', public: true }, + t.array(activity.rowType), + ctx => + newestFirst([ + ...ctx.db.activity.owner.filter(ctx.sender.toHexString()), + ]).slice(0, 80) +); + +export const posthogOutboxAdmin = spacetimedb.view( + { name: 'posthog_outbox_admin', public: true }, + posthog.t.array(posthog.posthogOutbox.rowType), + ctx => { + const admin = ctx.db.posthog.posthogAdminIdentity.identity.find(ctx.sender); + return admin + ? [ + ...ctx.db.posthog.posthogOutbox.byStatus.filter( + posthog.OutboxStatus.Queued + ), + ] + : []; + } +); + +export const posthogDeliveryLogAdmin = spacetimedb.view( + { name: 'posthog_delivery_log_admin', public: true }, + posthog.t.array(cafeDeliveryLogViewRow), + ctx => { + const admin = ctx.db.posthog.posthogAdminIdentity.identity.find(ctx.sender); + if (!admin) return []; + const rows = [ + ...ctx.db.posthog.posthogDeliveryLog.byAttemptedAt.filter(new Range()), + ]; + rows.sort((a, b) => { + const av = a.attemptedAt.microsSinceUnixEpoch; + const bv = b.attemptedAt.microsSinceUnixEpoch; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + return rows.slice(0, 50).map(row => ({ + deliveryId: row.deliveryId.toString(), + source: row.source.tag, + distinctId: row.distinctId, + event: row.event, + ok: row.ok, + statusCode: row.statusCode, + responseBody: row.responseBody, + attemptedAt: row.attemptedAt, + })); + } +); + +export const cafeAnalyticsSummary = spacetimedb.anonymousView( + { name: 'cafe_analytics_summary', public: true }, + posthog.t.array(cafeAnalyticsSummaryRow), + ctx => { + const stats = ctx.db.posthog.posthogDeliveryStats.singleton.find(true); + return [ + { + queued: stats?.pending ?? 0n, + delivered: stats?.delivered ?? 0n, + failed: stats?.failed ?? 0n, + }, + ]; + } +); diff --git a/spacetime-posthog-ts/example/spacetimedb/tsconfig.json b/spacetime-posthog-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..4b599551afe --- /dev/null +++ b/spacetime-posthog-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +} diff --git a/spacetime-posthog-ts/example/src/app.ts b/spacetime-posthog-ts/example/src/app.ts new file mode 100644 index 00000000000..a30300ec0e3 --- /dev/null +++ b/spacetime-posthog-ts/example/src/app.ts @@ -0,0 +1,1009 @@ +import { + DbConnection, + tables, + type ErrorContext, + type EventContext, +} from './module_bindings'; +import { + MAX_MACHINE_LEVEL, + RUSH_CYCLE_TICKS, + RUSH_LENGTH_TICKS, + SUPPLY_PRICE, + maximumQueueLength, + storageCapacity, + supplyCost, + upgradeCost, +} from '../spacetimedb/src/economy'; + +interface ServerConfig { + spacetimeUri: string; + databaseName: string; + posthogAppUrl?: string | null; +} + +type TableEvents = { + iter(): Iterable; + onInsert(cb: (ctx: EventContext, row: T) => void): void; + onUpdate(cb: (ctx: EventContext, old: T, row: T) => void): void; + onDelete(cb: (ctx: EventContext, row: T) => void): void; +}; + +type ProductRow = { + productId: string; + name: string; + category: string; + description: string; + baseAppeal: number; + active: boolean; +}; + +type VariantRow = { + variantId: string; + productId: string; + name: string; + flavor: string; + contextTokens: number; + reasoning: number; + latency: number; + priceCents: number; + baselinePriceCents: number; + discountBps: number; + active: boolean; + featured: boolean; +}; + +type ScenarioRow = { + scenarioId: string; + name: string; + description: string; + trafficPerTick: number; +}; + +type ConfigRow = { + scenarioId: string; + tick: bigint; + experimentKey: string; + experimentVariant?: string; +}; + +type MetricsRow = { + tick: bigint; + views: bigint; + carts: bigint; + checkouts: bigint; + purchases: bigint; + abandons: bigint; + revenueCents: bigint; +}; + +type EconRow = { + cashCents: bigint; + computeUnits: number; + contextUnits: number; + memoryUnits: number; + suppliesSpentCents: bigint; + stockouts: number; + reputation: number; + workers: number; + machineLevel: number; + seats: number; + storageLevel: number; + reneged: number; +}; + +const BUY_UNITS = 50; + +type SessionRow = { + sessionId: bigint; + botId: string; + tick: bigint; + profile: string; + variantId?: string; + stage: string; + revenueCents: number; + reason: string; +}; + +type AnalyticsSummaryRow = { + queued: bigint; + delivered: bigint; + failed: bigint; +}; + +let conn: DbConnection | null = null; +let simTimer: ReturnType | null = null; +let running = false; +let selectedVariantId = ''; +let drawerOpen = false; +let speedMs = 900; + +let toastTimer: ReturnType | null = null; + +// Track served bots that appeared on the counter and animate each outcome once. +const seenSessionIds = new Set(); +let counterReady = false; + +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`missing #${id}`); + return el; +} + +function input(id: string): HTMLInputElement { + return $(id) as HTMLInputElement; +} + +function select(id: string): HTMLSelectElement { + return $(id) as HTMLSelectElement; +} + +function setText(id: string, value: string): void { + $(id).textContent = value; +} + +// Ephemeral status line: slides in on a message, auto-dismisses success +// messages and holds errors until the next action clears them. +function showToast(message: string, kind: 'ok' | 'error' = 'ok'): void { + const el = $('toast'); + el.textContent = message; + el.className = `toast ${kind} show`; + if (toastTimer) { + clearTimeout(toastTimer); + toastTimer = null; + } + if (kind === 'ok') { + toastTimer = setTimeout(() => el.classList.remove('show'), 3000); + } +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +function money(cents: number | bigint): string { + const n = typeof cents === 'bigint' ? Number(cents) : cents; + return `$${(n / 100).toFixed(2)}`; +} + +function pct(num: bigint, den: bigint): string { + if (den === 0n) return '0.0%'; + return `${(Number((num * 1000n) / den) / 10).toFixed(1)}%`; +} + +function requireConn(): DbConnection { + if (!conn) throw new Error('stdb.disconnected'); + return conn; +} + +function productsTable(): TableEvents { + return requireConn().db.cafeProducts; +} +function variantsTable(): TableEvents { + return requireConn().db.cafeVariants; +} +function scenariosTable(): TableEvents { + return requireConn().db.cafeScenarios; +} +function configTable(): TableEvents { + return requireConn().db.cafeConfig; +} +function metricsTable(): TableEvents { + return requireConn().db.cafeMetrics; +} +function sessionsTable(): TableEvents { + return requireConn().db.cafeRecentSessions; +} +function queueTable(): TableEvents { + return requireConn().db.cafeQueue; +} +function econTable(): TableEvents { + return requireConn().db.cafeEcon; +} + +function analyticsSummaryTable(): TableEvents { + return requireConn().db.cafeAnalyticsSummary; +} + +function rows(source: TableEvents): T[] { + return [...source.iter()]; +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + return (await r.json()) as ServerConfig; +} + +const TOKEN_KEY = 'context-cafe.stdb-token'; + +function connect(config: ServerConfig): Promise { + const attempt = (token: string | null): Promise => + new Promise((resolve, reject) => { + let builder = DbConnection.builder() + .withUri(config.spacetimeUri) + .withDatabaseName(config.databaseName) + // Persist the token so this browser keeps its identity across reloads. + .onConnect((connection, _identity, token) => { + try { + localStorage.setItem(TOKEN_KEY, token); + } catch { + /* ignore */ + } + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + stopSimulation(); + showToast(err?.message ?? 'Disconnected.', 'error'); + }) + .onConnectError((_ctx, err) => reject(err)); + if (token) builder = builder.withToken(token); + builder.build(); + }); + + let saved: string | null = null; + try { + saved = localStorage.getItem(TOKEN_KEY); + } catch { + /* ignore */ + } + if (!saved) return attempt(null); + // A token rejected after a --delete-data republish + // is rejected with a 401. Drop it and reconnect with a fresh identity. + return attempt(saved).catch(err => { + console.warn( + 'Stored identity token rejected. Clearing it and reconnecting.', + err + ); + try { + localStorage.removeItem(TOKEN_KEY); + } catch { + /* ignore */ + } + return attempt(null); + }); +} + +function effectivePriceCents(row: VariantRow): number { + return Math.round(row.priceCents * (1 - row.discountBps / 10000)); +} + +function variantById(id: string): VariantRow | undefined { + return rows(variantsTable()).find(v => v.variantId === id); +} + +function productById(id: string): ProductRow | undefined { + return rows(productsTable()).find(p => p.productId === id); +} + +function currentConfig(): ConfigRow | undefined { + return rows(configTable())[0]; +} + +function currentMetrics(): MetricsRow { + return ( + rows(metricsTable())[0] ?? { + tick: 0n, + views: 0n, + carts: 0n, + checkouts: 0n, + purchases: 0n, + abandons: 0n, + revenueCents: 0n, + } + ); +} + +function renderKpis(): void { + const m = currentMetrics(); + const e = currentEcon(); + + // Wallet cash is set in renderEcon. Profit equals sales minus supply spend. + setText('walletRevenue', money(m.revenueCents)); + setText('walletSpent', money(e.suppliesSpentCents)); + const profit = m.revenueCents - e.suppliesSpentCents; + const profitEl = document.getElementById('walletProfit'); + if (profitEl) { + const mag = money(profit < 0n ? -profit : profit); + profitEl.textContent = `${profit < 0n ? '▼' : '▲'} ${mag} profit`; + profitEl.classList.toggle('up', profit > 0n); + profitEl.classList.toggle('down', profit < 0n); + } + + // Ticker. + setText('kpiConversion', pct(m.purchases, m.views)); + setText( + 'kpiAov', + m.purchases === 0n ? '$0.00' : money(m.revenueCents / m.purchases) + ); + setText('kpiTicks', m.tick.toString()); + setText( + 'kpiSent', + (rows(analyticsSummaryTable())[0]?.delivered ?? 0n).toString() + ); + setText('kpiReneged', String(e.reneged)); + const rushing = + Number(m.tick % BigInt(RUSH_CYCLE_TICKS)) < RUSH_LENGTH_TICKS && + m.tick > 0n; + document.getElementById('rushBadge')?.classList.toggle('on', rushing); + + const funnel: Array<[string, bigint, string]> = [ + ['views', m.views, ''], + ['carts', m.carts, ''], + ['bought', m.purchases, 'buy'], + ['walked', m.abandons, 'off'], + ]; + $('flowSummary').innerHTML = funnel + .map( + ([label, val, cls]) => + `${val.toString()} ${escapeHtml(label)}` + ) + .join(''); +} + +function currentEcon(): EconRow { + return ( + rows(econTable())[0] ?? { + cashCents: 0n, + computeUnits: 0, + contextUnits: 0, + memoryUnits: 0, + suppliesSpentCents: 0n, + stockouts: 0, + reputation: 0, + workers: 1, + machineLevel: 0, + seats: 0, + storageLevel: 0, + reneged: 0, + } + ); +} + +const SUPPLIES: Array<{ + kind: 'compute' | 'context' | 'memory'; + unitsKey: 'computeUnits' | 'contextUnits' | 'memoryUnits'; + id: string; + fillId: string; + capId: string; +}> = [ + { + kind: 'compute', + unitsKey: 'computeUnits', + id: 'bankCompute', + fillId: 'fillCompute', + capId: 'capCompute', + }, + { + kind: 'context', + unitsKey: 'contextUnits', + id: 'bankContext', + fillId: 'fillContext', + capId: 'capContext', + }, + { + kind: 'memory', + unitsKey: 'memoryUnits', + id: 'bankMemory', + fillId: 'fillMemory', + capId: 'capMemory', + }, +]; + +function renderEcon(): void { + const e = currentEcon(); + setText('econCash', money(e.cashCents)); + setText('econStockouts', String(e.stockouts)); + for (const s of SUPPLIES) { + const units = e[s.unitsKey]; + const cap = storageCapacity(s.kind, e.storageLevel); + const low = units < 20; + const el = document.getElementById(s.id); + if (el) { + el.textContent = String(units); + el.classList.toggle('low', low); + } + const capEl = document.getElementById(s.capId); + if (capEl) capEl.textContent = `/${cap}`; + const fill = document.getElementById(s.fillId); + if (fill) { + fill.style.width = `${Math.max(0, Math.min(100, Math.round((units / cap) * 100)))}%`; + fill.classList.toggle('low', low); + } + const btn = document.querySelector( + `[data-supply="${s.kind}"]` + ) as HTMLButtonElement | null; + if (btn) { + const headroom = cap - units; + const full = headroom <= 0; + btn.disabled = + full || + e.cashCents < + BigInt(Math.min(BUY_UNITS, headroom) * SUPPLY_PRICE[s.kind]); + btn.textContent = full + ? 'Full' + : `+50 · ${money(BUY_UNITS * SUPPLY_PRICE[s.kind])}`; + } + } + renderReputation(e); + renderUpgrades(e); +} + +function renderReputation(e: EconRow): void { + const filled = Math.round(e.reputation / 20); + setText('repStars', '★'.repeat(filled) + '☆'.repeat(5 - filled)); + setText('repValue', String(e.reputation)); +} + +function renderUpgrades(e: EconRow): void { + setText('upWorkers', `${e.workers}/tick`); + setText( + 'upMachine', + e.machineLevel > 0 ? `−${e.machineLevel * 8}% supplies` : 'standard' + ); + setText('upCounter', `holds ${maximumQueueLength(e)}`); + setText( + 'upStorage', + e.storageLevel > 0 ? `+${e.storageLevel * 50}% space` : 'standard' + ); + + const maxed = e.machineLevel >= MAX_MACHINE_LEVEL; + setBuy('worker', upgradeCost('worker', e), e.cashCents); + setBuy( + 'machine', + upgradeCost('machine', e), + e.cashCents, + maxed, + maxed ? 'Maxed' : undefined + ); + setBuy('counter', upgradeCost('counter', e), e.cashCents); + setBuy('storage', upgradeCost('storage', e), e.cashCents); +} + +function setBuy( + kind: string, + costCents: number | bigint, + cash: bigint, + force = false, + label?: string +): void { + const btn = document.querySelector( + `[data-upgrade="${kind}"]` + ) as HTMLButtonElement | null; + if (!btn) return; + btn.textContent = label ?? money(costCents); + btn.disabled = force || cash < BigInt(costCents); +} + +function activeScenarioId(): string { + return ( + currentConfig()?.scenarioId || rows(scenariosTable())[0]?.scenarioId || '' + ); +} + +function renderMenu(): void { + const products = new Map(rows(productsTable()).map(p => [p.productId, p])); + const variants = rows(variantsTable()).sort((a, b) => { + const pa = products.get(a.productId)?.name ?? ''; + const pb = products.get(b.productId)?.name ?? ''; + return pa.localeCompare(pb) || a.name.localeCompare(b.name); + }); + + if (variants.length === 0) { + $('menuGrid').innerHTML = + '
Waiting for catalog sync.
'; + return; + } + + $('menuGrid').innerHTML = variants + .map(v => { + const product = products.get(v.productId); + const off = !v.active || (product ? !product.active : false); + const discounted = v.discountBps > 0; + const badges = [ + v.featured ? 'Featured' : '', + discounted + ? `-${v.discountBps / 100}%` + : '', + off ? 'Off' : '', + ].join(''); + return ` + + `; + }) + .join(''); +} + +function renderDrawer(): void { + if (!drawerOpen) return; + const variant = variantById(selectedVariantId); + if (!variant) { + closeDrawer(); + return; + } + const product = productById(variant.productId); + setText('drawerTitle', variant.name); + setText('drawerSub', `${product?.name ?? ''} · ${product?.category ?? ''}`); + + input('priceInput').value = (variant.priceCents / 100).toFixed(2); + input('discountInput').value = String(variant.discountBps / 100); + + const cost = supplyCost( + product ?? { category: '' }, + variant, + currentEcon().machineLevel + ); + const costCents = + cost.compute * SUPPLY_PRICE.compute + + cost.context * SUPPLY_PRICE.context + + cost.memory * SUPPLY_PRICE.memory; + const margin = effectivePriceCents(variant) - costCents; + const units = (n: number) => `${n} unit${n === 1 ? '' : 's'}`; + + $('variantDetails').innerHTML = ` +
+ Context window${variant.contextTokens.toLocaleString()} tokens + Reasoning${variant.reasoning}/10 + Latency${variant.latency}/10 + Price${money(effectivePriceCents(variant))}${variant.discountBps > 0 ? ` (was ${money(variant.priceCents)})` : ''} +
+
Uses per cup
+
+ Compute${units(cost.compute)} + Context${units(cost.context)} + Memory${units(cost.memory)} + Cost to make${money(costCents)} + Margin / cup${margin < 0 ? '−' : '+'}${money(Math.abs(margin))} +
+ `; + + setText('featureVariant', variant.featured ? 'Featured ✓' : 'Feature'); + $('featureVariant').classList.toggle('is-on', variant.featured); + setText('toggleVariant', variant.active ? 'Disable recipe' : 'Enable recipe'); + setText( + 'toggleProduct', + product?.active + ? `Disable ${product?.name ?? 'product'}` + : `Enable ${product?.name ?? 'product'}` + ); +} + +function openDrawer(variantId: string): void { + selectedVariantId = variantId; + drawerOpen = true; + $('drawer').classList.add('open'); + $('drawerScrim').classList.add('open'); + renderDrawer(); + renderMenu(); +} + +function closeDrawer(): void { + drawerOpen = false; + $('drawer').classList.remove('open'); + $('drawerScrim').classList.remove('open'); + renderMenu(); +} + +function cap(value: string): string { + return value ? value.charAt(0).toUpperCase() + value.slice(1) : value; +} + +type QueueRow = { + queueId: bigint; + botId: string; + profile: string; + scenarioId: string; + productId: string; + variantId: string; + wants: string; + thrifty: boolean; + arrivedTick: bigint; +}; + +function botInner(row: QueueRow, drink: string): string { + const wants = `wants ${escapeHtml(row.wants)}${row.thrifty ? ' · price-sensitive' : ''}`; + return ` +
${escapeHtml(cap(row.profile))} bot
${wants}
eyeing ${escapeHtml(drink)}
+
🤖
+
${escapeHtml(row.profile)}
+
${escapeHtml(row.wants)}${row.thrifty ? ' 💸' : ''}
`; +} + +// Render the waiting line, diffing against the live DOM so only changes animate. +function renderCounter(): void { + const bots = document.getElementById('counterBots'); + if (!bots) return; + const queue = [...rows(queueTable())].sort((a, b) => + a.queueId < b.queueId ? -1 : a.queueId > b.queueId ? 1 : 0 + ); + + if (queue.length === 0) { + if (!bots.querySelector('.empty')) + bots.innerHTML = '
Press Run to open the queue.
'; + } else { + const placeholder = bots.querySelector('.empty'); + if (placeholder) bots.innerHTML = ''; + + const variants = new Map( + rows(variantsTable()).map(row => [row.variantId, row]) + ); + const desired = queue.slice(0, 12); // front (lowest queueId) is served next, shown leftmost + const desiredIds = new Set(desired.map(row => row.queueId.toString())); + + // Bots that were served leave the line. + for (const el of Array.from(bots.children) as HTMLElement[]) { + const id = el.getAttribute('data-queue-id'); + if (id && !desiredIds.has(id) && !el.classList.contains('leaving')) { + el.classList.add('leaving'); + window.setTimeout(() => el.remove(), 480); + } + } + + const existing = new Set(); + for (const el of Array.from(bots.children) as HTMLElement[]) { + const id = el.getAttribute('data-queue-id'); + if (id) existing.add(id); + } + + let added = 0; + for (const row of desired) { + const id = row.queueId.toString(); + if (existing.has(id)) continue; + const drink = variants.get(row.variantId)?.name ?? 'a drink'; + const node = document.createElement('div'); + node.className = `bot waiting${row.thrifty ? ' thrifty' : ''} entering`; + node.setAttribute('data-queue-id', id); + node.style.animationDelay = `${added * 60}ms`; + node.innerHTML = botInner(row, drink); + bots.appendChild(node); + const delay = added * 60; + window.setTimeout(() => { + node.classList.remove('entering'); + node.style.animationDelay = ''; + }, 480 + delay); + added++; + } + } + + detectOutcomes(); +} + +// Pop each served bot: green "+$" on a sale, red "no sale" otherwise. Seed the +// first batch silently to prevent replaying past outcomes after a reload. +function detectOutcomes(): void { + const sessions = rows(sessionsTable()); + if (sessions.length === 0) { + seenSessionIds.clear(); + counterReady = false; + return; + } + if (!counterReady) { + for (const row of sessions) seenSessionIds.add(row.sessionId.toString()); + counterReady = true; + return; + } + // Oldest-first so a burst pops in the order it happened. + const fresh = sessions + .filter(row => !seenSessionIds.has(row.sessionId.toString())) + .sort((a, b) => + a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0 + ); + // Fan a batch out so simultaneous serves don't stack on the same spot. + fresh.forEach((row, i) => { + seenSessionIds.add(row.sessionId.toString()); + if (row.stage === 'purchased') + spawnPop(`+${money(row.revenueCents)}`, 'sale', i); + else spawnPop(missReason(row.reason), 'miss', i); + }); +} + +// Friendly counter caption for why a bot left without buying. +function missReason(reason: string): string { + switch (reason) { + case 'price': + return 'too pricey'; + case 'slow': + return 'too slow'; + case 'want_vision': + return 'wanted image support'; + case 'want_memory': + return 'wanted memory'; + case 'want_smart': + return 'wanted more reasoning'; + case 'want_premium': + return 'wanted top quality'; + case 'meh': + return 'changed its mind'; + case 'waited': + return 'gave up waiting'; + case 'short_context': + return 'out of context'; + case 'short_compute': + return 'out of compute'; + case 'short_memory': + return 'out of memory'; + case 'unavailable': + return 'off the menu'; + default: + return 'no sale'; + } +} + +function spawnPop(text: string, variant: 'sale' | 'miss', index = 0): void { + const pops = document.getElementById('counterPops'); + const bots = document.getElementById('counterBots'); + if (!pops) return; + // Anchor near the counter, then fan a batch rightward + stagger so they don't overlap. + const anchor = bots?.querySelector('.bot') as HTMLElement | null; + const baseLeft = anchor ? anchor.offsetLeft + anchor.offsetWidth / 2 : 30; + const baseTop = anchor ? anchor.offsetTop + 4 : 14; + const pop = document.createElement('div'); + pop.className = `pop ${variant}`; + pop.textContent = text; + pop.style.left = `${baseLeft + (index % 5) * 62}px`; + pop.style.top = `${baseTop - (index % 2) * 12}px`; + pop.style.animationDelay = `${index * 90}ms`; + pops.appendChild(pop); + pop.addEventListener('animationend', () => pop.remove()); +} + +function renderAll(): void { + if (!conn) return; + renderKpis(); + renderEcon(); + renderMenu(); + renderDrawer(); + renderCounter(); + syncRunButton(); +} + +function syncRunButton(): void { + const btn = document.getElementById('runToggle'); + if (!btn) return; + btn.classList.toggle('running', running); + const label = btn.querySelector('.run-label'); + if (label) label.textContent = running ? 'Pause' : 'Run'; +} + +function restartTimer(): void { + if (simTimer) clearInterval(simTimer); + simTimer = null; + if (!running) return; + simTimer = setInterval(() => { + void tickSimulation().catch(err => { + stopSimulation(); + showToast(err instanceof Error ? err.message : String(err), 'error'); + }); + }, speedMs); +} + +function stopSimulation(): void { + running = false; + if (simTimer) clearInterval(simTimer); + simTimer = null; + syncRunButton(); +} + +async function tickSimulation(): Promise { + requireConn().reducers.simulateTick({ + ticks: 1, + seed: `${Date.now()}:${Math.random()}`, + }); +} + +function registerNavigationHandlers(): void { + $('menuGrid').addEventListener('click', event => { + const card = (event.target as HTMLElement).closest( + '[data-variant-id]' + ) as HTMLElement | null; + if (card?.dataset.variantId) openDrawer(card.dataset.variantId); + }); + $('drawerClose').addEventListener('click', () => closeDrawer()); + $('drawerScrim').addEventListener('click', () => closeDrawer()); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && drawerOpen) closeDrawer(); + }); +} + +function guard(fn: () => Promise): () => Promise { + return async () => { + try { + await fn(); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), 'error'); + } + }; +} + +function registerActionHandlers(): void { + $('runToggle').addEventListener('click', () => { + running = !running; + restartTimer(); + renderAll(); + }); + $('tickOnce').addEventListener( + 'click', + guard(() => tickSimulation()) + ); + select('speedSelect').addEventListener('change', () => { + speedMs = Number(select('speedSelect').value); + restartTimer(); + }); + $('resetSim').addEventListener( + 'click', + guard(async () => { + stopSimulation(); + requireConn().reducers.resetSimulation({ + scenarioId: activeScenarioId(), + }); + showToast('Simulation reset.'); + }) + ); + $('savePrice').addEventListener( + 'click', + guard(async () => { + const cents = Math.max( + 0, + Math.round(Number(input('priceInput').value) * 100) + ); + requireConn().reducers.setVariantPrice({ + variantId: selectedVariantId, + priceCents: cents, + }); + }) + ); + $('saveDiscount').addEventListener( + 'click', + guard(async () => { + const bps = Math.max( + 0, + Math.min(9000, Math.round(Number(input('discountInput').value) * 100)) + ); + requireConn().reducers.setVariantDiscount({ + variantId: selectedVariantId, + discountBps: bps, + }); + }) + ); + $('featureVariant').addEventListener( + 'click', + guard(async () => { + requireConn().reducers.setFeaturedVariant({ + variantId: selectedVariantId, + }); + }) + ); + $('toggleVariant').addEventListener( + 'click', + guard(async () => { + const row = variantById(selectedVariantId); + if (!row) throw new Error('No recipe selected.'); + requireConn().reducers.setVariantActive({ + variantId: selectedVariantId, + active: !row.active, + }); + }) + ); + $('toggleProduct').addEventListener( + 'click', + guard(async () => { + const variant = variantById(selectedVariantId); + const product = variant ? productById(variant.productId) : undefined; + if (!product) throw new Error('No product selected.'); + requireConn().reducers.setProductActive({ + productId: product.productId, + active: !product.active, + }); + }) + ); + $('bankGrid').addEventListener('click', event => { + const btn = (event.target as HTMLElement).closest( + '[data-supply]' + ) as HTMLElement | null; + const kind = btn?.dataset.supply; + if (!kind) return; + void guard(async () => { + requireConn().reducers.buySupply({ kind, units: BUY_UNITS }); + })(); + }); + $('upgradeGrid').addEventListener('click', event => { + const btn = (event.target as HTMLElement).closest( + '[data-upgrade]' + ) as HTMLElement | null; + const kind = btn?.dataset.upgrade; + if (!kind) return; + void guard(async () => { + requireConn().reducers.buyUpgrade({ kind }); + })(); + }); +} + +function registerRowCallbacks(): void { + const render = () => renderAll(); + const sources = [ + productsTable(), + variantsTable(), + scenariosTable(), + configTable(), + metricsTable(), + sessionsTable(), + queueTable(), + econTable(), + analyticsSummaryTable(), + ]; + for (const source of sources) { + source.onInsert(render); + source.onUpdate(render); + source.onDelete(render); + } +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => { + renderAll(); + showToast('Context Cafe ready.'); + }) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([ + tables.cafeProducts, + tables.cafeVariants, + tables.cafeScenarios, + tables.cafeConfig, + tables.cafeMetrics, + tables.cafeEcon, + tables.cafeQueue, + tables.cafeRecentSessions, + tables.cafeAnalyticsSummary, + ]); +} + +async function main(): Promise { + // Register the chrome handlers first so the menu and drawer work immediately. + registerNavigationHandlers(); + registerActionHandlers(); + + const config = await loadServerConfig(); + + // The link ships with a working default href in the HTML; upgrade it to the + // configured PostHog host when the server provides one. + const link = document.getElementById( + 'posthogLink' + ) as HTMLAnchorElement | null; + if (link && config.posthogAppUrl) { + link.href = config.posthogAppUrl; + } + + conn = await connect(config); + + // Seed this browser's café (idempotent); rows stream in via the subscriptions. + try { + requireConn().reducers.initSession({}); + } catch (err) { + console.error('init_session failed', err); + } + + registerRowCallbacks(); + subscribeToTables(conn); + + renderAll(); +} + +main().catch(err => { + showToast(err instanceof Error ? err.message : String(err), 'error'); +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts new file mode 100644 index 00000000000..3b5f1cb92ab --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + kind: __t.string(), + units: __t.u32(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts new file mode 100644 index 00000000000..923a27af927 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + kind: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts new file mode 100644 index 00000000000..8eaef875426 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + queued: __t.u64(), + delivered: __t.u64(), + failed: __t.u64(), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts new file mode 100644 index 00000000000..2200717ee71 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + owner: __t.string().primaryKey(), + scenarioId: __t.string().name("scenario_id"), + tick: __t.u64(), + experimentKey: __t.string().name("experiment_key"), + experimentVariant: __t.option(__t.string()).name("experiment_variant"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts new file mode 100644 index 00000000000..aa684e30231 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + owner: __t.string().primaryKey(), + cashCents: __t.u64().name("cash_cents"), + computeUnits: __t.u32().name("compute_units"), + contextUnits: __t.u32().name("context_units"), + memoryUnits: __t.u32().name("memory_units"), + suppliesSpentCents: __t.u64().name("supplies_spent_cents"), + stockouts: __t.u32(), + reputation: __t.u32(), + workers: __t.u32(), + machineLevel: __t.u32().name("machine_level"), + seats: __t.u32(), + storageLevel: __t.u32().name("storage_level"), + reneged: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts new file mode 100644 index 00000000000..aa776242be2 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + owner: __t.string().primaryKey(), + tick: __t.u64(), + views: __t.u64(), + carts: __t.u64(), + checkouts: __t.u64(), + purchases: __t.u64(), + abandons: __t.u64(), + revenueCents: __t.u64().name("revenue_cents"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts new file mode 100644 index 00000000000..ea5ad870f38 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + owner: __t.string(), + productId: __t.string().name("product_id"), + name: __t.string(), + category: __t.string(), + description: __t.string(), + baseAppeal: __t.u32().name("base_appeal"), + active: __t.bool(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts new file mode 100644 index 00000000000..f6ffc4aabae --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + queueId: __t.u64().primaryKey().name("queue_id"), + owner: __t.string(), + botId: __t.string().name("bot_id"), + profile: __t.string(), + scenarioId: __t.string().name("scenario_id"), + productId: __t.string().name("product_id"), + variantId: __t.string().name("variant_id"), + wants: __t.string(), + thrifty: __t.bool(), + arrivedTick: __t.u64().name("arrived_tick"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts new file mode 100644 index 00000000000..7db0b0e7eee --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + activityId: __t.u64().primaryKey().name("activity_id"), + owner: __t.string(), + tick: __t.u64(), + kind: __t.string(), + message: __t.string(), + profile: __t.option(__t.string()), + productId: __t.option(__t.string()).name("product_id"), + variantId: __t.option(__t.string()).name("variant_id"), + amountCents: __t.option(__t.u32()).name("amount_cents"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts new file mode 100644 index 00000000000..f8686ababf5 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + purchaseId: __t.u64().primaryKey().name("purchase_id"), + owner: __t.string(), + sessionId: __t.u64().name("session_id"), + tick: __t.u64(), + botId: __t.string().name("bot_id"), + profile: __t.string(), + productId: __t.string().name("product_id"), + variantId: __t.string().name("variant_id"), + pricePaidCents: __t.u32().name("price_paid_cents"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts new file mode 100644 index 00000000000..67d0f14b874 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + sessionId: __t.u64().primaryKey().name("session_id"), + owner: __t.string(), + botId: __t.string().name("bot_id"), + tick: __t.u64(), + profile: __t.string(), + scenarioId: __t.string().name("scenario_id"), + productId: __t.option(__t.string()).name("product_id"), + variantId: __t.option(__t.string()).name("variant_id"), + stage: __t.string(), + revenueCents: __t.u32().name("revenue_cents"), + reason: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts new file mode 100644 index 00000000000..633b7ca5a2f --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + scenarioId: __t.string().primaryKey().name("scenario_id"), + name: __t.string(), + description: __t.string(), + trafficPerTick: __t.u32().name("traffic_per_tick"), + priceSensitivity: __t.u32().name("price_sensitivity"), + rushBias: __t.u32().name("rush_bias"), + researchBias: __t.u32().name("research_bias"), + visualBias: __t.u32().name("visual_bias"), + memoryBias: __t.u32().name("memory_bias"), + premiumBias: __t.u32().name("premium_bias"), + volatility: __t.u32(), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts b/spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts new file mode 100644 index 00000000000..d58a40be71b --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + owner: __t.string(), + variantId: __t.string().name("variant_id"), + productId: __t.string().name("product_id"), + name: __t.string(), + flavor: __t.string(), + contextTokens: __t.u32().name("context_tokens"), + reasoning: __t.u32(), + latency: __t.u32(), + priceCents: __t.u32().name("price_cents"), + baselinePriceCents: __t.u32().name("baseline_price_cents"), + discountBps: __t.u32().name("discount_bps"), + active: __t.bool(), + featured: __t.bool(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts new file mode 100644 index 00000000000..3bf3bb5c715 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + limit: __t.u32(), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/index.ts b/spacetime-posthog-ts/example/src/module_bindings/index.ts new file mode 100644 index 00000000000..d02c27f116c --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/index.ts @@ -0,0 +1,327 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.10.1 (commit 5418e2f229c57f503d4306ae9e5fb6df29533f7d). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import BuySupplyReducer from "./buy_supply_reducer"; +import BuyUpgradeReducer from "./buy_upgrade_reducer"; +import InitSessionReducer from "./init_session_reducer"; +import ResetSimulationReducer from "./reset_simulation_reducer"; +import SelectScenarioReducer from "./select_scenario_reducer"; +import SetExperimentVariantReducer from "./set_experiment_variant_reducer"; +import SetFeaturedVariantReducer from "./set_featured_variant_reducer"; +import SetProductActiveReducer from "./set_product_active_reducer"; +import SetVariantActiveReducer from "./set_variant_active_reducer"; +import SetVariantDiscountReducer from "./set_variant_discount_reducer"; +import SetVariantPriceReducer from "./set_variant_price_reducer"; +import SimulateTickReducer from "./simulate_tick_reducer"; +import SyncCatalogReducer from "./sync_catalog_reducer"; + +// Import all procedure arg schemas +import * as FlushAnalyticsProcedure from "./flush_analytics_procedure"; + +// Import all table schema definitions +import CafeAnalyticsSummaryRow from "./cafe_analytics_summary_table"; +import CafeConfigRow from "./cafe_config_table"; +import CafeEconRow from "./cafe_econ_table"; +import CafeMetricsRow from "./cafe_metrics_table"; +import CafeProductsRow from "./cafe_products_table"; +import CafeQueueRow from "./cafe_queue_table"; +import CafeRecentActivityRow from "./cafe_recent_activity_table"; +import CafeRecentPurchasesRow from "./cafe_recent_purchases_table"; +import CafeRecentSessionsRow from "./cafe_recent_sessions_table"; +import CafeScenariosRow from "./cafe_scenarios_table"; +import CafeVariantsRow from "./cafe_variants_table"; +import PosthogDeliveryLogAdminRow from "./posthog_delivery_log_admin_table"; +import PosthogOutboxAdminRow from "./posthog_outbox_admin_table"; + +// Import namespace table schema definitions +import Posthog_PosthogDeliveryLogAdminRow from "./posthog/posthog_delivery_log_admin_table"; +import Posthog_PosthogOutboxAdminRow from "./posthog/posthog_outbox_admin_table"; + +// Import namespace reducer arg schemas +import Posthog_EnqueueEventReducer from "./posthog/enqueue_event_reducer"; + +// Import namespace procedure arg schemas +import * as Posthog_AddAdminIdentityProcedure from "./posthog/add_admin_identity_procedure"; +import * as Posthog_CaptureNowProcedure from "./posthog/capture_now_procedure"; +import * as Posthog_FlushOutboxProcedure from "./posthog/flush_outbox_procedure"; +import * as Posthog_GetFeatureFlagProcedure from "./posthog/get_feature_flag_procedure"; +import * as Posthog_GetPosthogConfigStatusProcedure from "./posthog/get_posthog_config_status_procedure"; +import * as Posthog_RemoveAdminIdentityProcedure from "./posthog/remove_admin_identity_procedure"; +import * as Posthog_SetPosthogConfigProcedure from "./posthog/set_posthog_config_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + cafeAnalyticsSummary: __table({ + name: 'cafe_analytics_summary', + indexes: [ + ], + constraints: [ + ], + }, CafeAnalyticsSummaryRow), + cafeConfig: __table({ + name: 'cafe_config', + indexes: [ + ], + constraints: [ + ], + }, CafeConfigRow), + cafeEcon: __table({ + name: 'cafe_econ', + indexes: [ + ], + constraints: [ + ], + }, CafeEconRow), + cafeMetrics: __table({ + name: 'cafe_metrics', + indexes: [ + ], + constraints: [ + ], + }, CafeMetricsRow), + cafeProducts: __table({ + name: 'cafe_products', + indexes: [ + ], + constraints: [ + ], + }, CafeProductsRow), + cafeQueue: __table({ + name: 'cafe_queue', + indexes: [ + ], + constraints: [ + ], + }, CafeQueueRow), + cafeRecentActivity: __table({ + name: 'cafe_recent_activity', + indexes: [ + ], + constraints: [ + ], + }, CafeRecentActivityRow), + cafeRecentPurchases: __table({ + name: 'cafe_recent_purchases', + indexes: [ + ], + constraints: [ + ], + }, CafeRecentPurchasesRow), + cafeRecentSessions: __table({ + name: 'cafe_recent_sessions', + indexes: [ + ], + constraints: [ + ], + }, CafeRecentSessionsRow), + cafeScenarios: __table({ + name: 'cafe_scenarios', + indexes: [ + ], + constraints: [ + ], + }, CafeScenariosRow), + cafeVariants: __table({ + name: 'cafe_variants', + indexes: [ + ], + constraints: [ + ], + }, CafeVariantsRow), + posthogDeliveryLogAdmin: __table({ + name: 'posthog_delivery_log_admin', + indexes: [ + ], + constraints: [ + ], + }, PosthogDeliveryLogAdminRow), + posthogOutboxAdmin: __table({ + name: 'posthog_outbox_admin', + indexes: [ + ], + constraints: [ + ], + }, PosthogOutboxAdminRow), + "posthog.posthog_delivery_log_admin": __table({ + name: 'posthog.posthog_delivery_log_admin', + indexes: [ + ], + constraints: [ + ], + }, Posthog_PosthogDeliveryLogAdminRow), + "posthog.posthog_outbox_admin": __table({ + name: 'posthog.posthog_outbox_admin', + indexes: [ + ], + constraints: [ + ], + }, Posthog_PosthogOutboxAdminRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("buy_supply", BuySupplyReducer), + __reducerSchema("buy_upgrade", BuyUpgradeReducer), + __reducerSchema("init_session", InitSessionReducer), + __reducerSchema("reset_simulation", ResetSimulationReducer), + __reducerSchema("select_scenario", SelectScenarioReducer), + __reducerSchema("set_experiment_variant", SetExperimentVariantReducer), + __reducerSchema("set_featured_variant", SetFeaturedVariantReducer), + __reducerSchema("set_product_active", SetProductActiveReducer), + __reducerSchema("set_variant_active", SetVariantActiveReducer), + __reducerSchema("set_variant_discount", SetVariantDiscountReducer), + __reducerSchema("set_variant_price", SetVariantPriceReducer), + __reducerSchema("simulate_tick", SimulateTickReducer), + __reducerSchema("sync_catalog", SyncCatalogReducer), + __reducerSchema("posthog.enqueue_event", Posthog_EnqueueEventReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("flush_analytics", FlushAnalyticsProcedure.params, FlushAnalyticsProcedure.returnType), + __procedureSchema("posthog.add_admin_identity", Posthog_AddAdminIdentityProcedure.params, Posthog_AddAdminIdentityProcedure.returnType), + __procedureSchema("posthog.capture_now", Posthog_CaptureNowProcedure.params, Posthog_CaptureNowProcedure.returnType), + __procedureSchema("posthog.flush_outbox", Posthog_FlushOutboxProcedure.params, Posthog_FlushOutboxProcedure.returnType), + __procedureSchema("posthog.get_feature_flag", Posthog_GetFeatureFlagProcedure.params, Posthog_GetFeatureFlagProcedure.returnType), + __procedureSchema("posthog.get_posthog_config_status", Posthog_GetPosthogConfigStatusProcedure.params, Posthog_GetPosthogConfigStatusProcedure.returnType), + __procedureSchema("posthog.remove_admin_identity", Posthog_RemoveAdminIdentityProcedure.params, Posthog_RemoveAdminIdentityProcedure.returnType), + __procedureSchema("posthog.set_posthog_config", Posthog_SetPosthogConfigProcedure.params, Posthog_SetPosthogConfigProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.10.1" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + cafeAnalyticsSummary: __qb.cafeAnalyticsSummary, + cafeConfig: __qb.cafeConfig, + cafeEcon: __qb.cafeEcon, + cafeMetrics: __qb.cafeMetrics, + cafeProducts: __qb.cafeProducts, + cafeQueue: __qb.cafeQueue, + cafeRecentActivity: __qb.cafeRecentActivity, + cafeRecentPurchases: __qb.cafeRecentPurchases, + cafeRecentSessions: __qb.cafeRecentSessions, + cafeScenarios: __qb.cafeScenarios, + cafeVariants: __qb.cafeVariants, + posthogDeliveryLogAdmin: __qb.posthogDeliveryLogAdmin, + posthogOutboxAdmin: __qb.posthogOutboxAdmin, + posthog: { + posthogDeliveryLogAdmin: __qb["posthog.posthog_delivery_log_admin"], + posthogOutboxAdmin: __qb["posthog.posthog_outbox_admin"], + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + buySupply: __reducerAccessors.buySupply, + buyUpgrade: __reducerAccessors.buyUpgrade, + initSession: __reducerAccessors.initSession, + resetSimulation: __reducerAccessors.resetSimulation, + selectScenario: __reducerAccessors.selectScenario, + setExperimentVariant: __reducerAccessors.setExperimentVariant, + setFeaturedVariant: __reducerAccessors.setFeaturedVariant, + setProductActive: __reducerAccessors.setProductActive, + setVariantActive: __reducerAccessors.setVariantActive, + setVariantDiscount: __reducerAccessors.setVariantDiscount, + setVariantPrice: __reducerAccessors.setVariantPrice, + simulateTick: __reducerAccessors.simulateTick, + syncCatalog: __reducerAccessors.syncCatalog, + posthog: { + enqueueEvent: __reducerAccessors["posthog.enqueueEvent"], + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + flushAnalytics: __procedureAccessors.flushAnalytics, + posthog: { + addAdminIdentity: __procedureAccessors["posthog.addAdminIdentity"], + captureNow: __procedureAccessors["posthog.captureNow"], + flushOutbox: __procedureAccessors["posthog.flushOutbox"], + getFeatureFlag: __procedureAccessors["posthog.getFeatureFlag"], + getPosthogConfigStatus: __procedureAccessors["posthog.getPosthogConfigStatus"], + removeAdminIdentity: __procedureAccessors["posthog.removeAdminIdentity"], + setPosthogConfig: __procedureAccessors["posthog.setPosthogConfig"], + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts new file mode 100644 index 00000000000..9305ab0f7de --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts new file mode 100644 index 00000000000..0c2b30658d8 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), + idempotencyKey: __t.option(__t.string()), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts new file mode 100644 index 00000000000..3bf3bb5c715 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + limit: __t.u32(), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts new file mode 100644 index 00000000000..a1980cfd5d4 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + key: __t.string(), + distinctId: __t.string(), + personPropertiesJson: __t.option(__t.string()), + groupsJson: __t.option(__t.string()), +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts new file mode 100644 index 00000000000..d6933140f3b --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { +}; +export const returnType = __t.string() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts new file mode 100644 index 00000000000..df3db325c77 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + PostHogDeliverySource, +} from "./types"; + + +export default __t.row({ + deliveryId: __t.u64().name("delivery_id"), + get source() { + return PostHogDeliverySource; + }, + outboxId: __t.option(__t.string()).name("outbox_id"), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16().name("status_code"), + responseBody: __t.string().name("response_body"), + errorMessage: __t.option(__t.string()).name("error_message"), + attemptedAt: __t.timestamp().name("attempted_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts new file mode 100644 index 00000000000..eebab071cb7 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + PostHogOutboxStatus, +} from "./types"; + + +export default __t.row({ + outboxId: __t.string().primaryKey().name("outbox_id"), + idempotencyKey: __t.option(__t.string()).name("idempotency_key"), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + propertiesJson: __t.option(__t.string()).name("properties_json"), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()).name("claim_id"), + claimExpiresAtMicros: __t.i64().name("claim_expires_at_micros"), + nextAttemptAt: __t.timestamp().name("next_attempt_at"), + lastStatusCode: __t.option(__t.u16()).name("last_status_code"), + lastError: __t.option(__t.string()).name("last_error"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deliveredAt: __t.option(__t.timestamp()).name("delivered_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts new file mode 100644 index 00000000000..bfd93108ec4 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + identity: __t.identity(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts new file mode 100644 index 00000000000..d75d24aca6f --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + host: __t.string(), + projectApiKey: __t.string(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts new file mode 100644 index 00000000000..a6078e2a41f --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts @@ -0,0 +1,112 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const PostHogDeliveryLogRow = __t.object("PostHogDeliveryLogRow", { + deliveryId: __t.u64(), + get source() { + return PostHogDeliverySource; + }, + outboxId: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16(), + responseBody: __t.string(), + errorMessage: __t.option(__t.string()), + attemptedAt: __t.timestamp(), +}); +export type PostHogDeliveryLogRow = __Infer; + +// The tagged union or sum type for the algebraic type `PostHogDeliverySource`. +export const PostHogDeliverySource = __t.enum("PostHogDeliverySource", { + Direct: __t.unit(), + Flush: __t.unit(), + FeatureFlag: __t.unit(), +}); +export type PostHogDeliverySource = __Infer; + +// The tagged union or sum type for the algebraic type `PostHogOutboxStatus`. +export const PostHogOutboxStatus = __t.enum("PostHogOutboxStatus", { + Queued: __t.unit(), + Processing: __t.unit(), + Delivered: __t.unit(), + Failed: __t.unit(), +}); +export type PostHogOutboxStatus = __Infer; + +export const PosthogAdminIdentity = __t.object("PosthogAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type PosthogAdminIdentity = __Infer; + +export const PosthogConfig = __t.object("PosthogConfig", { + singleton: __t.bool(), + host: __t.string(), + projectApiKey: __t.string(), + updatedAt: __t.timestamp(), +}); +export type PosthogConfig = __Infer; + +export const PosthogDeliveryLog = __t.object("PosthogDeliveryLog", { + deliveryId: __t.u64(), + get source() { + return PostHogDeliverySource; + }, + outboxId: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16(), + responseBody: __t.string(), + errorMessage: __t.option(__t.string()), + attemptedAt: __t.timestamp(), + attemptedAtOrder: __t.i64(), +}); +export type PosthogDeliveryLog = __Infer; + +export const PosthogDeliveryLogAdmin = __t.object("PosthogDeliveryLogAdmin", {}); +export type PosthogDeliveryLogAdmin = __Infer; + +export const PosthogDeliveryStats = __t.object("PosthogDeliveryStats", { + singleton: __t.bool(), + pending: __t.u64(), + delivered: __t.u64(), + failed: __t.u64(), + updatedAt: __t.timestamp(), +}); +export type PosthogDeliveryStats = __Infer; + +export const PosthogOutbox = __t.object("PosthogOutbox", { + outboxId: __t.string(), + idempotencyKey: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()), + claimExpiresAtMicros: __t.i64(), + nextAttemptAt: __t.timestamp(), + lastStatusCode: __t.option(__t.u16()), + lastError: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deliveredAt: __t.option(__t.timestamp()), +}); +export type PosthogOutbox = __Infer; + +export const PosthogOutboxAdmin = __t.object("PosthogOutboxAdmin", {}); +export type PosthogOutboxAdmin = __Infer; + diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts new file mode 100644 index 00000000000..32a8176ced6 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + deliveryId: __t.string().name("delivery_id"), + source: __t.string(), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16().name("status_code"), + responseBody: __t.string().name("response_body"), + attemptedAt: __t.timestamp().name("attempted_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts b/spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts new file mode 100644 index 00000000000..eebab071cb7 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts @@ -0,0 +1,35 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; +import { + PostHogOutboxStatus, +} from "./types"; + + +export default __t.row({ + outboxId: __t.string().primaryKey().name("outbox_id"), + idempotencyKey: __t.option(__t.string()).name("idempotency_key"), + distinctId: __t.string().name("distinct_id"), + event: __t.string(), + propertiesJson: __t.option(__t.string()).name("properties_json"), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()).name("claim_id"), + claimExpiresAtMicros: __t.i64().name("claim_expires_at_micros"), + nextAttemptAt: __t.timestamp().name("next_attempt_at"), + lastStatusCode: __t.option(__t.u16()).name("last_status_code"), + lastError: __t.option(__t.string()).name("last_error"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), + deliveredAt: __t.option(__t.timestamp()).name("delivered_at"), +}); diff --git a/spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts new file mode 100644 index 00000000000..fa2ece181cd --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + scenarioId: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts new file mode 100644 index 00000000000..fa2ece181cd --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + scenarioId: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts new file mode 100644 index 00000000000..f67bc2af2c0 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + key: __t.string(), + variant: __t.option(__t.string()), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts new file mode 100644 index 00000000000..9964a3d6901 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + variantId: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts new file mode 100644 index 00000000000..36891f8a1d3 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productId: __t.string(), + active: __t.bool(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts new file mode 100644 index 00000000000..fa836963c3e --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + variantId: __t.string(), + active: __t.bool(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts new file mode 100644 index 00000000000..e97fb1a5fbc --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + variantId: __t.string(), + discountBps: __t.u32(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts new file mode 100644 index 00000000000..b2b5714fb19 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + variantId: __t.string(), + priceCents: __t.u32(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts new file mode 100644 index 00000000000..2b6e915beb8 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + ticks: __t.u32(), + seed: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts b/spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts new file mode 100644 index 00000000000..34d098ba890 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + productsJson: __t.string(), + scenariosJson: __t.string(), +}; diff --git a/spacetime-posthog-ts/example/src/module_bindings/types.ts b/spacetime-posthog-ts/example/src/module_bindings/types.ts new file mode 100644 index 00000000000..9b28e9352f0 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/types.ts @@ -0,0 +1,273 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const Activity = __t.object("Activity", { + activityId: __t.u64(), + owner: __t.string(), + tick: __t.u64(), + kind: __t.string(), + message: __t.string(), + profile: __t.option(__t.string()), + productId: __t.option(__t.string()), + variantId: __t.option(__t.string()), + amountCents: __t.option(__t.u32()), + createdAt: __t.timestamp(), +}); +export type Activity = __Infer; + +export const BotSession = __t.object("BotSession", { + sessionId: __t.u64(), + owner: __t.string(), + botId: __t.string(), + tick: __t.u64(), + profile: __t.string(), + scenarioId: __t.string(), + productId: __t.option(__t.string()), + variantId: __t.option(__t.string()), + stage: __t.string(), + revenueCents: __t.u32(), + reason: __t.string(), + createdAt: __t.timestamp(), +}); +export type BotSession = __Infer; + +export const CafeAnalyticsSummary = __t.object("CafeAnalyticsSummary", {}); +export type CafeAnalyticsSummary = __Infer; + +export const CafeConfig = __t.object("CafeConfig", {}); +export type CafeConfig = __Infer; + +export const CafeEcon = __t.object("CafeEcon", {}); +export type CafeEcon = __Infer; + +export const CafeMetrics = __t.object("CafeMetrics", {}); +export type CafeMetrics = __Infer; + +export const CafeProducts = __t.object("CafeProducts", {}); +export type CafeProducts = __Infer; + +export const CafeQueue = __t.object("CafeQueue", {}); +export type CafeQueue = __Infer; + +export const CafeRecentActivity = __t.object("CafeRecentActivity", {}); +export type CafeRecentActivity = __Infer; + +export const CafeRecentPurchases = __t.object("CafeRecentPurchases", {}); +export type CafeRecentPurchases = __Infer; + +export const CafeRecentSessions = __t.object("CafeRecentSessions", {}); +export type CafeRecentSessions = __Infer; + +export const CafeScenarios = __t.object("CafeScenarios", {}); +export type CafeScenarios = __Infer; + +export const CafeVariants = __t.object("CafeVariants", {}); +export type CafeVariants = __Infer; + +export const ContextCafeAnalyticsSummaryRow = __t.object("ContextCafeAnalyticsSummaryRow", { + queued: __t.u64(), + delivered: __t.u64(), + failed: __t.u64(), +}); +export type ContextCafeAnalyticsSummaryRow = __Infer; + +export const ContextCafeDeliveryLogRow = __t.object("ContextCafeDeliveryLogRow", { + deliveryId: __t.string(), + source: __t.string(), + distinctId: __t.string(), + event: __t.string(), + ok: __t.bool(), + statusCode: __t.u16(), + responseBody: __t.string(), + attemptedAt: __t.timestamp(), +}); +export type ContextCafeDeliveryLogRow = __Infer; + +export const Econ = __t.object("Econ", { + owner: __t.string(), + cashCents: __t.u64(), + computeUnits: __t.u32(), + contextUnits: __t.u32(), + memoryUnits: __t.u32(), + suppliesSpentCents: __t.u64(), + stockouts: __t.u32(), + reputation: __t.u32(), + workers: __t.u32(), + machineLevel: __t.u32(), + seats: __t.u32(), + storageLevel: __t.u32(), + reneged: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type Econ = __Infer; + +export const Metrics = __t.object("Metrics", { + owner: __t.string(), + tick: __t.u64(), + views: __t.u64(), + carts: __t.u64(), + checkouts: __t.u64(), + purchases: __t.u64(), + abandons: __t.u64(), + revenueCents: __t.u64(), + updatedAt: __t.timestamp(), +}); +export type Metrics = __Infer; + +// The tagged union or sum type for the algebraic type `PostHogOutboxStatus`. +export const PostHogOutboxStatus = __t.enum("PostHogOutboxStatus", { + Queued: __t.unit(), + Processing: __t.unit(), + Delivered: __t.unit(), + Failed: __t.unit(), +}); +export type PostHogOutboxStatus = __Infer; + +export const PosthogDeliveryLogAdmin = __t.object("PosthogDeliveryLogAdmin", {}); +export type PosthogDeliveryLogAdmin = __Infer; + +export const PosthogOutbox = __t.object("PosthogOutbox", { + outboxId: __t.string(), + idempotencyKey: __t.option(__t.string()), + distinctId: __t.string(), + event: __t.string(), + propertiesJson: __t.option(__t.string()), + get status() { + return PostHogOutboxStatus; + }, + attempts: __t.u32(), + claimId: __t.option(__t.string()), + claimExpiresAtMicros: __t.i64(), + nextAttemptAt: __t.timestamp(), + lastStatusCode: __t.option(__t.u16()), + lastError: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), + deliveredAt: __t.option(__t.timestamp()), +}); +export type PosthogOutbox = __Infer; + +export const PosthogOutboxAdmin = __t.object("PosthogOutboxAdmin", {}); +export type PosthogOutboxAdmin = __Infer; + +export const Product = __t.object("Product", { + key: __t.string(), + owner: __t.string(), + productId: __t.string(), + name: __t.string(), + category: __t.string(), + description: __t.string(), + baseAppeal: __t.u32(), + active: __t.bool(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Product = __Infer; + +export const ProductTemplate = __t.object("ProductTemplate", { + productId: __t.string(), + name: __t.string(), + category: __t.string(), + description: __t.string(), + baseAppeal: __t.u32(), + active: __t.bool(), +}); +export type ProductTemplate = __Infer; + +export const Purchase = __t.object("Purchase", { + purchaseId: __t.u64(), + owner: __t.string(), + sessionId: __t.u64(), + tick: __t.u64(), + botId: __t.string(), + profile: __t.string(), + productId: __t.string(), + variantId: __t.string(), + pricePaidCents: __t.u32(), + createdAt: __t.timestamp(), +}); +export type Purchase = __Infer; + +export const Scenario = __t.object("Scenario", { + scenarioId: __t.string(), + name: __t.string(), + description: __t.string(), + trafficPerTick: __t.u32(), + priceSensitivity: __t.u32(), + rushBias: __t.u32(), + researchBias: __t.u32(), + visualBias: __t.u32(), + memoryBias: __t.u32(), + premiumBias: __t.u32(), + volatility: __t.u32(), +}); +export type Scenario = __Infer; + +export const SimConfig = __t.object("SimConfig", { + owner: __t.string(), + scenarioId: __t.string(), + tick: __t.u64(), + experimentKey: __t.string(), + experimentVariant: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type SimConfig = __Infer; + +export const Variant = __t.object("Variant", { + key: __t.string(), + owner: __t.string(), + variantId: __t.string(), + productId: __t.string(), + name: __t.string(), + flavor: __t.string(), + contextTokens: __t.u32(), + reasoning: __t.u32(), + latency: __t.u32(), + priceCents: __t.u32(), + baselinePriceCents: __t.u32(), + discountBps: __t.u32(), + active: __t.bool(), + featured: __t.bool(), + updatedAt: __t.timestamp(), +}); +export type Variant = __Infer; + +export const VariantTemplate = __t.object("VariantTemplate", { + variantId: __t.string(), + productId: __t.string(), + name: __t.string(), + flavor: __t.string(), + contextTokens: __t.u32(), + reasoning: __t.u32(), + latency: __t.u32(), + priceCents: __t.u32(), + discountBps: __t.u32(), + active: __t.bool(), + featured: __t.bool(), +}); +export type VariantTemplate = __Infer; + +export const WaitingBot = __t.object("WaitingBot", { + queueId: __t.u64(), + owner: __t.string(), + botId: __t.string(), + profile: __t.string(), + scenarioId: __t.string(), + productId: __t.string(), + variantId: __t.string(), + wants: __t.string(), + thrifty: __t.bool(), + arrivedTick: __t.u64(), + createdAt: __t.timestamp(), +}); +export type WaitingBot = __Infer; + diff --git a/spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts b/spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..283f37b4540 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as FlushAnalyticsProcedure from "../flush_analytics_procedure"; + +export type FlushAnalyticsArgs = __Infer; +export type FlushAnalyticsResult = __Infer; + diff --git a/spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts b/spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..d87717d3228 --- /dev/null +++ b/spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts @@ -0,0 +1,36 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import BuySupplyReducer from "../buy_supply_reducer"; +import BuyUpgradeReducer from "../buy_upgrade_reducer"; +import InitSessionReducer from "../init_session_reducer"; +import ResetSimulationReducer from "../reset_simulation_reducer"; +import SelectScenarioReducer from "../select_scenario_reducer"; +import SetExperimentVariantReducer from "../set_experiment_variant_reducer"; +import SetFeaturedVariantReducer from "../set_featured_variant_reducer"; +import SetProductActiveReducer from "../set_product_active_reducer"; +import SetVariantActiveReducer from "../set_variant_active_reducer"; +import SetVariantDiscountReducer from "../set_variant_discount_reducer"; +import SetVariantPriceReducer from "../set_variant_price_reducer"; +import SimulateTickReducer from "../simulate_tick_reducer"; +import SyncCatalogReducer from "../sync_catalog_reducer"; + +export type BuySupplyParams = __Infer; +export type BuyUpgradeParams = __Infer; +export type InitSessionParams = __Infer; +export type ResetSimulationParams = __Infer; +export type SelectScenarioParams = __Infer; +export type SetExperimentVariantParams = __Infer; +export type SetFeaturedVariantParams = __Infer; +export type SetProductActiveParams = __Infer; +export type SetVariantActiveParams = __Infer; +export type SetVariantDiscountParams = __Infer; +export type SetVariantPriceParams = __Infer; +export type SimulateTickParams = __Infer; +export type SyncCatalogParams = __Infer; + diff --git a/spacetime-posthog-ts/example/tsconfig.json b/spacetime-posthog-ts/example/tsconfig.json new file mode 100644 index 00000000000..3f3a247c57c --- /dev/null +++ b/spacetime-posthog-ts/example/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "public/app.js", "public/app.js.map"] +} diff --git a/spacetime-posthog-ts/package.json b/spacetime-posthog-ts/package.json new file mode 100644 index 00000000000..71d66f9d7bd --- /dev/null +++ b/spacetime-posthog-ts/package.json @@ -0,0 +1,65 @@ +{ + "name": "@spacetimedb/posthog", + "description": "PostHog capture, durable outbox, delivery logs, and feature flags for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-posthog-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-posthog-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "posthog", + "analytics", + "typescript" + ], + "scripts": { + "build": "spacetime build", + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts", + "spacetime:generate": "spacetime generate --lang typescript --out-dir ts-codegen", + "publish:module": "spacetime publish", + "publish:local": "spacetime publish --server local --yes spacetime-posthog", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-posthog" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-posthog-ts/scripts/test.ts b/spacetime-posthog-ts/scripts/test.ts new file mode 100644 index 00000000000..bd87517fb56 --- /dev/null +++ b/spacetime-posthog-ts/scripts/test.ts @@ -0,0 +1,140 @@ +import * as assert from 'node:assert/strict'; +import { + isOkStatus, + toStatusCode, + truncateForLog, + featureFlagValue, + posthogFetch, +} from '../src/submodule/http.ts'; +import { + MAX_DELIVERY_ATTEMPTS, + claimHasExpired, + claimOutboxRow, + releaseExpiredClaim, + retryDelayMicros, + settleOutboxClaim, +} from '../src/submodule/outbox-state.ts'; + +assert.equal(isOkStatus(200), true); +assert.equal(isOkStatus(299), true); +assert.equal(isOkStatus(300), false); +assert.equal(toStatusCode(65535), 65535); +assert.equal(toStatusCode(65536), 0); +assert.equal(truncateForLog('x'.repeat(3000)).length, 2051); + +const flagBody = JSON.stringify({ + flags: { + enabled: { enabled: true }, + disabled: { enabled: false }, + experiment: { enabled: true, variant: 'control' }, + invalid: { enabled: 'true' }, + }, + requestId: 'x'.repeat(3000), +}); +const flagResult = posthogFetch( + { + http: { fetch: () => ({ status: 200, text: () => flagBody }) }, + } as unknown as Parameters[0], + { host: 'https://us.i.posthog.com', projectApiKey: 'test' }, + '/flags?v=2', + {} +); +assert.equal( + flagResult.responseBody, + flagBody, + 'parse complete responses before trimming logs' +); +assert.equal(featureFlagValue(flagResult.responseBody, 'enabled'), true); +assert.equal(featureFlagValue(flagResult.responseBody, 'disabled'), false); +assert.equal( + featureFlagValue(flagResult.responseBody, 'experiment'), + 'control' +); +for (const key of ['invalid', 'missing', 'toString']) { + assert.equal(featureFlagValue(flagBody, key), undefined); +} +assert.equal(featureFlagValue('not json', 'enabled'), undefined); + +const timestamp = { microsSinceUnixEpoch: 10_000_000n }; +const queued = { + outboxId: 'event-1', + status: { tag: 'Queued' }, + attempts: 0, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: timestamp, + lastStatusCode: undefined, + lastError: undefined, + updatedAt: timestamp, + deliveredAt: undefined, +}; + +const claimed = claimOutboxRow(queued, 'claim-1', 15_000_000n, timestamp); +assert.equal(claimed.status.tag, 'Processing'); +assert.equal(claimed.claimId, 'claim-1'); +assert.equal(claimHasExpired(claimed, 14_999_999n), false); +assert.equal(claimHasExpired(claimed, 15_000_000n), true); + +const released = releaseExpiredClaim(claimed, timestamp); +assert.equal(released.status.tag, 'Queued'); +assert.equal(released.claimId, undefined); +assert.equal(released.claimExpiresAtMicros, 0n); +assert.equal(retryDelayMicros(1), 1_000_000n); +assert.equal(retryDelayMicros(2), 2_000_000n); +assert.equal(retryDelayMicros(20), 300_000_000n); + +let retrying = claimed; +for (let attempt = 1; attempt < MAX_DELIVERY_ATTEMPTS; attempt++) { + const settled = settleOutboxClaim( + retrying, + { ok: false, statusCode: 503, responseBody: 'unavailable' }, + timestamp, + { microsSinceUnixEpoch: 11_000_000n } + ); + assert.equal(settled.row.attempts, attempt); + assert.equal(settled.terminal, false); + assert.equal(settled.row.status.tag, 'Queued'); + retrying = claimOutboxRow( + settled.row, + `claim-${attempt + 1}`, + 15_000_000n, + timestamp + ); +} + +const exhausted = settleOutboxClaim( + retrying, + { ok: false, statusCode: 503, responseBody: 'unavailable' }, + timestamp, + { microsSinceUnixEpoch: 11_000_000n } +); +assert.equal(exhausted.row.attempts, MAX_DELIVERY_ATTEMPTS); +assert.equal(exhausted.terminal, true); +assert.equal(exhausted.row.status.tag, 'Failed'); +assert.equal(exhausted.row.lastError, 'unavailable'); +assert.equal( + settleOutboxClaim( + claimed, + { + ok: false, + statusCode: 503, + responseBody: 'x'.repeat(3000), + }, + timestamp, + timestamp + ).row.lastError, + truncateForLog('x'.repeat(3000)) +); + +const delivered = settleOutboxClaim( + claimed, + { ok: true, statusCode: 200, responseBody: 'ok' }, + timestamp, + timestamp +); +assert.equal(delivered.terminal, true); +assert.equal(delivered.row.status.tag, 'Delivered'); +assert.equal(delivered.row.deliveredAt, timestamp); +assert.equal(delivered.row.lastError, undefined); + +console.log('posthog tests passed'); diff --git a/spacetime-posthog-ts/src/index.ts b/spacetime-posthog-ts/src/index.ts new file mode 100644 index 00000000000..e9582b1e210 --- /dev/null +++ b/spacetime-posthog-ts/src/index.ts @@ -0,0 +1,11 @@ +export { default, init } from './submodule/schema'; +export { setPosthogConfig, getPosthogConfigStatus } from './submodule/config'; +export { addAdminIdentity, removeAdminIdentity } from './submodule/auth'; +export { + captureNow, + enqueueEvent, + flushOutbox, + getFeatureFlag, + posthogDeliveryLogAdmin, + posthogOutboxAdmin, +} from './submodule/operations'; diff --git a/spacetime-posthog-ts/src/submodule.ts b/spacetime-posthog-ts/src/submodule.ts new file mode 100644 index 00000000000..43487869658 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule.ts @@ -0,0 +1,23 @@ +export { default } from './submodule/schema'; +export { + OutboxStatus, + posthogDeliveryLog, + posthogDeliveryStats, + posthogOutbox, + t, +} from './submodule/schema'; +export { installPostHog } from './submodule/install'; +export { setPosthogConfig, getPosthogConfigStatus } from './submodule/config'; +export { addAdminIdentity, removeAdminIdentity } from './submodule/auth'; +export { + captureEvent, + clearAnalytics, + enqueueEventInTx, + deliverOutbox, + captureNow, + enqueueEvent, + flushOutbox, + getFeatureFlag, + posthogDeliveryLogAdmin, + posthogOutboxAdmin, +} from './submodule/operations'; diff --git a/spacetime-posthog-ts/src/submodule/auth.ts b/spacetime-posthog-ts/src/submodule/auth.ts new file mode 100644 index 00000000000..fabacc7a50f --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/auth.ts @@ -0,0 +1,58 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { throwSenderError } from './validation'; + +type Sender = WriteCtx['sender']; +type AdminReadableCtx = { + db: { + posthogAdminIdentity: { + identity: { find(identity: Sender): unknown }; + }; + }; +}; + +export function isAdmin(ctx: AdminReadableCtx, sender: Sender): boolean { + return ctx.db.posthogAdminIdentity.identity.find(sender) != null; +} + +export function requireAdmin(ctx: WriteCtx, sender: Sender): void { + if (!isAdmin(ctx, sender)) throwSenderError('posthog.not_authorized'); +} + +export const addAdminIdentity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + if (tx.db.posthogAdminIdentity.identity.find(identity) == null) { + tx.db.posthogAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + }); + return {}; + } +); + +export const removeAdminIdentity = spacetimedb.procedure( + { identity: t.identity() }, + t.unit(), + (ctx: ProcedureModuleCtx, { identity }) => { + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + const existing = tx.db.posthogAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.posthogAdminIdentity.count() <= 1n) { + throwSenderError('posthog.cannot_remove_last_admin'); + } + tx.db.posthogAdminIdentity.delete(existing); + }); + return {}; + } +); diff --git a/spacetime-posthog-ts/src/submodule/config.ts b/spacetime-posthog-ts/src/submodule/config.ts new file mode 100644 index 00000000000..6bb1b3bdc89 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/config.ts @@ -0,0 +1,80 @@ +import { + spacetimedb, + t, + type ProcedureModuleCtx, + type WriteCtx, +} from './schema'; +import { requireAdmin } from './auth'; +import { normalizeHost, throwSenderError } from './validation'; + +export type PostHogConfig = { + host: string; + projectApiKey: string; +}; + +export function loadConfigOrThrow(ctx: WriteCtx): PostHogConfig { + const row = ctx.db.posthogConfig.singleton.find(true); + if (!row) { + throwSenderError('posthog.config_missing'); + } + return { + host: row.host, + projectApiKey: row.projectApiKey, + }; +} + +export function loadConfigOrThrowFromProcedure( + ctx: ProcedureModuleCtx +): PostHogConfig { + return ctx.withTx(tx => loadConfigOrThrow(tx)); +} + +export const setPosthogConfig = spacetimedb.procedure( + { + host: t.string(), + projectApiKey: t.string(), + }, + t.unit(), + (ctx, args) => { + const host = normalizeHost(args.host); + const projectApiKey = args.projectApiKey.trim(); + if (!projectApiKey) throwSenderError('posthog.invalid_project_api_key'); + ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + const existing = tx.db.posthogConfig.singleton.find(true); + const row = { + singleton: true, + host, + projectApiKey, + updatedAt: ctx.timestamp, + }; + if (!existing) { + tx.db.posthogConfig.insert(row); + } else { + tx.db.posthogConfig.singleton.update(row); + } + }); + return {}; + } +); + +export const getPosthogConfigStatus = spacetimedb.procedure( + {}, + t.string(), + ctx => + ctx.withTx(tx => { + const row = tx.db.posthogConfig.singleton.find(true); + if (!row) { + return JSON.stringify({ + isConfigured: false, + host: undefined, + projectApiKeyLength: 0, + }); + } + return JSON.stringify({ + isConfigured: true, + host: row.host, + projectApiKeyLength: row.projectApiKey.length, + }); + }) +); diff --git a/spacetime-posthog-ts/src/submodule/http.ts b/spacetime-posthog-ts/src/submodule/http.ts new file mode 100644 index 00000000000..28c9e4c4d14 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/http.ts @@ -0,0 +1,72 @@ +import type { ProcedureModuleCtx } from './schema'; +import type { PostHogConfig } from './config'; + +const MAX_LOG_BODY_LENGTH = 2048; + +export function truncateForLog(body: string): string { + return body.length <= MAX_LOG_BODY_LENGTH + ? body + : `${body.slice(0, MAX_LOG_BODY_LENGTH)}...`; +} + +export function toStatusCode(status: number): number { + if (!Number.isInteger(status) || status < 0 || status > 0xffff) return 0; + return status; +} + +export function isOkStatus(status: number): boolean { + return status >= 200 && status < 300; +} + +export type PostHogHttpResult = { + ok: boolean; + statusCode: number; + responseBody: string; +}; + +export function featureFlagValue( + body: string, + key: string +): boolean | string | undefined { + try { + const parsed: unknown = JSON.parse(body); + if (!parsed || typeof parsed !== 'object' || !('flags' in parsed)) + return undefined; + const flags = parsed.flags; + if (!flags || typeof flags !== 'object' || !Object.hasOwn(flags, key)) + return undefined; + const flag: unknown = (flags as Record)[key]; + if ( + !flag || + typeof flag !== 'object' || + !('enabled' in flag) || + typeof flag.enabled !== 'boolean' + ) + return undefined; + return 'variant' in flag && typeof flag.variant === 'string' + ? flag.variant + : flag.enabled; + } catch { + return undefined; + } +} + +export function posthogFetch( + ctx: ProcedureModuleCtx, + cfg: PostHogConfig, + path: string, + body: unknown +): PostHogHttpResult { + const response = ctx.http.fetch(`${cfg.host}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const statusCode = toStatusCode(response.status); + const responseBody = response.text(); + return { + ok: isOkStatus(statusCode), + statusCode, + responseBody, + }; +} diff --git a/spacetime-posthog-ts/src/submodule/install.ts b/spacetime-posthog-ts/src/submodule/install.ts new file mode 100644 index 00000000000..5201e9cf5c0 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/install.ts @@ -0,0 +1,9 @@ +import type { ReducerModuleCtx } from './schema'; + +export function installPostHog(ctx: ReducerModuleCtx) { + if (ctx.db.posthogAdminIdentity.identity.find(ctx.sender) != null) return; + ctx.db.posthogAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-posthog-ts/src/submodule/operations.ts b/spacetime-posthog-ts/src/submodule/operations.ts new file mode 100644 index 00000000000..5f9eb897ba0 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/operations.ts @@ -0,0 +1,503 @@ +import { Range } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + DeliverySource, + OutboxStatus, + deliverySource, + posthogDeliveryLogRow, + posthogOutbox, + spacetimedb, + t, + type ProcedureModuleCtx, + type ViewModuleCtx, + type WriteCtx, +} from './schema'; +import { loadConfigOrThrowFromProcedure } from './config'; +import { + featureFlagValue, + posthogFetch, + truncateForLog, + type PostHogHttpResult, +} from './http'; +import { isAdmin, requireAdmin } from './auth'; +import { parseJsonObject, throwSenderError } from './validation'; +import { + claimHasExpired, + claimOutboxRow, + releaseExpiredClaim, + retryDelayMicros, + settleOutboxClaim, +} from './outbox-state'; + +const DEFAULT_FLUSH_LIMIT = 25; +const MAX_FLUSH_LIMIT = 100; +const CLAIM_TTL_MICROS = 5n * 60n * 1_000_000n; +const MAX_EXPIRED_CLAIMS_PER_FLUSH = 1_000; +const MAX_DISTINCT_ID_LENGTH = 256; +const MAX_EVENT_NAME_LENGTH = 200; +const MAX_PROPERTIES_JSON_LENGTH = 64 * 1024; +const MAX_IDEMPOTENCY_KEY_LENGTH = 256; +const HISTORY_RETENTION_MICROS = 30n * 24n * 60n * 60n * 1_000_000n; +const MAX_RETENTION_ROWS_PER_CALL = 100; + +function takeRows(rows: Iterable, limit: number): T[] { + const out: T[] = []; + for (const row of rows) { + if (out.length >= limit) break; + out.push(row); + } + return out; +} + +function updateDeliveryStats( + ctx: WriteCtx, + delta: { pending?: bigint; delivered?: bigint; failed?: bigint } +): void { + const existing = ctx.db.posthogDeliveryStats.singleton.find(true); + const current = existing ?? { + singleton: true, + pending: 0n, + delivered: 0n, + failed: 0n, + updatedAt: ctx.timestamp, + }; + const adjust = (value: bigint, change = 0n) => { + const next = value + change; + return next < 0n ? 0n : next; + }; + const row = { + ...current, + pending: adjust(current.pending, delta.pending), + delivered: adjust(current.delivered, delta.delivered), + failed: adjust(current.failed, delta.failed), + updatedAt: ctx.timestamp, + }; + if (existing) ctx.db.posthogDeliveryStats.singleton.update(row); + else ctx.db.posthogDeliveryStats.insert(row); +} + +export type EnqueueEventArgs = { + distinctId: string; + event: string; + propertiesJson?: string | undefined; + idempotencyKey?: string | undefined; +}; + +export type CaptureEventArgs = { + distinctId: string; + event: string; + propertiesJson?: string | undefined; +}; + +function validateEventInput(args: CaptureEventArgs): void { + const distinctId = args.distinctId.trim(); + const event = args.event.trim(); + if (!distinctId || distinctId.length > MAX_DISTINCT_ID_LENGTH) { + throwSenderError('posthog.invalid_distinct_id'); + } + if (!event || event.length > MAX_EVENT_NAME_LENGTH) { + throwSenderError('posthog.invalid_event'); + } + if ((args.propertiesJson?.length ?? 0) > MAX_PROPERTIES_JSON_LENGTH) { + throwSenderError('posthog.properties_too_large'); + } + parseJsonObject(args.propertiesJson, 'properties'); +} + +function buildBatchBody(projectApiKey: string, events: CaptureEventArgs[]) { + return { + api_key: projectApiKey, + batch: events.map(event => ({ + distinct_id: event.distinctId, + event: event.event, + properties: parseJsonObject(event.propertiesJson, 'properties') ?? {}, + })), + }; +} + +function outboxIdFor( + ctx: WriteCtx, + idempotencyKey: string | undefined +): string { + if (idempotencyKey !== undefined && idempotencyKey.trim()) { + return `idem:${idempotencyKey.trim()}`; + } + return `evt:${ctx.newUuidV7().toString()}`; +} + +export function enqueueEventInTx(ctx: WriteCtx, args: EnqueueEventArgs) { + validateEventInput(args); + if ((args.idempotencyKey?.length ?? 0) > MAX_IDEMPOTENCY_KEY_LENGTH) { + throwSenderError('posthog.idempotency_key_too_long'); + } + const outboxId = outboxIdFor(ctx, args.idempotencyKey); + const existing = ctx.db.posthogOutbox.outboxId.find(outboxId); + if (existing) { + return { outboxId, inserted: false }; + } + ctx.db.posthogOutbox.insert({ + outboxId, + idempotencyKey: args.idempotencyKey, + distinctId: args.distinctId, + event: args.event, + propertiesJson: args.propertiesJson, + status: OutboxStatus.Queued, + attempts: 0, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: ctx.timestamp, + lastStatusCode: undefined, + lastError: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + deliveredAt: undefined, + }); + updateDeliveryStats(ctx, { pending: 1n }); + return { outboxId, inserted: true }; +} + +function pruneDeliveryHistory( + ctx: WriteCtx, + maxRows = MAX_RETENTION_ROWS_PER_CALL +): number { + const cutoff = new Timestamp( + ctx.timestamp.microsSinceUnixEpoch - HISTORY_RETENTION_MICROS + ); + let removed = 0; + for (const status of [OutboxStatus.Delivered, OutboxStatus.Failed]) { + for (const row of ctx.db.posthogOutbox.byStatusUpdatedAt.filter([ + status, + new Range(undefined, { tag: 'included', value: cutoff }), + ])) { + if (removed >= maxRows) return removed; + ctx.db.posthogOutbox.delete(row); + removed++; + } + } + for (const row of ctx.db.posthogDeliveryLog.byAttemptedAt.filter( + new Range(undefined, { tag: 'included', value: cutoff }) + )) { + if (removed >= maxRows) break; + ctx.db.posthogDeliveryLog.delete(row); + removed++; + } + return removed; +} + +// Remove queued and delivered outbox entries plus the delivery log for bounded +// Operator-requested cleanup. Events received by PostHog remain at the provider. +export function clearAnalytics( + ctx: WriteCtx, + maxRows = 1000 +): { outbox: number; deliveries: number } { + if (!Number.isInteger(maxRows) || maxRows <= 0 || maxRows > 10_000) { + throwSenderError('posthog.invalid_clear_batch'); + } + let outbox = 0; + let deliveries = 0; + let pendingRemoved = 0n; + let deliveredRemoved = 0n; + let failedRemoved = 0n; + for (const row of ctx.db.posthogOutbox.iter()) { + if (outbox + deliveries >= maxRows) break; + if (row.status.tag === 'Queued' || row.status.tag === 'Processing') + pendingRemoved += 1n; + ctx.db.posthogOutbox.delete(row); + outbox++; + } + for (const row of ctx.db.posthogDeliveryLog.iter()) { + if (outbox + deliveries >= maxRows) break; + if (row.ok) deliveredRemoved += 1n; + else failedRemoved += 1n; + ctx.db.posthogDeliveryLog.delete(row); + deliveries++; + } + updateDeliveryStats(ctx, { + pending: -pendingRemoved, + delivered: -deliveredRemoved, + failed: -failedRemoved, + }); + return { outbox, deliveries }; +} + +function logDelivery( + ctx: WriteCtx, + source: (typeof DeliverySource)[keyof typeof DeliverySource], + outboxId: string | undefined, + event: CaptureEventArgs, + result: PostHogHttpResult +) { + const responseBody = truncateForLog(result.responseBody); + ctx.db.posthogDeliveryLog.insert({ + deliveryId: 0n, + source, + outboxId, + distinctId: event.distinctId, + event: event.event, + ok: result.ok, + statusCode: result.statusCode, + responseBody, + errorMessage: result.ok ? undefined : responseBody, + attemptedAt: ctx.timestamp, + attemptedAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + }); + updateDeliveryStats(ctx, result.ok ? { delivered: 1n } : { failed: 1n }); +} + +export function captureEvent( + ctx: ProcedureModuleCtx, + args: CaptureEventArgs +): PostHogHttpResult { + validateEventInput(args); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const result = posthogFetch( + ctx, + cfg, + '/batch', + buildBatchBody(cfg.projectApiKey, [args]) + ); + ctx.withTx(tx => { + logDelivery(tx, DeliverySource.Direct, undefined, args, result); + pruneDeliveryHistory(tx); + }); + return result; +} + +function claimQueuedRows(ctx: WriteCtx, limit: number) { + const nowMicros = ctx.timestamp.microsSinceUnixEpoch; + let inspected = 0; + for (const row of ctx.db.posthogOutbox.byStatusClaimExpiresAtMicros.filter([ + OutboxStatus.Processing, + new Range(undefined, { tag: 'included', value: nowMicros }), + ])) { + if (inspected >= MAX_EXPIRED_CLAIMS_PER_FLUSH) break; + inspected++; + if (!claimHasExpired(row, nowMicros)) continue; + ctx.db.posthogOutbox.outboxId.update( + releaseExpiredClaim(row, ctx.timestamp) + ); + } + + const rows = takeRows( + ctx.db.posthogOutbox.byStatusNextAttemptAt.filter([ + OutboxStatus.Queued, + new Range(undefined, { tag: 'included', value: ctx.timestamp }), + ]), + limit + ); + const claimId = ctx.newUuidV7().toString(); + const claimed = rows.map(row => + claimOutboxRow(row, claimId, nowMicros + CLAIM_TTL_MICROS, ctx.timestamp) + ); + for (const row of claimed) ctx.db.posthogOutbox.outboxId.update(row); + return { claimId, rows: claimed }; +} + +export function deliverOutbox( + ctx: ProcedureModuleCtx, + args: { limit?: number | undefined } +) { + const rawLimit = args.limit ?? DEFAULT_FLUSH_LIMIT; + if ( + !Number.isInteger(rawLimit) || + rawLimit <= 0 || + rawLimit > MAX_FLUSH_LIMIT + ) { + throwSenderError('posthog.invalid_flush_limit'); + } + const cfg = loadConfigOrThrowFromProcedure(ctx); + const claim = ctx.withTx(tx => { + requireAdmin(tx, ctx.sender); + return claimQueuedRows(tx, rawLimit); + }); + const rows = claim.rows; + if (rows.length === 0) { + return { attempted: 0, delivered: 0, failed: 0 }; + } + + const events = rows.map(row => ({ + distinctId: row.distinctId, + event: row.event, + propertiesJson: row.propertiesJson, + })); + const result = posthogFetch( + ctx, + cfg, + '/batch', + buildBatchBody(cfg.projectApiKey, events) + ); + + return ctx.withTx(tx => { + let delivered = 0; + let failed = 0; + for (const row of rows) { + const current = tx.db.posthogOutbox.outboxId.find(row.outboxId); + if ( + !current || + current.status.tag !== 'Processing' || + current.claimId !== claim.claimId + ) + continue; + logDelivery(tx, DeliverySource.Flush, row.outboxId, row, result); + const retryAt = new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + retryDelayMicros(current.attempts + 1) + ); + const settled = settleOutboxClaim( + current, + result, + ctx.timestamp, + retryAt + ); + tx.db.posthogOutbox.outboxId.update(settled.row); + if (settled.terminal) updateDeliveryStats(tx, { pending: -1n }); + if (result.ok) delivered++; + else failed++; + } + pruneDeliveryHistory(tx); + return { attempted: rows.length, delivered, failed }; + }); +} + +export const enqueueEvent = spacetimedb.reducer( + { + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + enqueueEventInTx(ctx, args); + } +); + +export const captureNow = spacetimedb.procedure( + { + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + return JSON.stringify(captureEvent(ctx, args)); + } +); + +export const flushOutbox = spacetimedb.procedure( + { limit: t.u32() }, + t.string(), + (ctx, args) => JSON.stringify(deliverOutbox(ctx, { limit: args.limit })) +); + +export const getFeatureFlag = spacetimedb.procedure( + { + key: t.string(), + distinctId: t.string(), + personPropertiesJson: t.option(t.string()), + groupsJson: t.option(t.string()), + }, + t.string(), + (ctx, args) => { + ctx.withTx(tx => requireAdmin(tx, ctx.sender)); + if (!args.key.trim() || args.key.length > MAX_EVENT_NAME_LENGTH) { + throwSenderError('posthog.invalid_flag_key'); + } + if ( + !args.distinctId.trim() || + args.distinctId.length > MAX_DISTINCT_ID_LENGTH + ) { + throwSenderError('posthog.invalid_distinct_id'); + } + if ((args.personPropertiesJson?.length ?? 0) > MAX_PROPERTIES_JSON_LENGTH) { + throwSenderError('posthog.person_properties_too_large'); + } + if ((args.groupsJson?.length ?? 0) > MAX_PROPERTIES_JSON_LENGTH) { + throwSenderError('posthog.groups_too_large'); + } + const personProperties = parseJsonObject( + args.personPropertiesJson, + 'person_properties' + ); + const groups = parseJsonObject(args.groupsJson, 'groups'); + const cfg = loadConfigOrThrowFromProcedure(ctx); + const body: Record = { + api_key: cfg.projectApiKey, + distinct_id: args.distinctId, + }; + if (personProperties !== undefined) + body.person_properties = personProperties; + if (groups !== undefined) body.groups = groups; + const result = posthogFetch(ctx, cfg, '/flags?v=2', body); + const valueJson = result.ok + ? JSON.stringify(featureFlagValue(result.responseBody, args.key)) + : undefined; + ctx.withTx(tx => { + logDelivery( + tx, + DeliverySource.FeatureFlag, + undefined, + { distinctId: args.distinctId, event: `$feature_flag:${args.key}` }, + result + ); + pruneDeliveryHistory(tx); + }); + return JSON.stringify({ + ok: result.ok, + statusCode: result.statusCode, + responseBody: result.responseBody, + valueJson, + }); + } +); + +function viewIsAdmin(ctx: ViewModuleCtx): boolean { + return isAdmin(ctx, ctx.sender); +} + +export const posthogOutboxAdmin = spacetimedb.view( + { name: 'posthog_outbox_admin', public: true }, + t.array(posthogOutbox.rowType), + ctx => { + if (!viewIsAdmin(ctx)) return []; + const rows = takeRows( + ctx.db.posthogOutbox.byStatus.filter(OutboxStatus.Processing), + 500 + ); + if (rows.length < 500) { + rows.push( + ...takeRows( + ctx.db.posthogOutbox.byStatus.filter(OutboxStatus.Queued), + 500 - rows.length + ) + ); + } + return rows; + } +); + +export const posthogDeliveryLogAdmin = spacetimedb.view( + { name: 'posthog_delivery_log_admin', public: true }, + t.array(posthogDeliveryLogRow), + ctx => { + if (!viewIsAdmin(ctx)) return []; + return takeRows( + ctx.db.posthogDeliveryLog.byAttemptedAtOrder.filter(new Range()), + 50 + ).map(row => ({ + deliveryId: row.deliveryId, + source: row.source, + outboxId: row.outboxId, + distinctId: row.distinctId, + event: row.event, + ok: row.ok, + statusCode: row.statusCode, + responseBody: row.responseBody, + errorMessage: row.errorMessage, + attemptedAt: row.attemptedAt, + })); + } +); + +export { deliverySource }; diff --git a/spacetime-posthog-ts/src/submodule/outbox-state.ts b/spacetime-posthog-ts/src/submodule/outbox-state.ts new file mode 100644 index 00000000000..a731443255f --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/outbox-state.ts @@ -0,0 +1,88 @@ +import { truncateForLog } from './http'; + +export const MAX_DELIVERY_ATTEMPTS = 5; +const INITIAL_RETRY_DELAY_MICROS = 1_000_000n; +const MAX_RETRY_DELAY_MICROS = 5n * 60n * 1_000_000n; + +type OutboxRow = { + status: { tag: string }; + attempts: number; + claimId?: string | undefined; + claimExpiresAtMicros: bigint; + nextAttemptAt: unknown; + lastStatusCode?: number | undefined; + lastError?: string | undefined; + updatedAt: unknown; + deliveredAt?: unknown; +}; + +export function claimHasExpired( + row: Pick, + nowMicros: bigint +): boolean { + return row.claimExpiresAtMicros <= nowMicros; +} + +export function retryDelayMicros(attempt: number): bigint { + const exponent = Math.max(0, Math.min(30, Math.trunc(attempt) - 1)); + const delay = INITIAL_RETRY_DELAY_MICROS * (1n << BigInt(exponent)); + return delay > MAX_RETRY_DELAY_MICROS ? MAX_RETRY_DELAY_MICROS : delay; +} + +export function releaseExpiredClaim( + row: T, + timestamp: T['updatedAt'] +): T { + return { + ...row, + status: { tag: 'Queued' }, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: timestamp, + updatedAt: timestamp, + }; +} + +export function claimOutboxRow( + row: T, + claimId: string, + expiresAtMicros: bigint, + timestamp: T['updatedAt'] +): T { + return { + ...row, + status: { tag: 'Processing' }, + claimId, + claimExpiresAtMicros: expiresAtMicros, + updatedAt: timestamp, + }; +} + +export function settleOutboxClaim( + row: T, + result: { ok: boolean; statusCode: number; responseBody: string }, + timestamp: T['updatedAt'], + retryAt: T['nextAttemptAt'] +): { row: T; terminal: boolean } { + const attempts = row.attempts + 1; + const terminal = result.ok || attempts >= MAX_DELIVERY_ATTEMPTS; + return { + terminal, + row: { + ...row, + status: result.ok + ? { tag: 'Delivered' } + : terminal + ? { tag: 'Failed' } + : { tag: 'Queued' }, + attempts, + claimId: undefined, + claimExpiresAtMicros: 0n, + nextAttemptAt: terminal ? timestamp : retryAt, + lastStatusCode: result.statusCode, + lastError: result.ok ? undefined : truncateForLog(result.responseBody), + updatedAt: timestamp, + deliveredAt: result.ok ? timestamp : undefined, + }, + }; +} diff --git a/spacetime-posthog-ts/src/submodule/schema.ts b/spacetime-posthog-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..cada58018ca --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/schema.ts @@ -0,0 +1,182 @@ +import { + SenderError, + schema, + table, + t, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { installPostHog } from './install'; + +export const outboxStatus = t.enum('PostHogOutboxStatus', [ + 'Queued', + 'Processing', + 'Delivered', + 'Failed', +]); +export const OutboxStatus = { + Queued: { tag: 'Queued' as const }, + Processing: { tag: 'Processing' as const }, + Delivered: { tag: 'Delivered' as const }, + Failed: { tag: 'Failed' as const }, +}; + +export const deliverySource = t.enum('PostHogDeliverySource', [ + 'Direct', + 'Flush', + 'FeatureFlag', +]); +export const DeliverySource = { + Direct: { tag: 'Direct' as const }, + Flush: { tag: 'Flush' as const }, + FeatureFlag: { tag: 'FeatureFlag' as const }, +}; + +export const posthogConfig = table( + { name: 'posthog_config', public: false }, + { + singleton: t.bool().primaryKey(), + host: t.string(), + projectApiKey: t.string(), + updatedAt: t.timestamp(), + } +); + +export const posthogAdminIdentity = table( + { name: 'posthog_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const posthogOutbox = table( + { + name: 'posthog_outbox', + public: false, + indexes: [ + { accessor: 'byStatus', algorithm: 'btree', columns: ['status'] }, + { accessor: 'byCreatedAt', algorithm: 'btree', columns: ['createdAt'] }, + { + accessor: 'byStatusCreatedAt', + algorithm: 'btree', + columns: ['status', 'createdAt'], + }, + { + accessor: 'byStatusNextAttemptAt', + algorithm: 'btree', + columns: ['status', 'nextAttemptAt'], + }, + { + accessor: 'byStatusClaimExpiresAtMicros', + algorithm: 'btree', + columns: ['status', 'claimExpiresAtMicros'], + }, + { + accessor: 'byStatusUpdatedAt', + algorithm: 'btree', + columns: ['status', 'updatedAt'], + }, + ], + }, + { + outboxId: t.string().primaryKey(), + idempotencyKey: t.option(t.string()), + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + status: outboxStatus, + attempts: t.u32(), + claimId: t.option(t.string()), + claimExpiresAtMicros: t.i64(), + nextAttemptAt: t.timestamp(), + lastStatusCode: t.option(t.u16()), + lastError: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + deliveredAt: t.option(t.timestamp()), + } +); + +export const posthogDeliveryStats = table( + { name: 'posthog_delivery_stats', public: false }, + { + singleton: t.bool().primaryKey(), + pending: t.u64(), + delivered: t.u64(), + failed: t.u64(), + updatedAt: t.timestamp(), + } +); + +export const posthogDeliveryLog = table( + { + name: 'posthog_delivery_log', + public: false, + indexes: [ + { + accessor: 'byAttemptedAt', + algorithm: 'btree', + columns: ['attemptedAt'], + }, + { + accessor: 'byAttemptedAtOrder', + algorithm: 'btree', + columns: ['attemptedAtOrder'], + }, + { accessor: 'byOk', algorithm: 'btree', columns: ['ok'] }, + ], + }, + { + deliveryId: t.u64().primaryKey().autoInc(), + source: deliverySource, + outboxId: t.option(t.string()), + distinctId: t.string(), + event: t.string(), + ok: t.bool(), + statusCode: t.u16(), + responseBody: t.string(), + errorMessage: t.option(t.string()), + attemptedAt: t.timestamp(), + attemptedAtOrder: t.i64(), + } +); + +export const posthogDeliveryLogRow = t.object('PostHogDeliveryLogRow', { + deliveryId: t.u64(), + source: deliverySource, + outboxId: t.option(t.string()), + distinctId: t.string(), + event: t.string(), + ok: t.bool(), + statusCode: t.u16(), + responseBody: t.string(), + errorMessage: t.option(t.string()), + attemptedAt: t.timestamp(), +}); + +export const spacetimedb = schema({ + posthogConfig, + posthogAdminIdentity, + posthogOutbox, + posthogDeliveryLog, + posthogDeliveryStats, +}); + +export const init = spacetimedb.init(ctx => { + installPostHog(ctx); +}); + +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; +export type WriteCtx = ReducerModuleCtx | TransactionModuleCtx; + +export { SenderError, t }; diff --git a/spacetime-posthog-ts/src/submodule/validation.ts b/spacetime-posthog-ts/src/submodule/validation.ts new file mode 100644 index 00000000000..19eb4333ef3 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/validation.ts @@ -0,0 +1,28 @@ +import { SenderError } from 'spacetimedb/server'; + +export function throwSenderError(message: string): never { + throw new SenderError(message); +} + +export function normalizeHost(host: string): string { + const trimmed = host.trim(); + if (!trimmed) throwSenderError('posthog.invalid_host'); + return trimmed.replace(/\/+$/, ''); +} + +export function parseJsonObject( + json: string | undefined, + name: string +): unknown { + if (json === undefined) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throwSenderError(`posthog.invalid_${name}_json`); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throwSenderError(`posthog.invalid_${name}_json`); + } + return parsed; +} diff --git a/spacetime-posthog-ts/tsconfig.json b/spacetime-posthog-ts/tsconfig.json new file mode 100644 index 00000000000..c659d97428a --- /dev/null +++ b/spacetime-posthog-ts/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "dist/**/*"] +}