From a0dec5cc7158b29e0505997d9df446217ff4a34c Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 8 Sep 2026 17:24:10 -0400 Subject: [PATCH] Add PostHog analytics submodule and example --- pnpm-lock.yaml | 68 + spacetime-posthog-ts/.npmrc | 1 + spacetime-posthog-ts/LICENSE.txt | 759 +++++++++++ spacetime-posthog-ts/README.md | 171 +++ spacetime-posthog-ts/example/.env.example | 10 + spacetime-posthog-ts/example/.npmrc | 1 + spacetime-posthog-ts/example/README.md | 166 +++ .../example/catalog/catalog.ts | 238 ++++ spacetime-posthog-ts/example/package.json | 29 + .../example/public/index.html | 314 +++++ .../example/public/styles.css | 1159 +++++++++++++++++ .../example/scripts/test-economy.ts | 38 + spacetime-posthog-ts/example/server.ts | 279 ++++ .../example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/catalog.ts | 149 +++ .../example/spacetimedb/src/economy.ts | 246 ++++ .../example/spacetimedb/src/index.ts | 1006 ++++++++++++++ .../example/spacetimedb/src/recent.ts | 9 + .../example/spacetimedb/src/schema.ts | 331 +++++ .../example/spacetimedb/src/validation.ts | 26 + .../example/spacetimedb/src/views.ts | 169 +++ .../example/spacetimedb/tsconfig.json | 22 + spacetime-posthog-ts/example/src/app.ts | 1009 ++++++++++++++ .../src/module_bindings/buy_supply_reducer.ts | 16 + .../module_bindings/buy_upgrade_reducer.ts | 15 + .../cafe_analytics_summary_table.ts | 17 + .../src/module_bindings/cafe_config_table.ts | 20 + .../src/module_bindings/cafe_econ_table.ts | 28 + .../src/module_bindings/cafe_metrics_table.ts | 23 + .../module_bindings/cafe_products_table.ts | 24 + .../src/module_bindings/cafe_queue_table.ts | 25 + .../cafe_recent_activity_table.ts | 24 + .../cafe_recent_purchases_table.ts | 24 + .../cafe_recent_sessions_table.ts | 26 + .../module_bindings/cafe_scenarios_table.ts | 25 + .../module_bindings/cafe_variants_table.ts | 29 + .../flush_analytics_procedure.ts | 16 + .../example/src/module_bindings/index.ts | 327 +++++ .../module_bindings/init_session_reducer.ts | 13 + .../posthog/add_admin_identity_procedure.ts | 16 + .../posthog/capture_now_procedure.ts | 18 + .../posthog/enqueue_event_reducer.ts | 18 + .../posthog/flush_outbox_procedure.ts | 16 + .../posthog/get_feature_flag_procedure.ts | 19 + .../get_posthog_config_status_procedure.ts | 15 + .../posthog_delivery_log_admin_table.ts | 30 + .../posthog/posthog_outbox_admin_table.ts | 35 + .../remove_admin_identity_procedure.ts | 16 + .../posthog/set_posthog_config_procedure.ts | 17 + .../src/module_bindings/posthog/types.ts | 112 ++ .../posthog_delivery_log_admin_table.ts | 22 + .../posthog_outbox_admin_table.ts | 35 + .../reset_simulation_reducer.ts | 15 + .../select_scenario_reducer.ts | 15 + .../set_experiment_variant_reducer.ts | 16 + .../set_featured_variant_reducer.ts | 15 + .../set_product_active_reducer.ts | 16 + .../set_variant_active_reducer.ts | 16 + .../set_variant_discount_reducer.ts | 16 + .../set_variant_price_reducer.ts | 16 + .../module_bindings/simulate_tick_reducer.ts | 16 + .../module_bindings/sync_catalog_reducer.ts | 16 + .../example/src/module_bindings/types.ts | 273 ++++ .../src/module_bindings/types/procedures.ts | 13 + .../src/module_bindings/types/reducers.ts | 36 + spacetime-posthog-ts/example/tsconfig.json | 22 + spacetime-posthog-ts/package.json | 65 + spacetime-posthog-ts/scripts/test.ts | 92 ++ spacetime-posthog-ts/src/index.ts | 14 + spacetime-posthog-ts/src/submodule.ts | 26 + spacetime-posthog-ts/src/submodule/auth.ts | 58 + spacetime-posthog-ts/src/submodule/config.ts | 80 ++ spacetime-posthog-ts/src/submodule/http.ts | 45 + spacetime-posthog-ts/src/submodule/install.ts | 9 + .../src/submodule/operations.ts | 511 ++++++++ .../src/submodule/outbox-state.ts | 86 ++ spacetime-posthog-ts/src/submodule/schema.ts | 182 +++ .../src/submodule/validation.ts | 28 + spacetime-posthog-ts/tsconfig.json | 22 + 80 files changed, 8960 insertions(+) create mode 100644 spacetime-posthog-ts/.npmrc create mode 100644 spacetime-posthog-ts/LICENSE.txt create mode 100644 spacetime-posthog-ts/README.md create mode 100644 spacetime-posthog-ts/example/.env.example create mode 100644 spacetime-posthog-ts/example/.npmrc create mode 100644 spacetime-posthog-ts/example/README.md create mode 100644 spacetime-posthog-ts/example/catalog/catalog.ts create mode 100644 spacetime-posthog-ts/example/package.json create mode 100644 spacetime-posthog-ts/example/public/index.html create mode 100644 spacetime-posthog-ts/example/public/styles.css create mode 100644 spacetime-posthog-ts/example/scripts/test-economy.ts create mode 100644 spacetime-posthog-ts/example/server.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-posthog-ts/example/spacetimedb/package.json create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/catalog.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/economy.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/recent.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/schema.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/validation.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-posthog-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-posthog-ts/example/src/app.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/buy_supply_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/buy_upgrade_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_analytics_summary_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_config_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_econ_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_metrics_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_products_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_queue_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_recent_activity_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_recent_purchases_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_recent_sessions_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_scenarios_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/cafe_variants_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/flush_analytics_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/index.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/init_session_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/add_admin_identity_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/capture_now_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/enqueue_event_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/flush_outbox_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/get_feature_flag_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/get_posthog_config_status_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_delivery_log_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/posthog_outbox_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/remove_admin_identity_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/set_posthog_config_procedure.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog/types.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog_delivery_log_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/posthog_outbox_admin_table.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/reset_simulation_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/select_scenario_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_experiment_variant_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_featured_variant_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_product_active_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_variant_active_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_variant_discount_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/set_variant_price_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/simulate_tick_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/sync_catalog_reducer.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/types.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/types/procedures.ts create mode 100644 spacetime-posthog-ts/example/src/module_bindings/types/reducers.ts create mode 100644 spacetime-posthog-ts/example/tsconfig.json create mode 100644 spacetime-posthog-ts/package.json create mode 100644 spacetime-posthog-ts/scripts/test.ts create mode 100644 spacetime-posthog-ts/src/index.ts create mode 100644 spacetime-posthog-ts/src/submodule.ts create mode 100644 spacetime-posthog-ts/src/submodule/auth.ts create mode 100644 spacetime-posthog-ts/src/submodule/config.ts create mode 100644 spacetime-posthog-ts/src/submodule/http.ts create mode 100644 spacetime-posthog-ts/src/submodule/install.ts create mode 100644 spacetime-posthog-ts/src/submodule/operations.ts create mode 100644 spacetime-posthog-ts/src/submodule/outbox-state.ts create mode 100644 spacetime-posthog-ts/src/submodule/schema.ts create mode 100644 spacetime-posthog-ts/src/submodule/validation.ts create mode 100644 spacetime-posthog-ts/tsconfig.json 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..ea0cb1c5e9e --- /dev/null +++ b/spacetime-posthog-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-posthog-ts/README.md b/spacetime-posthog-ts/README.md new file mode 100644 index 00000000000..e369e56698e --- /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@^2.8.3 +``` + +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.enqueueEvent(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. + +**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 `enqueueEvent` after authorization. + +For host modules, import `@spacetimedb/posthog/submodule` and call `enqueueEvent(ctx.as.posthog, ...)` from reducers or `captureNow(ctx.as.posthog, ...)` / `flushOutbox(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.flushOutbox(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 + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. 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..04e5310263f --- /dev/null +++ b/spacetime-posthog-ts/example/README.md @@ -0,0 +1,166 @@ +# 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. +- The released SpacetimeDB 2.8 CLI. +- 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. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +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@^2.8.3 +``` + +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..dbfc42651dc --- /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 sync_catalog = 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..c5fbc321881 --- /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.enqueueEvent(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 init_session = 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 reset_simulation = 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 select_scenario = 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 set_product_active = 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 set_variant_active = 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 set_variant_price = 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 set_variant_discount = 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 set_featured_variant = 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 set_experiment_variant = 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 buy_supply = 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 buy_upgrade = 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 simulate_tick = 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..0c033ea381f --- /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 flush_analytics = spacetimedb.procedure( + { limit: t.u32() }, + t.string(), + (ctx, args) => + JSON.stringify(posthog.flushOutbox(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..9034afa0b84 --- /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.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* 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.8.3" 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..da73748f2b1 --- /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": "BUSL-1.1", + "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..a781f620c88 --- /dev/null +++ b/spacetime-posthog-ts/scripts/test.ts @@ -0,0 +1,92 @@ +import * as assert from 'node:assert/strict'; +import { + isOkStatus, + toStatusCode, + truncateForLog, +} 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 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'); + +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..58964a6825c --- /dev/null +++ b/spacetime-posthog-ts/src/index.ts @@ -0,0 +1,14 @@ +export { default, init } from './submodule/schema'; +export { + set_posthog_config, + get_posthog_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { + capture_now, + enqueue_event, + flush_outbox, + get_feature_flag, + 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..ccd9adbffa5 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule.ts @@ -0,0 +1,26 @@ +export { default } from './submodule/schema'; +export { + OutboxStatus, + posthogDeliveryLog, + posthogDeliveryStats, + posthogOutbox, + t, +} from './submodule/schema'; +export { installPostHog } from './submodule/install'; +export { + set_posthog_config, + get_posthog_config_status, +} from './submodule/config'; +export { add_admin_identity, remove_admin_identity } from './submodule/auth'; +export { + captureNow, + clearAnalytics, + enqueueEvent, + flushOutbox, + capture_now, + enqueue_event, + flush_outbox, + get_feature_flag, + 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..a30567d272d --- /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 add_admin_identity = 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 remove_admin_identity = 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..84818e8c5ae --- /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 set_posthog_config = 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 get_posthog_config_status = 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..04c695f7a0c --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/http.ts @@ -0,0 +1,45 @@ +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 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 = truncateForLog(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..a658abc2d76 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/operations.ts @@ -0,0 +1,511 @@ +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 { isOkStatus, posthogFetch, 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 enqueueEvent(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 +) { + ctx.db.posthogDeliveryLog.insert({ + deliveryId: 0n, + source, + outboxId, + distinctId: event.distinctId, + event: event.event, + ok: result.ok, + statusCode: result.statusCode, + responseBody: result.responseBody, + errorMessage: result.ok ? undefined : result.responseBody, + attemptedAt: ctx.timestamp, + attemptedAtOrder: -ctx.timestamp.microsSinceUnixEpoch, + }); + updateDeliveryStats(ctx, result.ok ? { delivered: 1n } : { failed: 1n }); +} + +export function captureNow( + 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 flushOutbox( + 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 enqueue_event = spacetimedb.reducer( + { + distinctId: t.string(), + event: t.string(), + propertiesJson: t.option(t.string()), + idempotencyKey: t.option(t.string()), + }, + (ctx, args) => { + requireAdmin(ctx, ctx.sender); + enqueueEvent(ctx, args); + } +); + +export const capture_now = 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(captureNow(ctx, args)); + } +); + +export const flush_outbox = spacetimedb.procedure( + { limit: t.u32() }, + t.string(), + (ctx, args) => JSON.stringify(flushOutbox(ctx, { limit: args.limit })) +); + +export const get_feature_flag = 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); + let valueJson: string | undefined; + if (isOkStatus(result.statusCode)) { + try { + const parsed = JSON.parse(result.responseBody) as Record< + string, + unknown + >; + const flags = parsed.featureFlags; + if (flags && typeof flags === 'object' && args.key in flags) { + valueJson = JSON.stringify( + (flags as Record)[args.key] + ); + } + } catch { + valueJson = 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..7330fc5e3c2 --- /dev/null +++ b/spacetime-posthog-ts/src/submodule/outbox-state.ts @@ -0,0 +1,86 @@ +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 : 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/**/*"] +}