diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6898ab6d6c..afe768a5152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,9 @@ jobs: - uses: dsherret/rust-toolchain-file@v1 - run: echo ::add-matcher::.github/workflows/rust_matcher.json + - name: Run bindgen tests + run: cargo test -p spacetimedb-codegen + # Make sure the `Cargo.lock` file reflects the latest available versions. # This is what users would end up with on a fresh module, so we want to # catch any compile errors arising from a different transitive closure @@ -180,8 +183,6 @@ jobs: - name: Build module-test run: cargo run -p spacetimedb-cli -- build --project-path modules/module-test - - name: Run bindgen tests - run: cargo test -p spacetimedb-codegen publish_checks: name: Check that packages are publishable diff --git a/Cargo.lock b/Cargo.lock index a5ac90b2aa4..53fbb9439c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -484,9 +484,9 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.9.0", "cexpr", @@ -730,6 +730,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "calendrical_calculations" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c5d386a9f2c8b97e1a036420bcf937db4e5c9df33eb0232025008ced6104c0" +dependencies = [ + "core_maths", + "displaydoc", +] + [[package]] name = "camino" version = "1.1.9" @@ -1099,6 +1109,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpp_demangle" version = "0.4.4" @@ -1610,6 +1629,38 @@ dependencies = [ "subtle", ] +[[package]] +name = "diplomat" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced081520ee8cf2b8c5b64a1a901eccd7030ece670dac274afe64607d6499b71" +dependencies = [ + "diplomat_core", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "diplomat-runtime" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "098f9520ec5c190943b083bca3ea4cc4e67dc5f85a37062e528ecf1d25f04eb4" + +[[package]] +name = "diplomat_core" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad619d9fdee0e731bb6f8f7d797b6ecfdc2395e363f554d2f6377155955171eb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "smallvec", + "strck", + "syn 2.0.101", +] + [[package]] name = "directories-next" version = "2.0.0" @@ -2668,6 +2719,29 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_calendar" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f6c40ba6481ed7ddd358437af0f000eb9f661345845977d9d1b38e606374e1f" +dependencies = [ + "calendrical_calculations", + "displaydoc", + "icu_calendar_data", + "icu_locale", + "icu_locale_core", + "icu_provider 2.0.0", + "tinystr 0.8.1", + "writeable 0.6.1", + "zerovec 0.11.4", +] + +[[package]] +name = "icu_calendar_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7219c8639ab936713a87b571eed2bc2615aa9137e8af6eb221446ee5644acc18" + [[package]] name = "icu_collections" version = "1.5.0" @@ -2675,11 +2749,59 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", - "yoke", + "yoke 0.7.5", "zerofrom", - "zerovec", + "zerovec 0.10.4", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke 0.8.0", + "zerofrom", + "zerovec 0.11.4", +] + +[[package]] +name = "icu_locale" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae5921528335e91da1b6c695dbf1ec37df5ac13faa3f91e5640be93aa2fbefd" +dependencies = [ + "displaydoc", + "icu_collections 2.0.0", + "icu_locale_core", + "icu_locale_data", + "icu_provider 2.0.0", + "potential_utf", + "tinystr 0.8.1", + "zerovec 0.11.4", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap 0.8.0", + "tinystr 0.8.1", + "writeable 0.6.1", + "zerovec 0.11.4", +] + +[[package]] +name = "icu_locale_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fdef0c124749d06a743c69e938350816554eb63ac979166590e2b4ee4252765" + [[package]] name = "icu_locid" version = "1.5.0" @@ -2687,10 +2809,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "litemap 0.7.5", + "tinystr 0.7.6", + "writeable 0.5.5", + "zerovec 0.10.4", ] [[package]] @@ -2702,9 +2824,9 @@ dependencies = [ "displaydoc", "icu_locid", "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", + "icu_provider 1.5.0", + "tinystr 0.7.6", + "zerovec 0.10.4", ] [[package]] @@ -2720,15 +2842,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ "displaydoc", - "icu_collections", + "icu_collections 1.5.0", "icu_normalizer_data", "icu_properties", - "icu_provider", + "icu_provider 1.5.0", "smallvec", "utf16_iter", "utf8_iter", "write16", - "zerovec", + "zerovec 0.10.4", ] [[package]] @@ -2744,12 +2866,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", - "icu_collections", + "icu_collections 1.5.0", "icu_locid_transform", "icu_properties_data", - "icu_provider", - "tinystr", - "zerovec", + "icu_provider 1.5.0", + "tinystr 0.7.6", + "zerovec 0.10.4", ] [[package]] @@ -2768,11 +2890,28 @@ dependencies = [ "icu_locid", "icu_provider_macros", "stable_deref_trait", - "tinystr", - "writeable", - "yoke", + "tinystr 0.7.6", + "writeable 0.5.5", + "yoke 0.7.5", "zerofrom", - "zerovec", + "zerovec 0.10.4", +] + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr 0.8.1", + "writeable 0.6.1", + "yoke 0.8.0", + "zerofrom", + "zerotrie", + "zerovec 0.11.4", ] [[package]] @@ -3004,6 +3143,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "ixdtf" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ef2d119044a672ceb96e59608dffe8677f29dc6ec48ed693a4b9ac84751e9b" +dependencies = [ + "displaydoc", +] + [[package]] name = "jemalloc_pprof" version = "0.8.1" @@ -3021,6 +3169,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "jiff-tzdb" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" + [[package]] name = "jni" version = "0.21.1" @@ -3220,6 +3374,12 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" version = "0.4.12" @@ -4103,6 +4263,16 @@ dependencies = [ "postgres-protocol", ] +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "serde", + "zerovec 0.11.4", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -5022,6 +5192,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "ry_temporal_capi" +version = "0.0.11-ry.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfddefd45ee4814bd83d94b7196c95ef6af159f4a0035a6c67bd59edcff14ee" +dependencies = [ + "diplomat", + "diplomat-runtime", + "icu_calendar", + "icu_locale", + "num-traits", + "temporal_rs", + "writeable 0.6.1", +] + [[package]] name = "ryu" version = "1.0.20" @@ -5798,6 +5983,7 @@ dependencies = [ "bytes", "bytestring", "chrono", + "convert_case 0.6.0", "core_affinity", "criterion", "crossbeam-channel", @@ -6635,6 +6821,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" +[[package]] +name = "strck" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42316e70da376f3d113a68d138a60d8a9883c604fe97942721ec2068dab13a9f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -6905,6 +7100,25 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "temporal_rs" +version = "0.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7807e330b12e288b847a3e2a2b0dcd41ca764d0f90f9e8940f02c6ddd68cd2d7" +dependencies = [ + "combine", + "core_maths", + "icu_calendar", + "icu_locale", + "ixdtf", + "jiff-tzdb", + "num-traits", + "timezone_provider", + "tinystr 0.8.1", + "tzif", + "writeable 0.6.1", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -7079,6 +7293,17 @@ dependencies = [ "time-core", ] +[[package]] +name = "timezone_provider" +version = "0.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f357f8e2cddee6a7b56b69fbb4cab30a7e82914c80ee7f9a5eb799ee3de3f24d" +dependencies = [ + "tinystr 0.8.1", + "zerotrie", + "zerovec 0.11.4", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -7086,7 +7311,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", - "zerovec", + "zerovec 0.10.4", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec 0.11.4", ] [[package]] @@ -7525,6 +7760,15 @@ dependencies = [ "spacetimedb 1.3.0", ] +[[package]] +name = "tzif" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e762ac355f0c204d09ae644b3d59423d5ddfc5603997d60c8c56f24e429a9d" +dependencies = [ + "combine", +] + [[package]] name = "unarray" version = "0.1.4" @@ -7665,17 +7909,18 @@ dependencies = [ [[package]] name = "v8" -version = "137.2.1" +version = "140.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca393e2032ddba2a57169e15cac5d0a81cdb3d872a8886f4468bc0f486098d2" +checksum = "8827809a2884fb68530d678a8ef15b1ed1344bbf844879194d68c140c6f844f9" dependencies = [ - "bindgen 0.71.1", + "bindgen 0.72.1", "bitflags 2.9.0", "fslock", "gzip-header", "home", "miniz_oxide", "paste", + "ry_temporal_capi", "which 6.0.3", ] @@ -8692,6 +8937,12 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "wyz" version = "0.5.1" @@ -8755,7 +9006,19 @@ checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", - "yoke-derive", + "yoke-derive 0.7.5", + "zerofrom", +] + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive 0.8.0", "zerofrom", ] @@ -8771,6 +9034,18 @@ dependencies = [ "synstructure 0.13.2", ] +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", + "synstructure 0.13.2", +] + [[package]] name = "zerocopy" version = "0.8.25" @@ -8832,15 +9107,35 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", +] + [[package]] name = "zerovec" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ - "yoke", + "yoke 0.7.5", + "zerofrom", + "zerovec-derive 0.10.3", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke 0.8.0", "zerofrom", - "zerovec-derive", + "zerovec-derive 0.11.1", ] [[package]] @@ -8854,6 +9149,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "zip" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index f84eff4df7a..446a3e35c9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -292,7 +292,7 @@ unicode-normalization = "0.1.23" url = "2.3.1" urlencoding = "2.1.2" uuid = { version = "1.2.1", features = ["v4"] } -v8 = "137.2" +v8 = "140.2" walkdir = "2.2.5" wasmbin = "0.6" webbrowser = "1.0.2" diff --git a/crates/bindings-csharp/Runtime/Exceptions.cs b/crates/bindings-csharp/Runtime/Exceptions.cs index e591e3a0c00..1488b00d91f 100644 --- a/crates/bindings-csharp/Runtime/Exceptions.cs +++ b/crates/bindings-csharp/Runtime/Exceptions.cs @@ -72,6 +72,11 @@ public class NoSpaceException : StdbException public override string Message => "The provided bytes sink has no more room left"; } +public class AutoIncOverflowException : StdbException +{ + public override string Message => "The auto-increment sequence overflowed"; +} + public class UnknownException : StdbException { private readonly Errno code; diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 2c0bf8b925f..dc877ba2d52 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -36,6 +36,7 @@ public enum Errno : short SCHEDULE_AT_DELAY_TOO_LONG = 13, INDEX_NOT_UNIQUE = 14, NO_SUCH_ROW = 15, + AUTO_INC_OVERFLOW = 16, } #pragma warning disable IDE1006 // Naming Styles - Not applicable to FFI stuff. @@ -96,6 +97,7 @@ public static CheckedStatus ConvertToManaged(Errno status) Errno.SCHEDULE_AT_DELAY_TOO_LONG => new ScheduleAtDelayTooLongException(), Errno.INDEX_NOT_UNIQUE => new IndexNotUniqueException(), Errno.NO_SUCH_ROW => new NoSuchRowException(), + Errno.AUTO_INC_OVERFLOW => new AutoIncOverflowException(), _ => new UnknownException(status), }; } diff --git a/crates/bindings-typescript/src/index.ts b/crates/bindings-typescript/src/index.ts index ff0a3862ea1..2e723498d9d 100644 --- a/crates/bindings-typescript/src/index.ts +++ b/crates/bindings-typescript/src/index.ts @@ -8,5 +8,6 @@ export * from './lib/time_duration'; export * from './lib/timestamp'; export * from './lib/utils'; export * from './lib/identity'; +export * from './lib/option'; export * from './sdk'; export { default as t } from './server/type_builders'; diff --git a/crates/bindings-typescript/src/lib/algebraic_type.ts b/crates/bindings-typescript/src/lib/algebraic_type.ts index 21b593efd88..9f25bb925d0 100644 --- a/crates/bindings-typescript/src/lib/algebraic_type.ts +++ b/crates/bindings-typescript/src/lib/algebraic_type.ts @@ -4,6 +4,7 @@ import { ConnectionId } from './connection_id'; import type BinaryReader from './binary_reader'; import BinaryWriter from './binary_writer'; import { Identity } from './identity'; +import { Option } from './option'; import { AlgebraicType as AlgebraicTypeType, AlgebraicType as AlgebraicTypeValue, @@ -54,6 +55,10 @@ export type AlgebraicType = AlgebraicTypeType; * Algebraic Type utilities. */ export const AlgebraicType: { + Sum(value: T): { tag: 'Sum'; value: T }; + Product(value: T): { tag: 'Product'; value: T }; + Array(value: T): { tag: 'Array'; value: T }; + createOptionType(innerType: AlgebraicTypeType): AlgebraicTypeType; createIdentityType(): AlgebraicTypeType; createConnectionIdType(): AlgebraicTypeType; @@ -72,16 +77,20 @@ export const AlgebraicType: { intoMapKey(ty: AlgebraicTypeType, value: any): ComparablePrimitive; } & typeof AlgebraicTypeValue = { ...AlgebraicTypeValue, + Sum: (value: T): { tag: 'Sum'; value: T } => ({ + tag: 'Sum', + value, + }), + Product: (value: T): { tag: 'Product'; value: T } => ({ + tag: 'Product', + value, + }), + Array: (value: T): { tag: 'Array'; value: T } => ({ + tag: 'Array', + value, + }), createOptionType: function (innerType: AlgebraicTypeType): AlgebraicTypeType { - return AlgebraicTypeValue.Sum({ - variants: [ - { name: 'some', algebraicType: innerType }, - { - name: 'none', - algebraicType: AlgebraicTypeValue.Product({ elements: [] }), - }, - ], - }); + return Option.getAlgebraicType(innerType); }, createIdentityType: function (): AlgebraicTypeType { return Identity.getAlgebraicType(); @@ -363,6 +372,8 @@ export const ProductType: { }, }; +export type SumType = SumTypeType; + /** * Unlike most languages, sums in SATS are *[structural]* and not nominal. * When checking whether two nominal types are the same, diff --git a/crates/bindings-typescript/src/lib/binary_reader.ts b/crates/bindings-typescript/src/lib/binary_reader.ts index f40f4101bca..5a7cba95393 100644 --- a/crates/bindings-typescript/src/lib/binary_reader.ts +++ b/crates/bindings-typescript/src/lib/binary_reader.ts @@ -1,118 +1,142 @@ export default class BinaryReader { - #buffer: DataView; + /** + * The DataView used to read values from the binary data. + * + * Note: The DataView's `byteOffset` is relative to the beginning of the + * underlying ArrayBuffer, not the start of the provided Uint8Array input. + * This `BinaryReader`'s `#offset` field is used to track the current read position + * relative to the start of the provided Uint8Array input. + */ + #view: DataView; + + /** + * Represents the offset (in bytes) relative to the start of the DataView + * and provided Uint8Array input. + * + * Note: This is *not* the absolute byte offset within the underlying ArrayBuffer. + */ #offset: number = 0; constructor(input: Uint8Array) { - this.#buffer = new DataView(input.buffer); - this.#offset = input.byteOffset; + this.#view = new DataView(input.buffer, input.byteOffset, input.byteLength); + this.#offset = 0; } get offset(): number { return this.#offset; } + get remaining(): number { + return this.#view.byteLength - this.#offset; + } + + /** Ensure we have at least `n` bytes left to read */ + #ensure(n: number): void { + if (this.#offset + n > this.#view.byteLength) { + throw new RangeError( + `Tried to read ${n} byte(s) at relative offset ${this.#offset}, but only ${this.remaining} byte(s) remain` + ); + } + } + readUInt8Array(): Uint8Array { const length = this.readU32(); - const value: Uint8Array = new Uint8Array( - this.#buffer.buffer, - this.#offset, - length - ); - this.#offset += length; - return value; + this.#ensure(length); + return this.readBytes(length); } readBool(): boolean { - const value = this.#buffer.getUint8(this.#offset); + const value = this.#view.getUint8(this.#offset); this.#offset += 1; return value !== 0; } readByte(): number { - const value = this.#buffer.getUint8(this.#offset); + const value = this.#view.getUint8(this.#offset); this.#offset += 1; return value; } readBytes(length: number): Uint8Array { - const value: DataView = new DataView( - this.#buffer.buffer, - this.#offset, + // Create a Uint8Array view over the DataView's buffer at the current offset + // The #view.buffer is the whole ArrayBuffer, so we need to account for the + // #view's starting position in that buffer (#view.byteOffset) and the current #offset + const array = new Uint8Array( + this.#view.buffer, + this.#view.byteOffset + this.#offset, length ); this.#offset += length; - return new Uint8Array(value.buffer); + return array; } readI8(): number { - const value = this.#buffer.getInt8(this.#offset); + const value = this.#view.getInt8(this.#offset); this.#offset += 1; return value; } readU8(): number { - const value = this.#buffer.getUint8(this.#offset); - this.#offset += 1; - return value; + return this.readByte(); } readI16(): number { - const value = this.#buffer.getInt16(this.#offset, true); + const value = this.#view.getInt16(this.#offset, true); this.#offset += 2; return value; } readU16(): number { - const value = this.#buffer.getUint16(this.#offset, true); + const value = this.#view.getUint16(this.#offset, true); this.#offset += 2; return value; } readI32(): number { - const value = this.#buffer.getInt32(this.#offset, true); + const value = this.#view.getInt32(this.#offset, true); this.#offset += 4; return value; } readU32(): number { - const value = this.#buffer.getUint32(this.#offset, true); + const value = this.#view.getUint32(this.#offset, true); this.#offset += 4; return value; } readI64(): bigint { - const value = this.#buffer.getBigInt64(this.#offset, true); + const value = this.#view.getBigInt64(this.#offset, true); this.#offset += 8; return value; } readU64(): bigint { - const value = this.#buffer.getBigUint64(this.#offset, true); + const value = this.#view.getBigUint64(this.#offset, true); this.#offset += 8; return value; } readU128(): bigint { - const lowerPart = this.#buffer.getBigUint64(this.#offset, true); - const upperPart = this.#buffer.getBigUint64(this.#offset + 8, true); + const lowerPart = this.#view.getBigUint64(this.#offset, true); + const upperPart = this.#view.getBigUint64(this.#offset + 8, true); this.#offset += 16; return (upperPart << BigInt(64)) + lowerPart; } readI128(): bigint { - const lowerPart = this.#buffer.getBigUint64(this.#offset, true); - const upperPart = this.#buffer.getBigInt64(this.#offset + 8, true); + const lowerPart = this.#view.getBigUint64(this.#offset, true); + const upperPart = this.#view.getBigInt64(this.#offset + 8, true); this.#offset += 16; return (upperPart << BigInt(64)) + lowerPart; } readU256(): bigint { - const p0 = this.#buffer.getBigUint64(this.#offset, true); - const p1 = this.#buffer.getBigUint64(this.#offset + 8, true); - const p2 = this.#buffer.getBigUint64(this.#offset + 16, true); - const p3 = this.#buffer.getBigUint64(this.#offset + 24, true); + const p0 = this.#view.getBigUint64(this.#offset, true); + const p1 = this.#view.getBigUint64(this.#offset + 8, true); + const p2 = this.#view.getBigUint64(this.#offset + 16, true); + const p3 = this.#view.getBigUint64(this.#offset + 24, true); this.#offset += 32; return ( @@ -124,10 +148,10 @@ export default class BinaryReader { } readI256(): bigint { - const p0 = this.#buffer.getBigUint64(this.#offset, true); - const p1 = this.#buffer.getBigUint64(this.#offset + 8, true); - const p2 = this.#buffer.getBigUint64(this.#offset + 16, true); - const p3 = this.#buffer.getBigInt64(this.#offset + 24, true); + const p0 = this.#view.getBigUint64(this.#offset, true); + const p1 = this.#view.getBigUint64(this.#offset + 8, true); + const p2 = this.#view.getBigUint64(this.#offset + 16, true); + const p3 = this.#view.getBigInt64(this.#offset + 24, true); this.#offset += 32; return ( @@ -139,27 +163,19 @@ export default class BinaryReader { } readF32(): number { - const value = this.#buffer.getFloat32(this.#offset, true); + const value = this.#view.getFloat32(this.#offset, true); this.#offset += 4; return value; } readF64(): number { - const value = this.#buffer.getFloat64(this.#offset, true); + const value = this.#view.getFloat64(this.#offset, true); this.#offset += 8; return value; } readString(): string { - const length = this.readU32(); - const uint8Array = new Uint8Array( - this.#buffer.buffer, - this.#offset, - length - ); - const decoder = new TextDecoder('utf-8'); - const value = decoder.decode(uint8Array); - this.#offset += length; - return value; + const uint8Array = this.readUInt8Array(); + return new TextDecoder('utf-8').decode(uint8Array); } } diff --git a/crates/bindings-typescript/src/lib/binary_writer.ts b/crates/bindings-typescript/src/lib/binary_writer.ts index 8a55a3ffd44..e9b8609893a 100644 --- a/crates/bindings-typescript/src/lib/binary_writer.ts +++ b/crates/bindings-typescript/src/lib/binary_writer.ts @@ -29,6 +29,10 @@ export default class BinaryWriter { return this.#buffer.slice(0, this.#offset); } + get offset(): number { + return this.#offset; + } + writeUInt8Array(value: Uint8Array): void { const length = value.length; diff --git a/crates/bindings-typescript/src/lib/connection_id.ts b/crates/bindings-typescript/src/lib/connection_id.ts index e8a981602c4..64da6361395 100644 --- a/crates/bindings-typescript/src/lib/connection_id.ts +++ b/crates/bindings-typescript/src/lib/connection_id.ts @@ -1,6 +1,13 @@ import { AlgebraicType } from './algebraic_type'; import { hexStringToU128, u128ToHexString, u128ToUint8Array } from './utils'; +export type ConnectionIdAlgebraicType = { + tag: 'Product'; + value: { + elements: [{ name: '__connection_id__'; algebraicType: { tag: 'U128' } }]; + }; +}; + /** * A unique identifier for a client connected to a database. */ @@ -18,7 +25,7 @@ export class ConnectionId { * Get the algebraic type representation of the {@link ConnectionId} type. * @returns The algebraic type representation of the type. */ - static getAlgebraicType(): AlgebraicType { + static getAlgebraicType(): ConnectionIdAlgebraicType { return AlgebraicType.Product({ elements: [ { name: '__connection_id__', algebraicType: AlgebraicType.U128 }, diff --git a/crates/bindings-typescript/src/lib/identity.ts b/crates/bindings-typescript/src/lib/identity.ts index e2e128ce74f..3df32986cf1 100644 --- a/crates/bindings-typescript/src/lib/identity.ts +++ b/crates/bindings-typescript/src/lib/identity.ts @@ -1,6 +1,13 @@ import { AlgebraicType } from './algebraic_type'; import { hexStringToU256, u256ToHexString, u256ToUint8Array } from './utils'; +export type IdentityAlgebraicType = { + tag: 'Product'; + value: { + elements: [{ name: '__identity__'; algebraicType: { tag: 'U256' } }]; + }; +}; + /** * A unique identifier for a user connected to a database. */ @@ -22,7 +29,7 @@ export class Identity { * Get the algebraic type representation of the {@link Identity} type. * @returns The algebraic type representation of the type. */ - static getAlgebraicType(): AlgebraicType { + static getAlgebraicType(): IdentityAlgebraicType { return AlgebraicType.Product({ elements: [{ name: '__identity__', algebraicType: AlgebraicType.U256 }], }); diff --git a/crates/bindings-typescript/src/lib/option.ts b/crates/bindings-typescript/src/lib/option.ts new file mode 100644 index 00000000000..c6f719a89fe --- /dev/null +++ b/crates/bindings-typescript/src/lib/option.ts @@ -0,0 +1,30 @@ +import { AlgebraicType } from './algebraic_type'; + +export type OptionAlgebraicType = { + tag: 'Sum'; + value: { + variants: [ + { name: 'some'; algebraicType: AlgebraicType }, + { + name: 'none'; + algebraicType: { tag: 'Product'; value: { elements: [] } }; + }, + ]; + }; +}; + +export const Option: { + getAlgebraicType(innerType: AlgebraicType): OptionAlgebraicType; +} = { + getAlgebraicType(innerType: AlgebraicType): OptionAlgebraicType { + return AlgebraicType.Sum({ + variants: [ + { name: 'some', algebraicType: innerType }, + { + name: 'none', + algebraicType: AlgebraicType.Product({ elements: [] }), + }, + ], + }); + }, +}; diff --git a/crates/bindings-typescript/src/lib/schedule_at.ts b/crates/bindings-typescript/src/lib/schedule_at.ts index cbb9fae7b23..4fb185e7ea2 100644 --- a/crates/bindings-typescript/src/lib/schedule_at.ts +++ b/crates/bindings-typescript/src/lib/schedule_at.ts @@ -1,22 +1,42 @@ import { AlgebraicType } from './algebraic_type'; -import { TimeDuration } from './time_duration'; -import { Timestamp } from './timestamp'; +import { TimeDuration, type TimeDurationAlgebraicType } from './time_duration'; +import { Timestamp, type TimestampAlgebraicType } from './timestamp'; + +export type ScheduleAtAlgebraicType = { + tag: 'Sum'; + value: { + variants: [ + { name: 'Interval'; algebraicType: TimeDurationAlgebraicType }, + { name: 'Time'; algebraicType: TimestampAlgebraicType }, + ]; + }; +}; + +type ScheduleAtType = Interval | Time; export const ScheduleAt: { + interval: (micros: bigint) => ScheduleAtType; + time: (microsSinceUnixEpoch: bigint) => ScheduleAtType; /** * Get the algebraic type representation of the {@link ScheduleAt} type. * @returns The algebraic type representation of the type. */ - getAlgebraicType(): AlgebraicType; + getAlgebraicType(): ScheduleAtAlgebraicType; } = { - getAlgebraicType(): AlgebraicType { + interval(value: bigint): ScheduleAtType { + return Interval(value); + }, + time(value: bigint): ScheduleAtType { + return Time(value); + }, + getAlgebraicType(): ScheduleAtAlgebraicType { return AlgebraicType.Sum({ variants: [ { name: 'Interval', - algebraicType: AlgebraicType.createTimeDurationType(), + algebraicType: TimeDuration.getAlgebraicType(), }, - { name: 'Time', algebraicType: AlgebraicType.createTimestampType() }, + { name: 'Time', algebraicType: Timestamp.getAlgebraicType() }, ], }); }, @@ -26,18 +46,18 @@ export type Interval = { tag: 'Interval'; value: TimeDuration; }; -export const Interval = (value: bigint): Interval => ({ +export const Interval = (micros: bigint): Interval => ({ tag: 'Interval', - value: new TimeDuration(value), + value: new TimeDuration(micros), }); export type Time = { tag: 'Time'; value: Timestamp; }; -export const Time = (value: bigint): Time => ({ +export const Time = (microsSinceUnixEpoch: bigint): Time => ({ tag: 'Time', - value: new Timestamp(value), + value: new Timestamp(microsSinceUnixEpoch), }); -export type ScheduleAt = Interval | Time; export default ScheduleAt; +export type ScheduleAt = ScheduleAtType; diff --git a/crates/bindings-typescript/src/lib/time_duration.ts b/crates/bindings-typescript/src/lib/time_duration.ts index 478dc329264..2c2d370d6cd 100644 --- a/crates/bindings-typescript/src/lib/time_duration.ts +++ b/crates/bindings-typescript/src/lib/time_duration.ts @@ -1,5 +1,14 @@ import { AlgebraicType } from './algebraic_type'; +export type TimeDurationAlgebraicType = { + tag: 'Product'; + value: { + elements: [ + { name: '__time_duration_micros__'; algebraicType: { tag: 'I64' } }, + ]; + }; +}; + /** * A difference between two points in time, represented as a number of microseconds. */ @@ -12,7 +21,7 @@ export class TimeDuration { * Get the algebraic type representation of the {@link TimeDuration} type. * @returns The algebraic type representation of the type. */ - static getAlgebraicType(): AlgebraicType { + static getAlgebraicType(): TimeDurationAlgebraicType { return AlgebraicType.Product({ elements: [ { diff --git a/crates/bindings-typescript/src/lib/timestamp.ts b/crates/bindings-typescript/src/lib/timestamp.ts index 4f3081824c3..e97fca90de8 100644 --- a/crates/bindings-typescript/src/lib/timestamp.ts +++ b/crates/bindings-typescript/src/lib/timestamp.ts @@ -1,4 +1,17 @@ import { AlgebraicType } from './algebraic_type'; +import { TimeDuration } from './time_duration'; + +export type TimestampAlgebraicType = { + tag: 'Product'; + value: { + elements: [ + { + name: '__timestamp_micros_since_unix_epoch__'; + algebraicType: { tag: 'I64' }; + }, + ]; + }; +}; /** * A point in time, represented as a number of microseconds since the Unix epoch. @@ -20,7 +33,7 @@ export class Timestamp { * Get the algebraic type representation of the {@link Timestamp} type. * @returns The algebraic type representation of the type. */ - static getAlgebraicType(): AlgebraicType { + static getAlgebraicType(): TimestampAlgebraicType { return AlgebraicType.Product({ elements: [ { @@ -71,4 +84,11 @@ export class Timestamp { } return new Date(Number(millis)); } + + since(other: Timestamp): TimeDuration { + return new TimeDuration( + this.__timestamp_micros_since_unix_epoch__ - + other.__timestamp_micros_since_unix_epoch__ + ); + } } diff --git a/crates/bindings-typescript/src/sdk/db_connection_impl.ts b/crates/bindings-typescript/src/sdk/db_connection_impl.ts index f483848c83c..b8a2639c705 100644 --- a/crates/bindings-typescript/src/sdk/db_connection_impl.ts +++ b/crates/bindings-typescript/src/sdk/db_connection_impl.ts @@ -302,8 +302,7 @@ export class DbConnectionImpl< const rowType = this.#remoteModule.tables[tableName]!.rowType; const primaryKeyInfo = this.#remoteModule.tables[tableName]!.primaryKeyInfo; - while (reader.offset < buffer.length + buffer.byteOffset) { - const initialOffset = reader.offset; + while (reader.remaining > 0) { const row = AlgebraicType.deserializeValue(reader, rowType); let rowId: ComparablePrimitive | undefined = undefined; if (primaryKeyInfo !== undefined) { @@ -313,10 +312,7 @@ export class DbConnectionImpl< ); } else { // Get a view of the bytes for this row. - const rowBytes = buffer.subarray( - initialOffset - buffer.byteOffset, - reader.offset - buffer.byteOffset - ); + const rowBytes = buffer.subarray(0, reader.offset); // Convert it to a base64 string, so we can use it as a map key. const asBase64 = fromByteArray(rowBytes); rowId = asBase64; diff --git a/crates/bindings-typescript/src/server/constraints.ts b/crates/bindings-typescript/src/server/constraints.ts new file mode 100644 index 00000000000..4301137281f --- /dev/null +++ b/crates/bindings-typescript/src/server/constraints.ts @@ -0,0 +1,39 @@ +import type { UntypedTableDef } from './table'; +import type { ColumnMetadata } from './type_builders'; + +/** + * A helper type to determine if all columns in an index are unique. + */ +export type AllUnique< + TableDef extends UntypedTableDef, + Columns extends Array, +> = { + [i in keyof Columns]: ColumnIsUnique< + TableDef['columns'][Columns[i]]['columnMetadata'] + >; +} extends true[] + ? true + : false; + +/** + * A helper type to determine if a column is unique based on its metadata. + * A column is considered unique if it has either `isUnique` or `isPrimaryKey` set to true in its metadata. + * @template M - The column metadata to check. + * @returns `true` if the column is unique, otherwise `false`. + * @example + * ```typescript + * type Meta1 = { isUnique: true }; + * type Meta2 = { isPrimaryKey: true }; + * type Meta3 = { isUnique: false }; + * type Meta4 = {}; + * type Result1 = ColumnIsUnique; // true + * type Result2 = ColumnIsUnique; // true + * type Result3 = ColumnIsUnique; // false + * type Result4 = ColumnIsUnique; // false + * ``` + */ +export type ColumnIsUnique> = M extends + | { isUnique: true } + | { isPrimaryKey: true } + ? true + : false; diff --git a/crates/bindings-typescript/src/server/errors.ts b/crates/bindings-typescript/src/server/errors.ts new file mode 100644 index 00000000000..c3029d5e9c4 --- /dev/null +++ b/crates/bindings-typescript/src/server/errors.ts @@ -0,0 +1,252 @@ +import type { CheckAnyMetadata, UntypedTableDef } from './table'; + +/** + * Base class for all Spacetime errors. + * Each subclass must define a static CODE and MESSAGE property. + * Instances of SpacetimeError can be created with just an error code, + * which will return the appropriate subclass instance. + */ +export class SpacetimeError { + public readonly code: number; + public readonly message: string; + constructor(code: number) { + const proto = Object.getPrototypeOf(this); + let cls; + if (errorProtoypes.has(proto)) { + cls = proto.constructor; + if (code !== cls.CODE) + throw new TypeError(`invalid error code for ${cls.name}`); + } else if (proto === SpacetimeError.prototype) { + cls = errnoToClass.get(code); + if (!cls) throw new RangeError(`unknown error code ${code}`); + } else { + throw new TypeError('cannot subclass SpacetimeError'); + } + Object.setPrototypeOf(this, cls.prototype); + this.code = cls.CODE; + this.message = cls.MESSAGE; + } +} + +/** + * A generic error class for unknown error codes. + */ +export class HostCallFailure extends SpacetimeError { + static CODE = 1; + static MESSAGE = 'ABI called by host returned an error'; + constructor() { + super(HostCallFailure.CODE); + } +} + +/** + * Error indicating that an ABI call was made outside of a transaction. + */ +export class NotInTransaction extends SpacetimeError { + static CODE = 2; + static MESSAGE = 'ABI call can only be made while in a transaction'; + constructor() { + super(NotInTransaction.CODE); + } +} + +/** + * Error indicating that BSATN decoding failed. + * This typically means that the data could not be decoded to the expected type. + */ +export class BsatnDecodeError extends SpacetimeError { + static CODE = 3; + static MESSAGE = "Couldn't decode the BSATN to the expected type"; + constructor() { + super(BsatnDecodeError.CODE); + } +} + +/** + * Error indicating that a specified table does not exist. + */ +export class NoSuchTable extends SpacetimeError { + static CODE = 4; + static MESSAGE = 'No such table'; + constructor() { + super(NoSuchTable.CODE); + } +} + +/** + * Error indicating that a specified index does not exist. + */ +export class NoSuchIndex extends SpacetimeError { + static CODE = 5; + static MESSAGE = 'No such index'; + constructor() { + super(NoSuchIndex.CODE); + } +} + +/** + * Error indicating that a specified row iterator is not valid. + */ +export class NoSuchIter extends SpacetimeError { + static CODE = 6; + static MESSAGE = 'The provided row iterator is not valid'; + constructor() { + super(NoSuchIter.CODE); + } +} + +/** + * Error indicating that a specified console timer does not exist. + */ +export class NoSuchConsoleTimer extends SpacetimeError { + static CODE = 7; + static MESSAGE = 'The provided console timer does not exist'; + constructor() { + super(NoSuchConsoleTimer.CODE); + } +} + +/** + * Error indicating that a specified bytes source or sink is not valid. + */ +export class NoSuchBytes extends SpacetimeError { + static CODE = 8; + static MESSAGE = 'The provided bytes source or sink is not valid'; + constructor() { + super(NoSuchBytes.CODE); + } +} + +/** + * Error indicating that a provided sink has no more space left. + */ +export class NoSpace extends SpacetimeError { + static CODE = 9; + static MESSAGE = 'The provided sink has no more space left'; + constructor() { + super(NoSpace.CODE); + } +} + +/** + * Error indicating that there is no more space in the database. + */ +export class BufferTooSmall extends SpacetimeError { + static CODE = 11; + static MESSAGE = 'The provided buffer is not large enough to store the data'; + constructor() { + super(BufferTooSmall.CODE); + } +} + +/** + * Error indicating that a value with a given unique identifier already exists. + */ +export class UniqueAlreadyExists extends SpacetimeError { + static CODE = 12; + static MESSAGE = 'Value with given unique identifier already exists'; + constructor() { + super(UniqueAlreadyExists.CODE); + } +} + +/** + * Error indicating that the specified delay in scheduling a row was too long. + */ +export class ScheduleAtDelayTooLong extends SpacetimeError { + static CODE = 13; + static MESSAGE = 'Specified delay in scheduling row was too long'; + constructor() { + super(ScheduleAtDelayTooLong.CODE); + } +} + +/** + * Error indicating that an index was not unique when it was expected to be. + */ +export class IndexNotUnique extends SpacetimeError { + static CODE = 14; + static MESSAGE = 'The index was not unique'; + constructor() { + super(IndexNotUnique.CODE); + } +} + +/** + * Error indicating that an index was not unique when it was expected to be. + */ +export class NoSuchRow extends SpacetimeError { + static CODE = 15; + static MESSAGE = 'The row was not found, e.g., in an update call'; + constructor() { + super(NoSuchRow.CODE); + } +} + +/** + * Error indicating that an auto-increment sequence has overflowed. + */ +export class AutoIncOverflow extends SpacetimeError { + static CODE = 16; + static MESSAGE = 'The auto-increment sequence overflowed'; + constructor() { + super(AutoIncOverflow.CODE); + } +} + +/** + * List of all SpacetimeError subclasses. + */ +const errorSubclasses = [ + HostCallFailure, + NotInTransaction, + BsatnDecodeError, + NoSuchTable, + NoSuchIndex, + NoSuchIter, + NoSuchConsoleTimer, + NoSuchBytes, + NoSpace, + BufferTooSmall, + UniqueAlreadyExists, + ScheduleAtDelayTooLong, + IndexNotUnique, + NoSuchRow, +]; + +/** + * Set of prototypes of all SpacetimeError subclasses for quick lookup. + */ +const errorProtoypes = new Set(errorSubclasses.map(cls => cls.prototype)); + +/** + * Map from error codes to their corresponding SpacetimeError subclass. + */ +const errnoToClass = new Map( + errorSubclasses.map(cls => [cls.CODE as number, cls]) +); + +/** + * A type representing errors that can occur during an insert operation. + * - `UniqueAlreadyExists`: Error indicating that a unique constraint was violated during the insert. + * - `AutoIncOverflow`: Error indicating that an auto-increment field has overflowed its maximum value. + * @template TableDef - The table definition used to determine which errors are applicable. + * @example + * ```typescript + * // Example of handling insert errors + * const result = table.tryInsert({ id: 1, name: 'Alice' }); + * if (!result.ok) { + * if (result.err instanceof UniqueAlreadyExists) { + * console.error('Unique constraint violated:', result.err.message); + * } else if (result.err instanceof AutoIncOverflow) { + * console.error('Auto-increment overflow:', result.err.message); + * } + * ``` + */ +export type TryInsertError = + | CheckAnyMetadata< + TableDef, + { isUnique: true } | { isPrimaryKey: true }, + UniqueAlreadyExists + > + | CheckAnyMetadata; diff --git a/crates/bindings-typescript/src/server/index.ts b/crates/bindings-typescript/src/server/index.ts index 2c4f2d7bedd..cab69c07e6b 100644 --- a/crates/bindings-typescript/src/server/index.ts +++ b/crates/bindings-typescript/src/server/index.ts @@ -1,2 +1,6 @@ export * from './type_builders'; export * from './type_util'; +export { schema } from './schema'; +export { table } from './table'; + +import './runtime'; diff --git a/crates/bindings-typescript/src/server/indexes.ts b/crates/bindings-typescript/src/server/indexes.ts new file mode 100644 index 00000000000..7af552012f6 --- /dev/null +++ b/crates/bindings-typescript/src/server/indexes.ts @@ -0,0 +1,153 @@ +import type { RowType, UntypedTableDef } from './table'; +import type { ColumnMetadata, IndexTypes } from './type_builders'; +import type { CollapseTuple, Prettify } from './type_util'; +import { Range } from './range'; +import type { ColumnIsUnique } from './constraints'; + +/** + * Index helper type used *inside* {@link table} to enforce that only + * existing column names are referenced. + */ +export type IndexOpts = { + name?: string; +} & ( + | { algorithm: 'btree'; columns: readonly AllowedCol[] } + | { algorithm: 'direct'; column: AllowedCol } +); + +/** + * An untyped representation of an index definition. + */ +type UntypedIndex = { + name: string; + unique: boolean; + algorithm: 'btree' | 'direct'; + columns: AllowedCol[]; +}; + +/** + * A helper type to extract the column names from an index definition. + */ +export type IndexColumns> = I extends { + columns: string[]; +} + ? I['columns'] + : I extends { column: string } + ? [I['column']] + : never; + +/** + * A type representing the indexes defined on a table. + */ +export type Indexes< + TableDef extends UntypedTableDef, + I extends Record>, +> = { + [k in keyof I]: Index; +}; + +/** + * A type representing a database index, which can be either unique or ranged. + */ +export type Index< + TableDef extends UntypedTableDef, + I extends UntypedIndex, +> = I['unique'] extends true + ? UniqueIndex + : RangedIndex; + +/** + * A type representing a unique index on a database table. + * Unique indexes enforce that the indexed columns contain unique values. + */ +export type UniqueIndex< + TableDef extends UntypedTableDef, + I extends UntypedIndex, +> = { + find(col_val: IndexVal): RowType | null; + delete(col_val: IndexVal): boolean; + update(col_val: RowType): RowType; +}; + +/** + * A type representing a ranged index on a database table. + * Ranged indexes allow for range queries on the indexed columns. + */ +export type RangedIndex< + TableDef extends UntypedTableDef, + I extends UntypedIndex, +> = { + filter( + range: IndexScanRangeBounds + ): IterableIterator>; + delete(range: IndexScanRangeBounds): number; +}; + +/** + * A helper type to extract the value type of an index based on the table definition and index definition. + * This type constructs a tuple of the types of the columns that make up the index. + */ +export type IndexVal< + TableDef extends UntypedTableDef, + I extends UntypedIndex, +> = CollapseTuple<_IndexVal>; + +/** + * A helper type to extract the types of the columns that make up an index. + */ +type _IndexVal = { + [i in keyof Columns]: TableDef['columns'][Columns[i]]['typeBuilder']['type']; +}; + +/** + * A helper type to define the bounds for scanning an index. + * This type allows for specifying exact values or ranges for each column in the index. + * It supports omitting trailing columns if the index is multi-column. + */ +export type IndexScanRangeBounds< + TableDef extends UntypedTableDef, + I extends UntypedIndex, +> = _IndexScanRangeBounds<_IndexVal>; + +/** + * A helper type to define the bounds for scanning an index. + * This type allows for specifying exact values or ranges for each column in the index. + * It supports omitting trailing columns if the index is multi-column. + * This version only allows omitting the array if the index is single-column to avoid ambiguity. + */ +type _IndexScanRangeBounds = Columns extends [infer Term] + ? Term | Range + : _IndexScanRangeBoundsCase; + +/** + * A helper type to define the bounds for scanning an index. + * This type allows for specifying exact values or ranges for each column in the index. + * It supports omitting trailing columns if the index is multi-column. + */ +type _IndexScanRangeBoundsCase = Columns extends [ + ...infer Prefix, + infer Term, +] + ? [...Prefix, Term | Range] | _IndexScanRangeBounds + : never; + +/** + * A helper type representing a column index definition. + */ +export type ColumnIndex< + Name extends string, + M extends ColumnMetadata, +> = Prettify< + { + name: Name; + unique: ColumnIsUnique; + columns: [Name]; + algorithm: 'btree' | 'direct'; + } & (M extends { + indexType: infer I extends NonNullable; + } + ? { algorithm: I } + : ColumnIsUnique extends true + ? { algorithm: 'btree' } + : never) +>; diff --git a/crates/bindings-typescript/src/server/range.ts b/crates/bindings-typescript/src/server/range.ts new file mode 100644 index 00000000000..38c3277ea41 --- /dev/null +++ b/crates/bindings-typescript/src/server/range.ts @@ -0,0 +1,53 @@ +/** + * A class representing a range with optional lower and upper bounds. + * This class is used to specify ranges for index scans in SpacetimeDB. + * + * The range can be defined with inclusive or exclusive bounds, or can be unbounded on either side. + * @template T - The type of the values in the range. + * @example + * ```typescript + * // Create a range from 10 (inclusive) to 20 (exclusive) + * const range = new Range( + * { tag: 'included', value: 10 }, + * { tag: 'excluded', value: 20 } + * ); + * // Create an unbounded range + * const unboundedRange = new Range(); + * ``` + */ +export class Range { + #from: Bound; + #to: Bound; + public constructor(from?: Bound | null, to?: Bound | null) { + this.#from = from ?? { tag: 'unbounded' }; + this.#to = to ?? { tag: 'unbounded' }; + } + + public get from(): Bound { + return this.#from; + } + public get to(): Bound { + return this.#to; + } +} + +/** + * A type representing a bound in a range, which can be inclusive, exclusive, or unbounded. + * - `included`: The bound is inclusive, meaning the value is part of the range. + * - `excluded`: The bound is exclusive, meaning the value is not part of the range. + * - `unbounded`: The bound is unbounded, meaning there is no limit in that direction. + * @template T - The type of the value for the bound. + * @example + * ```typescript + * // Inclusive bound + * const inclusiveBound: Bound = { tag: 'included', value: 10 }; + * // Exclusive bound + * const exclusiveBound: Bound = { tag: 'excluded', value: 20 }; + * // Unbounded bound + * const unbounded: Bound = { tag: 'unbounded' }; + * ``` + */ +export type Bound = + | { tag: 'included'; value: T } + | { tag: 'excluded'; value: T } + | { tag: 'unbounded' }; diff --git a/crates/bindings-typescript/src/server/reducers.ts b/crates/bindings-typescript/src/server/reducers.ts new file mode 100644 index 00000000000..423abd01638 --- /dev/null +++ b/crates/bindings-typescript/src/server/reducers.ts @@ -0,0 +1,175 @@ +import Lifecycle from '../lib/autogen/lifecycle_type'; +import type { ConnectionId } from '../lib/connection_id'; +import type { Identity } from '../lib/identity'; +import type { Timestamp } from '../lib/timestamp'; +import { pushReducer } from './runtime'; +import type { UntypedSchemaDef } from './schema'; +import type { Table } from './table'; +import type { InferTypeOfRow, RowObj, TypeBuilder } from './type_builders'; + +/** + * Helper to extract the parameter types from an object type + */ +export type ParamsObj = Record>; + +/** + * Helper to convert a ParamsObj or RowObj into an object type + */ +type ParamsAsObject = + InferTypeOfRow; + +/** + * Defines a SpacetimeDB reducer function. + * Reducers are the primary way to modify the state of your SpacetimeDB application. + * They are atomic, meaning that either all operations within a reducer succeed, + * or none of them do. + * @template S - The inferred schema type of the SpacetimeDB module. + * @template Params - The type of the parameters object expected by the reducer. + * @param ctx - The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. + * @param payload - An object containing the arguments passed to the reducer, typed according to `params`. + * @example + * ```typescript + * // Define a reducer named 'create_user' that takes 'username' (string) and 'email' (string) + * reducer( + * 'create_user', + * { + * username: t.string(), + * email: t.string(), + * }, + * (ctx, { username, email }) => { + * // Access the 'user' table from the database view in the context + * ctx.db.user.insert({ username, email, created_at: ctx.timestamp }); + * console.log(`User ${username} created by ${ctx.sender.identityId}`); + * } + * ); + * ``` + */ +export type Reducer = ( + ctx: ReducerCtx, + payload: ParamsAsObject +) => void; + +/** + * A type representing the database view, mapping table names to their corresponding Table handles. + */ +export type DbView = { + readonly [Tbl in SchemaDef['tables'][number] as Tbl['name']]: Table; +}; + +/** + * Reducer context parametrized by the inferred Schema + */ +export type ReducerCtx = Readonly<{ + sender: Identity; + identity: Identity; + timestamp: Timestamp; + connection_id: ConnectionId | null; + db: DbView; +}>; + +/** + * Defines a SpacetimeDB reducer function. + * + * Reducers are the primary way to modify the state of your SpacetimeDB application. + * They are atomic, meaning that either all operations within a reducer succeed, + * or none of them do. + * + * @template S - The inferred schema type of the SpacetimeDB module. + * @template Params - The type of the parameters object expected by the reducer. + * + * @param {string} name - The name of the reducer. This name will be used to call the reducer from clients. + * @param {Params} params - An object defining the parameters that the reducer accepts. + * Each key-value pair represents a parameter name and its corresponding + * {@link TypeBuilder} or {@link ColumnBuilder}. + * @param {(ctx: ReducerCtx, payload: ParamsAsObject) => void} fn - The reducer function itself. + * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. + * - `payload`: An object containing the arguments passed to the reducer, typed according to `params`. + * + * @example + * ```typescript + * // Define a reducer named 'create_user' that takes 'username' (string) and 'email' (string) + * reducer( + * 'create_user', + * { + * username: t.string(), + * email: t.string(), + * }, + * (ctx, { username, email }) => { + * // Access the 'user' table from the database view in the context + * ctx.db.user.insert({ username, email, created_at: ctx.timestamp }); + * console.log(`User ${username} created by ${ctx.sender.identityId}`); + * } + * ); + * ``` + */ +export function reducer< + S extends UntypedSchemaDef, + Params extends ParamsObj | RowObj, +>( + name: string, + params: Params, + fn: (ctx: ReducerCtx, payload: ParamsAsObject) => void +): void { + pushReducer(name, params, fn); +} + +/** + * Registers an initialization reducer that runs when the SpacetimeDB module is published + * for the first time. + * This function is useful to set up any initial state of your database that is guaranteed + * to run only once, and before any other reducers or client connections. + * @template S - The inferred schema type of the SpacetimeDB module. + * @template Params - The type of the parameters object expected by the initialization reducer. + * + * @param params - The parameters object defining the expected input for the initialization reducer. + * @param fn - The initialization reducer function. + * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. + */ +export function init( + params: Params, + fn: Reducer +): void { + pushReducer('init', params, fn, Lifecycle.Init); +} + +/** + * Registers a reducer to be called when a client connects to the SpacetimeDB module. + * This function allows you to define custom logic that should execute + * whenever a new client establishes a connection. + * @template S - The inferred schema type of the SpacetimeDB module. + * @template Params - The type of the parameters object expected by the connection reducer. + * @param params - The parameters object defining the expected input for the connection reducer. + * @param fn - The connection reducer function itself. + */ +export function clientConnected< + S extends UntypedSchemaDef, + Params extends ParamsObj, +>(params: Params, fn: Reducer): void { + pushReducer('on_connect', params, fn, Lifecycle.OnConnect); +} + +/** + * Registers a reducer to be called when a client disconnects from the SpacetimeDB module. + * This function allows you to define custom logic that should execute + * whenever a client disconnects. + * + * @template S - The inferred schema type of the SpacetimeDB module. + * @template Params - The type of the parameters object expected by the disconnection reducer. + * @param params - The parameters object defining the expected input for the disconnection reducer. + * @param fn - The disconnection reducer function itself. + * @example + * ```typescript + * spacetime.clientDisconnected( + * { reason: t.string() }, + * (ctx, { reason }) => { + * console.log(`Client ${ctx.connection_id} disconnected: ${reason}`); + * } + * ); + * ``` + */ +export function clientDisconnected< + S extends UntypedSchemaDef, + Params extends ParamsObj, +>(params: Params, fn: Reducer): void { + pushReducer('on_disconnect', params, fn, Lifecycle.OnDisconnect); +} diff --git a/crates/bindings-typescript/src/server/runtime.ts b/crates/bindings-typescript/src/server/runtime.ts new file mode 100644 index 00000000000..8c520c28c56 --- /dev/null +++ b/crates/bindings-typescript/src/server/runtime.ts @@ -0,0 +1,652 @@ +import type { Reducer } from 'react'; +import { AlgebraicType, ProductType } from '../lib/algebraic_type'; +import RawModuleDef from '../lib/autogen/raw_module_def_type'; +import type RawModuleDefV9 from '../lib/autogen/raw_module_def_v_9_type'; +import type RawReducerDefV9 from '../lib/autogen/raw_reducer_def_v_9_type'; +import type RawTableDefV9 from '../lib/autogen/raw_table_def_v_9_type'; +import type Typespace from '../lib/autogen/typespace_type'; +import { ConnectionId } from '../lib/connection_id'; +import { Identity } from '../lib/identity'; +import { Timestamp } from '../lib/timestamp'; +import { BinaryReader, BinaryWriter } from '../sdk'; +import { AutoIncOverflow, SpacetimeError, UniqueAlreadyExists } from './errors'; +import { Range, type Bound } from './range'; +import { + type Index, + type IndexVal, + type UniqueIndex, + type RangedIndex, +} from './indexes'; +import { type RowType, type Table, type TableMethods } from './table'; +import { type DbView, type ReducerCtx } from './reducers'; +import type { RowObj } from './type_builders'; + +/** + * The global module definition that gets populated by calls to `reducer()` and lifecycle hooks. + */ +export const MODULE_DEF: RawModuleDefV9 = { + typespace: { types: [] }, + tables: [], + reducers: [], + types: [], + miscExports: [], + rowLevelSecurity: [], +}; + +/** + * internal: pushReducer() helper used by reducer() and lifecycle wrappers + * + * @param name - The name of the reducer. + * @param params - The parameters for the reducer. + * @param fn - The reducer function. + * @param lifecycle - Optional lifecycle hooks for the reducer. + */ +export function pushReducer( + name: string, + params: RowObj, + fn: Reducer, + lifecycle?: RawReducerDefV9['lifecycle'] +): void { + const paramType: ProductType = { + elements: Object.entries(params).map(([n, c]) => ({ + name: n, + algebraicType: ('typeBuilder' in c ? c.typeBuilder : c).algebraicType, + })), + }; + + MODULE_DEF.reducers.push({ + name, + params: paramType, + lifecycle, // <- lifecycle flag lands here + }); + + REDUCERS.push(fn); +} + +const REDUCERS: Reducer[] = []; + +type u8 = number; +type u16 = number; +type u32 = number; +type u64 = bigint; +type u128 = bigint; +type u256 = bigint; + +declare global { + function table_id_from_name(name: string): u32; + function index_id_from_name(name: string): u32; + function datastore_table_row_count(table_id: u32): u64; + function datastore_table_scan_bsatn(table_id: u32): u32; + function datastore_index_scan_range_bsatn( + index_id: u32, + prefix: Uint8Array, + prefix_elems: u16, + rstart: Uint8Array, + rend: Uint8Array + ): u32; + function row_iter_bsatn_advance( + iter: u32, + buffer_max_len: u32 + ): [boolean, Uint8Array]; + function row_iter_bsatn_close(iter: u32): void; + function datastore_insert_bsatn(table_id: u32, row: Uint8Array): Uint8Array; + function datastore_update_bsatn( + table_id: u32, + index_id: u32, + row: Uint8Array + ): Uint8Array; + function datastore_delete_by_index_scan_range_bsatn( + index_id: u32, + prefix: Uint8Array, + prefix_elems: u16, + rstart: Uint8Array, + rend: Uint8Array + ): u32; + function datastore_delete_all_by_eq_bsatn( + table_id: u32, + relation: Uint8Array + ): u32; + function volatile_nonatomic_schedule_immediate( + reducer_name: string, + args: Uint8Array + ): void; + function console_log(level: u8, message: string): void; + function console_timer_start(name: string): u32; + function console_timer_end(span_id: u32): void; + function identity(): { __identity__: u256 }; + + function __call_reducer__( + reducer_id: u32, + sender: u256, + conn_id: u128, + timestamp: bigint, + args: Uint8Array + ): void; + function __describe_module__(): RawModuleDef; +} + +const { freeze } = Object; + +const _syscalls = () => ({ + table_id_from_name, + index_id_from_name, + datastore_table_row_count, + datastore_table_scan_bsatn, + datastore_index_scan_range_bsatn, + row_iter_bsatn_advance, + row_iter_bsatn_close, + datastore_insert_bsatn, + datastore_update_bsatn, + datastore_delete_by_index_scan_range_bsatn, + datastore_delete_all_by_eq_bsatn, + volatile_nonatomic_schedule_immediate, + console_log, + console_timer_start, + console_timer_end, + identity, +}); + +const sys = {} as ReturnType; +function initSys() { + if (Object.isFrozen(sys)) return; + for (const [name, syscall] of Object.entries(_syscalls())) { + (sys as any)[name] = wrapSyscall(syscall); + } + freeze(sys); +} + +globalThis.__call_reducer__ = function __call_reducer__( + reducer_id, + sender, + conn_id, + timestamp, + args_buf +) { + initSys(); + const args_type = AlgebraicType.Product( + MODULE_DEF.reducers[reducer_id].params + ); + const args = AlgebraicType.deserializeValue( + new BinaryReader(args_buf), + args_type + ); + const ctx: ReducerCtx = freeze({ + sender: new Identity(sender), + get identity() { + return new Identity(_syscalls().identity().__identity__); + }, + timestamp: new Timestamp(timestamp), + connection_id: ConnectionId.nullIfZero(new ConnectionId(conn_id)), + db: getDbView(), + }); + REDUCERS[reducer_id](ctx, args); + return { tag: 'ok' }; +}; + +globalThis.__describe_module__ = function __describe_module__() { + initSys(); + return RawModuleDef.V9(MODULE_DEF); +}; + +let DB_VIEW: DbView | null = null; +function getDbView() { + DB_VIEW ??= makeDbView(MODULE_DEF); + return DB_VIEW; +} + +function makeDbView(module_def: RawModuleDefV9): DbView { + return freeze( + Object.fromEntries( + module_def.tables.map(table => [ + table.name, + makeTableView(module_def.typespace, table), + ]) + ) + ); +} + +function makeTableView(typespace: Typespace, table: RawTableDefV9): Table { + const table_id = sys.table_id_from_name(table.name); + const rowType = typespace.types[table.productTypeRef]; + if (rowType.tag !== 'Product') throw 'impossible'; + + const baseSize = bsatnBaseSize(typespace, rowType); + + const sequences = table.sequences.map(seq => { + const col = rowType.value.elements[seq.column]; + const colType = col.algebraicType; + return { + colName: col.name!, + read: (reader: BinaryReader) => + AlgebraicType.deserializeValue(reader, colType), + }; + }); + const hasAutoIncrement = sequences.length > 0; + + const iter = () => + new TableIterator(sys.datastore_table_scan_bsatn(table_id), rowType); + + const integrate_generated_columns = hasAutoIncrement + ? (row: RowType, ret_buf: Uint8Array) => { + const reader = new BinaryReader(ret_buf); + for (const { colName, read } of sequences) { + row[colName] = read(reader); + } + } + : null; + + const tryInsert: Table['tryInsert'] = row => { + const writer = new BinaryWriter(baseSize); + AlgebraicType.serializeValue(writer, rowType, row); + let ret_buf; + try { + ret_buf = sys.datastore_insert_bsatn(table_id, writer.getBuffer()); + } catch (e) { + if (e instanceof UniqueAlreadyExists || e instanceof AutoIncOverflow) + return { ok: false, err: e }; + throw e; + } + integrate_generated_columns?.(row, ret_buf); + + return { ok: true, val: row }; + }; + + const tableMethods: TableMethods = { + count: () => sys.datastore_table_row_count(table_id), + iter, + [Symbol.iterator]: () => iter(), + insert: (row: RowType): RowType => { + const res = tryInsert(row); + if (res.ok) return res.val; + throw res.err; + }, + tryInsert, + delete: (row: RowType): boolean => { + const writer = new BinaryWriter(4 + baseSize); + writer.writeU32(1); + AlgebraicType.serializeValue(writer, rowType, row); + const count = sys.datastore_delete_all_by_eq_bsatn( + table_id, + writer.getBuffer() + ); + return count > 0; + }, + }; + + const tableView = tableMethods as Table; + + for (const indexDef of table.indexes) { + const index_id = sys.index_id_from_name(indexDef.name!); + + let column_ids: number[]; + switch (indexDef.algorithm.tag) { + case 'BTree': + column_ids = indexDef.algorithm.value; + break; + case 'Hash': + throw new Error('impossible'); + case 'Direct': + column_ids = [indexDef.algorithm.value]; + break; + } + const numColumns = column_ids.length; + + const columnSet = new Set(column_ids); + const isUnique = table.constraints + .filter(x => x.data.tag === 'Unique') + .map(x => columnSet.isSubsetOf(new Set(x.data.value.columns))); + + const indexType = AlgebraicType.Product({ + elements: column_ids.map(id => rowType.value.elements[id]), + }); + + const baseSize = bsatnBaseSize(typespace, indexType); + + const serializePrefix = ( + writer: BinaryWriter, + prefix: any[], + prefix_elems: number + ) => { + if (prefix.length > numColumns - 1) + throw new TypeError('too many elements in prefix'); + for (let i = 0; i < prefix_elems; i++) { + const elemType = indexType.value.elements[i].algebraicType; + AlgebraicType.serializeValue(writer, elemType, prefix[i]); + } + return writer; + }; + + type IndexScanArgs = [ + prefix: Uint8Array, + prefix_elems: u16, + rstart: Uint8Array, + rend: Uint8Array, + ]; + + let index: Index; + if (isUnique) { + const serializeBound = (col_val: any[]): IndexScanArgs => { + if (col_val.length !== numColumns) + throw new TypeError('wrong number of elements'); + + const writer = new BinaryWriter(baseSize + 1); + const prefix_elems = numColumns - 1; + serializePrefix(writer, col_val, prefix_elems); + const rstartOffset = writer.offset; + writer.writeU8(0); + AlgebraicType.serializeValue( + writer, + indexType.value.elements[numColumns - 1].algebraicType, + col_val[numColumns - 1] + ); + const buffer = writer.getBuffer(); + const prefix = buffer.slice(0, rstartOffset); + const rstart = buffer.slice(rstartOffset); + return [prefix, prefix_elems, rstart, rstart]; + }; + index = { + find: (col_val: IndexVal): RowType | null => { + if (numColumns === 1) col_val = [col_val]; + const args = serializeBound(col_val); + const iter = new TableIterator( + sys.datastore_index_scan_range_bsatn(index_id, ...args), + rowType + ); + const { value, done } = iter.next(); + if (done) return null; + if (!iter.next().done) + throw new Error( + '`datastore_index_scan_range_bsatn` on unique field cannot return >1 rows' + ); + return value; + }, + delete: (col_val: IndexVal): boolean => { + if (numColumns === 1) col_val = [col_val]; + const args = serializeBound(col_val); + const num = sys.datastore_delete_by_index_scan_range_bsatn( + index_id, + ...args + ); + return num > 0; + }, + update: (row: RowType): RowType => { + const writer = new BinaryWriter(baseSize); + AlgebraicType.serializeValue(writer, rowType, row); + const ret_buf = sys.datastore_update_bsatn( + table_id, + index_id, + writer.getBuffer() + ); + integrate_generated_columns?.(row, ret_buf); + return row; + }, + } as UniqueIndex; + } else { + const serializeRange = (range: any[]): IndexScanArgs => { + if (range.length > numColumns) throw new TypeError('too many elements'); + + const writer = new BinaryWriter(baseSize + 1); + const prefix_elems = range.length - 1; + serializePrefix(writer, range, prefix_elems); + const rstartOffset = writer.offset; + const term = range[range.length - 1]; + const termType = + indexType.value.elements[range.length - 1].algebraicType; + let rstart: Uint8Array, rend: Uint8Array; + if (term instanceof Range) { + const writeBound = (bound: Bound) => { + const tags = { included: 0, excluded: 1, unbounded: 2 }; + writer.writeU8(tags[bound.tag]); + if (bound.tag !== 'unbounded') + AlgebraicType.serializeValue(writer, termType, bound.value); + }; + writeBound(term.from); + const rendOffset = writer.offset; + writeBound(term.to); + rstart = writer.getBuffer().slice(rstartOffset, rendOffset); + rend = writer.getBuffer().slice(rendOffset); + } else { + writer.writeU8(0); + AlgebraicType.serializeValue(writer, termType, term); + rstart = rend = writer.getBuffer().slice(rstartOffset); + } + const buffer = writer.getBuffer(); + const prefix = buffer.slice(0, rstartOffset); + return [prefix, prefix_elems, rstart, rend]; + }; + index = { + filter: (range: any): IterableIterator> => { + if (numColumns === 1) range = [range]; + const args = serializeRange(range); + return new TableIterator( + sys.datastore_index_scan_range_bsatn(index_id, ...args), + rowType + ); + }, + delete: (range: any): u32 => { + if (numColumns === 1) range = [range]; + const args = serializeRange(range); + return sys.datastore_delete_by_index_scan_range_bsatn( + index_id, + ...args + ); + }, + } as RangedIndex; + } + + if (Object.hasOwn(tableView, indexDef.name!)) { + freeze(Object.assign(tableView[indexDef.name!], index)); + } else { + tableView[indexDef.name!] = freeze(index) as any; + } + } + + return freeze(tableView); +} + +function bsatnBaseSize(typespace: Typespace, ty: AlgebraicType): number { + const assumedArrayLength = 4; + while (ty.tag === 'Ref') ty = typespace.types[ty.value]; + if (ty.tag === 'Product') { + let sum = 0; + for (const { algebraicType: elem } of ty.value.elements) { + sum += bsatnBaseSize(typespace, elem); + } + return sum; + } else if (ty.tag === 'Sum') { + let min = Infinity; + for (const { algebraicType: vari } of ty.value.variants) { + const vSize = bsatnBaseSize(typespace, vari); + if (vSize < min) min = vSize; + } + if (min === Infinity) min = 0; + return 4 + min; + } else if (ty.tag == 'Array') { + return 4 + assumedArrayLength * bsatnBaseSize(typespace, ty); + } + return { + String: 4 + assumedArrayLength, + Sum: 1, + Bool: 1, + I8: 1, + U8: 1, + I16: 2, + U16: 2, + I32: 4, + U32: 4, + F32: 4, + I64: 8, + U64: 8, + F64: 8, + I128: 16, + U128: 16, + I256: 32, + U256: 32, + }[ty.tag]; +} + +function hasOwn( + o: object, + k: K +): o is K extends PropertyKey ? { [k in K]: unknown } : never { + return Object.hasOwn(o, k); +} + +class TableIterator implements IterableIterator { + #id: u32 | -1; + #reader: BinaryReader; + #ty: AlgebraicType; + constructor(id: u32, ty: AlgebraicType) { + this.#id = id; + this.#reader = new BinaryReader(new Uint8Array()); + this.#ty = ty; + } + [Symbol.iterator](): typeof this { + return this; + } + next(): IteratorResult { + while (true) { + if (this.#reader.remaining > 0) { + const value = AlgebraicType.deserializeValue(this.#reader, this.#ty); + return { value }; + } + if (this.#id === -1) { + return { value: undefined, done: true }; + } + this.#advance_iter(); + } + } + + #advance_iter() { + let buf_max_len = 0x10000; + while (true) { + try { + const { 0: done, 1: buf } = sys.row_iter_bsatn_advance( + this.#id, + buf_max_len + ); + if (done) this.#id = -1; + this.#reader = new BinaryReader(buf); + return; + } catch (e) { + if (e && typeof e === 'object' && hasOwn(e, '__buffer_too_small__')) { + buf_max_len = e.__buffer_too_small__ as number; + continue; + } + throw e; + } + } + } + + [Symbol.dispose]() { + if (this.#id >= 0) { + this.#id = -1; + sys.row_iter_bsatn_close(this.#id); + } + } +} + +function wrapSyscall any>( + func: F +): (...args: Parameters) => ReturnType { + const name = func.name; + return { + [name](...args: Parameters) { + try { + return func(...args); + } catch (e) { + if ( + e !== null && + typeof e === 'object' && + hasOwn(e, '__code_error__') && + typeof e.__code_error__ == 'number' + ) { + throw new SpacetimeError(e.__code_error__); + } + throw e; + } + }, + }[name]; +} + +function fmtLog(...data: any[]) { + return data.join(' '); +} + +const console_level_error = 0; +const console_level_warn = 1; +const console_level_info = 2; +const console_level_debug = 3; +const console_level_trace = 4; +const console_level_panic = 101; + +const timerMap = new Map(); + +let console: Console = { + __proto__: {}, + [Symbol.toStringTag]: 'console', + assert: (condition = false, ...data: any[]) => { + if (!condition) { + sys.console_log(console_level_error, fmtLog(...data)); + } + }, + clear: () => {}, + debug: (...data: any[]) => { + sys.console_log(console_level_debug, fmtLog(...data)); + }, + error: (...data: any[]) => { + sys.console_log(console_level_error, fmtLog(...data)); + }, + info: (...data: any[]) => { + sys.console_log(console_level_info, fmtLog(...data)); + }, + log: (...data: any[]) => { + sys.console_log(console_level_info, fmtLog(...data)); + }, + table: (tabularData: any, properties: any) => { + sys.console_log(console_level_info, fmtLog(tabularData)); + }, + trace: (...data: any[]) => { + sys.console_log(console_level_trace, fmtLog(...data)); + }, + warn: (...data: any[]) => { + sys.console_log(console_level_warn, fmtLog(...data)); + }, + dir: (item: any, options: any) => {}, + dirxml: (...data: any[]) => {}, + // Counting + count: (label = 'default') => {}, + countReset: (label = 'default') => {}, + // Grouping + group: (...data: any[]) => {}, + groupCollapsed: (...data: any[]) => {}, + groupEnd: () => {}, + // Timing + time: (label = 'default') => { + if (timerMap.has(label)) { + sys.console_log(console_level_warn, `Timer '${label}' already exists.`); + return; + } + timerMap.set(label, sys.console_timer_start(label)); + }, + timeLog: (label = 'default', ...data: any[]) => { + sys.console_log(console_level_info, fmtLog(label, ...data)); + }, + timeEnd: (label = 'default') => { + const spanId = timerMap.get(label); + if (spanId === undefined) { + sys.console_log(console_level_warn, `Timer '${label}' does not exist.`); + return; + } + sys.console_timer_end(spanId); + timerMap.delete(label); + }, + // Additional console methods to satisfy the Console interface + timeStamp: () => {}, + profile: () => {}, + profileEnd: () => {}, +} as any; + +(console as any).Console = console; + +globalThis.console = console; diff --git a/crates/bindings-typescript/src/server/schema.ts b/crates/bindings-typescript/src/server/schema.ts new file mode 100644 index 00000000000..1265593f614 --- /dev/null +++ b/crates/bindings-typescript/src/server/schema.ts @@ -0,0 +1,279 @@ +import type RawTableDefV9 from '../lib/autogen/raw_table_def_v_9_type'; +import type Typespace from '../lib/autogen/typespace_type'; +import { MODULE_DEF } from './runtime'; +import { ColumnBuilder, type RowObj, type TypeBuilder } from './type_builders'; +import type { AlgebraicTypeRef, TableSchema, UntypedTableDef } from './table'; +import { + clientConnected, + clientDisconnected, + init, + reducer, + type ParamsObj, + type Reducer, +} from './reducers'; + +/** + * An untyped representation of the database schema. + */ +export type UntypedSchemaDef = { + tables: readonly UntypedTableDef[]; +}; + +/** + * Helper type to convert an array of TableSchema into a schema definition + */ +type TablesToSchema[]> = { + tables: { + /** @type {UntypedTableDef} */ + readonly [i in keyof T]: { + name: T[i]['tableName']; + columns: T[i]['rowType']; + indexes: T[i]['idxs']; + }; + }; +}; + +/** + * The Schema class represents the database schema for a SpacetimeDB application. + * It encapsulates the table definitions and typespace, and provides methods to define + * reducers and lifecycle hooks. + * + * Schema has a generic parameter S which represents the inferred schema type. This type + * is automatically inferred when creating a schema using the `schema()` function and is + * used to type the database view in reducer contexts. + * + * The methods on this class are used to register reducers and lifecycle hooks + * with the SpacetimeDB runtime. Theey forward to free functions that handle the actual + * registration logic, but having them as methods on the Schema class helps with type inference. + * + * @template S - The inferred schema type of the SpacetimeDB module. + * + * @example + * ```typescript + * const spacetime = schema( + * table({ name: 'user' }, userType), + * table({ name: 'post' }, postType) + * ); + * spacetime.reducer( + * 'create_user', + * { username: t.string(), email: t.string() }, + * (ctx, { username, email }) => { + * ctx.db.user.insert({ username, email, created_at: ctx.timestamp }); + * console.log(`User ${username} created by ${ctx.sender.identityId}`); + * } + * ); + * ``` + */ +// TODO(cloutiertyler): It might be nice to have a way to access the types +// for the tables from the schema object, e.g. `spacetimedb.user.type` would +// be the type of the user table. +class Schema { + readonly tablesDef: { tables: RawTableDefV9[] }; + readonly typespace: Typespace; + readonly schemaType!: S; + + constructor(tables: RawTableDefV9[], typespace: Typespace) { + this.tablesDef = { tables }; + this.typespace = typespace; + } + + /** + * Defines a SpacetimeDB reducer function. + * + * Reducers are the primary way to modify the state of your SpacetimeDB application. + * They are atomic, meaning that either all operations within a reducer succeed, + * or none of them do. + * + * @template S - The inferred schema type of the SpacetimeDB module. + * @template Params - The type of the parameters object expected by the reducer. + * + * @param {string} name - The name of the reducer. This name will be used to call the reducer from clients. + * @param {Params} params - An object defining the parameters that the reducer accepts. + * Each key-value pair represents a parameter name and its corresponding + * {@link TypeBuilder} or {@link ColumnBuilder}. + * @param {(ctx: ReducerCtx, payload: ParamsAsObject) => void} fn - The reducer function itself. + * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. + * - `payload`: An object containing the arguments passed to the reducer, typed according to `params`. + * + * @example + * ```typescript + * // Define a reducer named 'create_user' that takes 'username' (string) and 'email' (string) + * spacetime.reducer( + * 'create_user', + * { + * username: t.string(), + * email: t.string(), + * }, + * (ctx, { username, email }) => { + * // Access the 'user' table from the database view in the context + * ctx.db.user.insert({ username, email, created_at: ctx.timestamp }); + * console.log(`User ${username} created by ${ctx.sender.identityId}`); + * } + * ); + * ``` + */ + reducer( + name: string, + params: Params, + fn: Reducer + ): void { + reducer(name, params, fn); + } + + /** + * Registers an initialization reducer that runs when the SpacetimeDB module is published + * for the first time. + * + * This function is useful to set up any initial state of your database that is guaranteed + * to run only once, and before any other reducers or client connections. + * + * @template S - The inferred schema type of the SpacetimeDB module. + * @param {Reducer} fn - The initialization reducer function. + * - `ctx`: The reducer context, providing access to `sender`, `timestamp`, `connection_id`, and `db`. + * @example + * ```typescript + * spacetime.init((ctx) => { + * ctx.db.user.insert({ username: 'admin', email: 'admin@example.com' }); + * }); + * ``` + */ + init(fn: Reducer): void { + init({}, fn); + } + + /** + * Registers a reducer to be called when a client connects to the SpacetimeDB module. + * This function allows you to define custom logic that should execute + * whenever a new client establishes a connection. + * @template S - The inferred schema type of the SpacetimeDB module. + * + * @param fn - The reducer function to execute on client connection. + * + * @example + * ```typescript + * spacetime.clientConnected( + * (ctx) => { + * console.log(`Client ${ctx.connectionId} connected`); + * } + * ); + */ + clientConnected(fn: Reducer): void { + clientConnected({}, fn); + } + + /** + * Registers a reducer to be called when a client disconnects from the SpacetimeDB module. + * This function allows you to define custom logic that should execute + * whenever a client disconnects. + * @template S - The inferred schema type of the SpacetimeDB module. + * + * @param fn - The reducer function to execute on client disconnection. + * + * @example + * ```typescript + * spacetime.clientDisconnected( + * (ctx) => { + * console.log(`Client ${ctx.connectionId} disconnected`); + * } + * ); + * ``` + */ + clientDisconnected(fn: Reducer): void { + clientDisconnected({}, fn); + } +} + +/** + * Extracts the inferred schema type from a Schema instance + */ +export type InferSchema> = + SchemaDef extends Schema ? S : never; + +/** + * Creates a schema from table definitions + * @param handles - Array of table handles created by table() function + * @returns ColumnBuilder representing the complete database schema + * @example + * ```ts + * const s = schema( + * table({ name: 'user' }, userType), + * table({ name: 'post' }, postType) + * ); + * ``` + */ +export function schema[]>( + ...handles: H +): Schema>; + +/** + * Creates a schema from table definitions + * @param handles - Array of table handles created by table() function + * @returns ColumnBuilder representing the complete database schema + * @example + * ```ts + * const s = schema( + * table({ name: 'user' }, userType), + * table({ name: 'post' }, postType) + * ); + * ``` + */ +export function schema[]>( + ...handles: H +): Schema>; + +/** + * Creates a schema from table definitions (array overload) + * @param handles - Array of table handles created by table() function + * @returns ColumnBuilder representing the complete database schema + */ +export function schema[]>( + handles: H +): Schema>; + +/** + * Creates a schema from table definitions + * @param args - Either an array of table handles or a variadic list of table handles + * @returns ColumnBuilder representing the complete database schema + * @example + * ```ts + * const s = schema( + * table({ name: 'user' }, userType), + * table({ name: 'post' }, postType) + * ); + * ``` + */ +export function schema( + ...args: + | [readonly TableSchema[]] + | readonly TableSchema[] +): Schema { + const handles: readonly TableSchema[] = + args.length === 1 && Array.isArray(args[0]) ? args[0] : args; + + const tableDefs = handles.map(h => h.tableDef); + + // Traverse the tables in order. For each newly encountered + // insert the type into the typespace and increment the product + // type reference, inserting the product type reference into the + // table. + let productTypeRef: AlgebraicTypeRef = 0; + const typespace: Typespace = { + types: [], + }; + handles.forEach(h => { + const tableType = h.rowSpacetimeType; + // Insert the table type into the typespace + typespace.types.push(tableType); + h.tableDef.productTypeRef = productTypeRef; + // Increment the product type reference + productTypeRef++; + }); + + // Side-effect: + // Modify the `MODULE_DEF` which will be read by + // __describe_module__ + MODULE_DEF.tables.push(...tableDefs); + MODULE_DEF.typespace = typespace; + + return new Schema(tableDefs, typespace); +} diff --git a/crates/bindings-typescript/src/server/table.ts b/crates/bindings-typescript/src/server/table.ts new file mode 100644 index 00000000000..2e799e8d8af --- /dev/null +++ b/crates/bindings-typescript/src/server/table.ts @@ -0,0 +1,346 @@ +import { AlgebraicType } from '../lib/algebraic_type'; +import type RawConstraintDefV9 from '../lib/autogen/raw_constraint_def_v_9_type'; +import RawIndexAlgorithm from '../lib/autogen/raw_index_algorithm_type'; +import type RawIndexDefV9 from '../lib/autogen/raw_index_def_v_9_type'; +import type RawSequenceDefV9 from '../lib/autogen/raw_sequence_def_v_9_type'; +import type RawTableDefV9 from '../lib/autogen/raw_table_def_v_9_type'; +import type { AllUnique } from './constraints'; +import type { TryInsertError } from './errors'; +import type { ColumnIndex, IndexColumns, Indexes, IndexOpts } from './indexes'; +import { + RowBuilder, + type ColumnBuilder, + type ColumnMetadata, + type InferTypeOfRow, + type RowObj, + type TypeBuilder, +} from './type_builders'; +import type { Prettify, Result, Values } from './type_util'; + +export type AlgebraicTypeRef = number; +type ColId = number; +type ColList = ColId[]; + +/** + * A helper type to extract the row type from a TableDef + */ +export type RowType = InferTypeOfRow< + TableDef['columns'] +>; + +/** + * Coerces a column which may be a TypeBuilder or ColumnBuilder into a ColumnBuilder + */ +export type CoerceColumn< + Col extends TypeBuilder | ColumnBuilder, +> = + Col extends TypeBuilder ? ColumnBuilder : Col; + +/** + * Coerces a RowObj where TypeBuilders are replaced with ColumnBuilders + */ +export type CoerceRow = { + [k in keyof Row & string]: CoerceColumn; +}; + +/** + * Helper type to coerce an array of IndexOpts + */ +type CoerceArray[]> = X; + +/** + * An untyped representation of a table's schema. + */ +export type UntypedTableDef = { + name: string; + columns: Record>>; + indexes: IndexOpts[]; +}; + +/** + * A type representing the indexes defined on a table. + */ +export type TableIndexes = { + [k in keyof TableDef['columns'] & string]: ColumnIndex< + k, + TableDef['columns'][k]['columnMetadata'] + >; +} & { + [I in TableDef['indexes'][number] as I['name'] & {}]: { + name: I['name']; + unique: AllUnique>; + algorithm: Lowercase; + columns: IndexColumns; + }; +}; + +/** + * Options for configuring a database table. + * - `name`: The name of the table. + * - `public`: Whether the table is publicly accessible. Defaults to `false`. + * - `indexes`: An array of index configurations for the table. + * - `scheduled`: The name of the reducer to be executed based on the scheduled rows in this table. + */ +export type TableOpts = { + name: string; + public?: boolean; + indexes?: IndexOpts[]; // declarative multi‑column indexes + scheduled?: string; +}; + +/** + * Extracts the indices from TableOpts, defaulting to an empty array if none are provided. + */ +type OptsIndices> = Opts extends { + indexes: infer Ixs extends NonNullable; +} + ? Ixs + : CoerceArray<[]>; + +/** + * A type which is only T if any column in the table has the specified metadata. + * Otherwise, it resolves to never. + * @template TableDef - The table definition to check. + * @template Metadata - The metadata to look for in the table's columns. + * @template T - The type to return if the metadata is found in any column. + */ +export type CheckAnyMetadata< + TableDef extends UntypedTableDef, + Metadata extends ColumnMetadata, + T, +> = Values['columnMetadata'] extends Metadata ? T : never; + +/** + * Table + * + * - Row: row shape + * - UCV: unique-constraint violation error type (never if none) + * - AIO: auto-increment overflow error type (never if none) + */ +export type Table = Prettify< + TableMethods & Indexes> +>; + +/** + * A type representing the methods available on a table. + */ +export type TableMethods = { + /** Returns the number of rows in the TX state. */ + count(): bigint; + + /** Iterate over all rows in the TX state. Rust Iterator → TS IterableIterator. */ + iter(): IterableIterator>; + [Symbol.iterator](): IterableIterator>; + + /** Insert and return the inserted row (auto-increment fields filled). May throw on error. */ + insert(row: RowType): RowType; + + /** Like insert, but returns a Result instead of throwing. */ + tryInsert( + row: RowType + ): Result, TryInsertError>; + + /** Delete a row equal to `row`. Returns true if something was deleted. */ + delete(row: RowType): boolean; +}; + +/** + * Represents a handle to a database table, including its name, row type, and row spacetime type. + */ +export type TableSchema< + TableName extends string, + Row extends Record>, + Idx extends readonly IndexOpts[], +> = { + /** + * The TypeScript phantom type. This is not stored at runtime, + * but is visible to the compiler + */ + readonly rowType: Row; + + /** + * The name of the table. + */ + readonly tableName: TableName; + + /** + * The {@link AlgebraicType} representing the structure of a row in the table. + */ + readonly rowSpacetimeType: AlgebraicType; + + /** + * The {@link RawTableDefV9} of the configured table + */ + readonly tableDef: RawTableDefV9; + + /** + * The indexes defined on the table. + */ + readonly idxs: Idx; +}; + +/** + * Defines a database table with schema and options + * @param opts - Table configuration including name, indexes, and access control + * @param row - Product type defining the table's row structure + * @returns Table handle for use in schema() function + * @example + * ```ts + * const playerTable = table( + * { name: 'player', public: true }, + * t.object({ + * id: t.u32().primary_key(), + * name: t.string().index('btree') + * }) + * ); + * ``` + */ +export function table>( + opts: Opts, + row: Row | RowBuilder +): TableSchema, OptsIndices> { + const { + name, + public: isPublic = false, + indexes: userIndexes = [], + scheduled, + } = opts; + + // 1. column catalogue + helpers + const colIds = new Map(); + const colNameList: string[] = []; + + if (!(row instanceof RowBuilder)) { + row = new RowBuilder(row); + } + + let nextColId = 0; + row.algebraicType.value.elements.forEach(elem => { + colIds.set(elem.name, nextColId++); + colNameList.push(elem.name); + }); + + // gather primary keys, per‑column indexes, uniques, sequences + const pk: ColList = []; + const indexes: RawIndexDefV9[] = []; + const constraints: RawConstraintDefV9[] = []; + const sequences: RawSequenceDefV9[] = []; + + let scheduleAtCol: ColId | undefined; + + for (const [name, builder] of Object.entries(row)) { + const meta: ColumnMetadata = + 'columnMetadata' in builder ? builder.columnMetadata : {}; + + if (meta.isPrimaryKey) { + pk.push(colIds.get(name)!); + } + + const isUnique = meta.isUnique || meta.isPrimaryKey; + + // implicit 1‑column indexes + if (meta.indexType || isUnique) { + const algo = meta.indexType ?? 'btree'; + const id = colIds.get(name)!; + let algorithm: RawIndexAlgorithm; + switch (algo) { + case 'btree': + algorithm = RawIndexAlgorithm.BTree([id]); + break; + case 'direct': + algorithm = RawIndexAlgorithm.Direct(id); + break; + } + indexes.push({ + name: undefined, + accessorName: name, + algorithm, + }); + } + + if (isUnique) { + constraints.push({ + name: undefined, + data: { tag: 'Unique', value: { columns: [colIds.get(name)!] } }, + }); + } + + if (meta.isAutoIncrement) { + sequences.push({ + name: undefined, + start: undefined, + minValue: undefined, + maxValue: undefined, + column: colIds.get(name)!, + increment: 1n, + }); + } + + if (meta.isScheduleAt) { + scheduleAtCol = colIds.get(name)!; + } + } + + // convert explicit multi‑column indexes coming from options.indexes + for (const indexOpts of userIndexes ?? []) { + let algorithm: RawIndexAlgorithm; + switch (indexOpts.algorithm) { + case 'btree': + algorithm = { + tag: 'BTree', + value: indexOpts.columns.map(c => colIds.get(c)!), + }; + break; + case 'direct': + algorithm = { tag: 'Direct', value: colIds.get(indexOpts.column)! }; + break; + } + indexes.push({ name: undefined, accessorName: indexOpts.name, algorithm }); + } + + for (const index of indexes) { + const cols = + index.algorithm.tag === 'Direct' + ? [index.algorithm.value] + : index.algorithm.value; + const colS = cols.map(i => colNameList[i]).join('_'); + index.name = `${name}_${colS}_idx_${index.algorithm.tag.toLowerCase()}`; + } + + // Temporarily set the type ref to 0. We will set this later + // in the schema function. + const productTypeRef = 0; + + const tableDef: RawTableDefV9 = { + name, + productTypeRef, + primaryKey: pk, + indexes, + constraints, + sequences, + schedule: + scheduled && scheduleAtCol !== undefined + ? { + name: undefined, + reducerName: scheduled, + scheduledAtColumn: scheduleAtCol, + } + : undefined, + tableType: { tag: 'User' }, + tableAccess: { tag: isPublic ? 'Public' : 'Private' }, + }; + + const productType = AlgebraicType.Product({ + elements: row.algebraicType.value.elements.map(elem => { + return { name: elem.name, algebraicType: elem.algebraicType }; + }), + }); + + return { + tableName: name, + rowSpacetimeType: productType, + tableDef, + idxs: userIndexes as OptsIndices, + rowType: {} as CoerceRow, + }; +} diff --git a/crates/bindings-typescript/src/server/type_builders.test-d.ts b/crates/bindings-typescript/src/server/type_builders.test-d.ts index 3f622d7efb2..d0a153d046c 100644 --- a/crates/bindings-typescript/src/server/type_builders.test-d.ts +++ b/crates/bindings-typescript/src/server/type_builders.test-d.ts @@ -38,6 +38,7 @@ const row2 = { bar: t.i32().primaryKey(), idx: t.i64().index('btree').unique(), }; +// @ts-expect-error type Row2 = InferTypeOfRow; // eslint-disable-next-line @typescript-eslint/no-unused-vars type _ = MustBeNever; diff --git a/crates/bindings-typescript/src/server/type_builders.ts b/crates/bindings-typescript/src/server/type_builders.ts index c06ea3e64c3..a697daec731 100644 --- a/crates/bindings-typescript/src/server/type_builders.ts +++ b/crates/bindings-typescript/src/server/type_builders.ts @@ -1,39 +1,88 @@ +import type { U } from 'vitest/dist/chunks/environment.d.cL3nLXbE.js'; import { AlgebraicType, ConnectionId, Identity, ScheduleAt, - SumTypeVariant, TimeDuration, Timestamp, + Option, type AlgebraicTypeVariants, + type ConnectionIdAlgebraicType, + type IdentityAlgebraicType, + type TimeDurationAlgebraicType, + type TimestampAlgebraicType, } from '..'; -import type { Set } from './type_util'; +import type { OptionAlgebraicType } from '../lib/option'; +import { set, type Set } from './type_util'; /** * Helper type to extract the TypeScript type from a TypeBuilder */ -export type InferTypeOfTypeBuilder = +export type InferTypeOfTypeBuilder> = T extends TypeBuilder ? U : never; +/** + * Helper type to extract the Spacetime type from a TypeBuilder + */ +export type InferSpacetimeTypeOfTypeBuilder> = + T extends TypeBuilder ? U : never; + /** * Helper type to extract the TypeScript type from a TypeBuilder */ -export type Infer = InferTypeOfTypeBuilder; +export type Infer> = InferTypeOfTypeBuilder; /** * Helper type to extract the type of a row from an object. */ -export type InferTypeOfRow = - T extends Record | TypeBuilder> - ? { - [K in keyof T]: T[K] extends ColumnBuilder - ? V - : T[K] extends TypeBuilder - ? V - : never; - } - : never; +export type InferTypeOfRow< + T extends Record< + string, + ColumnBuilder | TypeBuilder + >, +> = { + [K in keyof T & string]: InferTypeOfTypeBuilder>; +}; + +/** + * Helper type to extract the Spacetime type from a row object. + */ +type CollapseColumn< + T extends TypeBuilder | ColumnBuilder, +> = T extends ColumnBuilder ? T['typeBuilder'] : T; + +/** + * A type representing an object which is used to define the type of + * a row in a table. + */ +export type RowObj = Record< + string, + TypeBuilder | ColumnBuilder +>; + +/** + * Type which converts the elements of RowObj to a ProductType elements array + */ +type ElementsArrayFromRowObj = Array< + { + [N in keyof Obj & string]: { + name: N; + algebraicType: InferSpacetimeTypeOfTypeBuilder>; + }; + }[keyof Obj & string] +>; + +/** + * A type which converts the elements of RowObj to a TypeScript object type. + * It works by `Infer`ing the types of the column builders which are the values of + * the keys in the object passed in. + * + * e.g. { a: I32TypeBuilder, b: StringBuilder } -> { a: number, b: string } + */ +type TypeScriptTypeFromRowObj = { + [K in keyof Row]: InferTypeOfTypeBuilder>; +}; /** * Type which represents a valid argument to the ProductColumnBuilder @@ -43,10 +92,14 @@ type ElementsObj = Record>; /** * Type which converts the elements of ElementsObj to a ProductType elements array */ -type ElementsArrayFromElementsObj = { - name: keyof Obj & string; - algebraicType: Obj[keyof Obj & string]['spacetimeType']; -}[]; +type ElementsArrayFromElementsObj = Array< + { + [N in keyof Obj & string]: { + name: N; + algebraicType: InferSpacetimeTypeOfTypeBuilder; + }; + }[keyof Obj & string] +>; /** * A type which converts the elements of ElementsObj to a TypeScript object type. @@ -77,26 +130,22 @@ type TypeScriptTypeFromVariantsObj = { */ type VariantsArrayFromVariantsObj = { name: keyof Obj & string; - algebraicType: Obj[keyof Obj & string]['spacetimeType']; + algebraicType: InferSpacetimeTypeOfTypeBuilder; }[]; /** * A generic type builder that captures both the TypeScript type * and the corresponding `AlgebraicType`. */ -export class TypeBuilder { +export class TypeBuilder + implements Optional +{ /** * The TypeScript phantom type. This is not stored at runtime, * but is visible to the compiler */ readonly type!: Type; - /** - * TypeScript phantom type representing the type of the particular - * AlgebraicType stored in {@link algebraicType}. - */ - readonly spacetimeType!: SpacetimeType; - /** * The SpacetimeDB algebraic type (run‑time value). In addition to storing * the runtime representation of the `AlgebraicType`, it also captures @@ -106,11 +155,15 @@ export class TypeBuilder { * * e.g. `string` corresponds to `AlgebraicType.String` */ - readonly algebraicType: AlgebraicType; + readonly algebraicType: SpacetimeType; - constructor(algebraicType: AlgebraicType) { + constructor(algebraicType: SpacetimeType) { this.algebraicType = algebraicType; } + + optional(): OptionBuilder { + return new OptionBuilder(this); + } } /** @@ -133,7 +186,7 @@ export class TypeBuilder { interface PrimaryKeyable< Type, SpacetimeType extends AlgebraicType, - M extends ColumnMetadata = DefaultMetadata, + M extends ColumnMetadata = DefaultMetadata, > { /** * Specify this column as primary key @@ -165,7 +218,7 @@ interface PrimaryKeyable< interface Uniqueable< Type, SpacetimeType extends AlgebraicType, - M extends ColumnMetadata = DefaultMetadata, + M extends ColumnMetadata = DefaultMetadata, > { /** * Specify this column as unique @@ -192,14 +245,15 @@ interface Uniqueable< interface Indexable< Type, SpacetimeType extends AlgebraicType, - M extends ColumnMetadata = DefaultMetadata, + M extends ColumnMetadata = DefaultMetadata, > { /** * Specify the index type for this column * @param algorithm The index algorithm to use */ - index( - algorithm?: N + index(): ColumnBuilder>; + index>( + algorithm: N ): ColumnBuilder>; } @@ -223,7 +277,7 @@ interface Indexable< interface AutoIncrementable< Type, SpacetimeType extends AlgebraicType, - M extends ColumnMetadata = DefaultMetadata, + M extends ColumnMetadata = DefaultMetadata, > { /** * Specify this column as auto-incrementing @@ -235,47 +289,104 @@ interface AutoIncrementable< >; } +/** + * Interface for types that can be converted into an optional type. + * All {@link TypeBuilder}s implement this interface, however since the `optional()` method + * returns an {@link OptionBuilder}, {@link OptionBuilder} controls what metadata is allowed + * to be configured for the column. This allows us to restrict whether things like indexes + * or unique constraints can be applied to optional columns. + * + * For this reason {@link ColumnBuilder} does not implement this interface. + */ +interface Optional { + /** + * Specify this column as optional + */ + optional(this: TypeBuilder): OptionBuilder; +} + +/** + * Interface for types that can be converted into a column builder with default value metadata. + * Implementing this interface allows a type to have a default value specified in a table column + * in a type-safe manner. The `default()` method returns a new `ColumnBuilder` instance + * with the metadata updated to include the specified default value. + * + * @typeParam Type - The TypeScript type of the column's value. + * @typeParam SpacetimeType - The corresponding SpacetimeDB algebraic type. + * @typeParam M - The metadata type for the column, defaulting to `DefaultMetadata`. + * + * @remarks + * - This interface is typically implemented by type builders for primitive and complex types. + * - The returned `ColumnBuilder` will have its metadata extended with `{ default: value }`. + * - The default value must be of the same type as the column's TypeScript type. + * - This method can be called multiple times; the last call takes precedence. + */ +interface Defaultable< + Type, + SpacetimeType extends AlgebraicType, + M extends ColumnMetadata = DefaultMetadata, +> { + /** + * Specify a default value for this column + * @param value The default value for the column + * @example + * ```typescript + * const col = t.i32().default(42); + * ``` + * @remarks + * - This method can be called multiple times; the last call takes precedence. + * - The default value must be of the same type as the column's TypeScript type. + */ + default( + value: Type + ): ColumnBuilder>; +} + export class U8Builder extends TypeBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.U8); } - index( - algorithm?: N - ): U8ColumnBuilder> { - return new U8ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): U8ColumnBuilder>; + index>( + algorithm: N + ): U8ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U8ColumnBuilder> { + return new U8ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): U8ColumnBuilder> { - return new U8ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new U8ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): U8ColumnBuilder> { - return new U8ColumnBuilder>( + return new U8ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): U8ColumnBuilder> { - return new U8ColumnBuilder>( + return new U8ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: number + ): U8ColumnBuilder> { + return new U8ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -286,41 +397,45 @@ export class U16Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.U16); } - index( - algorithm?: N - ): U16ColumnBuilder> { - return new U16ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): U16ColumnBuilder>; + index>( + algorithm: N + ): U16ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U16ColumnBuilder> { + return new U16ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): U16ColumnBuilder> { - return new U16ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new U16ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): U16ColumnBuilder> { - return new U16ColumnBuilder>( + return new U16ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): U16ColumnBuilder> { - return new U16ColumnBuilder>( + return new U16ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: number + ): U16ColumnBuilder> { + return new U16ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -331,41 +446,45 @@ export class U32Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.U32); } - index( - algorithm?: N - ): U32ColumnBuilder> { - return new U32ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): U32ColumnBuilder>; + index>( + algorithm: N + ): U32ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U32ColumnBuilder> { + return new U32ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): U32ColumnBuilder> { - return new U32ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new U32ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): U32ColumnBuilder> { - return new U32ColumnBuilder>( + return new U32ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): U32ColumnBuilder> { - return new U32ColumnBuilder>( + return new U32ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: number + ): U32ColumnBuilder> { + return new U32ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -376,41 +495,45 @@ export class U64Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.U64); } - index( - algorithm?: N - ): U64ColumnBuilder> { - return new U64ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): U64ColumnBuilder>; + index>( + algorithm: N + ): U64ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U64ColumnBuilder> { + return new U64ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): U64ColumnBuilder> { - return new U64ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new U64ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): U64ColumnBuilder> { - return new U64ColumnBuilder>( + return new U64ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): U64ColumnBuilder> { - return new U64ColumnBuilder>( + return new U64ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: bigint + ): U64ColumnBuilder> { + return new U64ColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) ); } } @@ -421,41 +544,48 @@ export class U128Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.U128); } - index( - algorithm?: N - ): U128ColumnBuilder> { - return new U128ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): U128ColumnBuilder>; + index>( + algorithm: N + ): U128ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U128ColumnBuilder> { + return new U128ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): U128ColumnBuilder> { - return new U128ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new U128ColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); } primaryKey(): U128ColumnBuilder> { - return new U128ColumnBuilder>( + return new U128ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): U128ColumnBuilder> { - return new U128ColumnBuilder>( + return new U128ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: bigint + ): U128ColumnBuilder> { + return new U128ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -466,41 +596,48 @@ export class U256Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.U256); } - index( - algorithm?: N - ): U256ColumnBuilder> { - return new U256ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): U256ColumnBuilder>; + index>( + algorithm: N + ): U256ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U256ColumnBuilder> { + return new U256ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): U256ColumnBuilder> { - return new U256ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new U256ColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); } primaryKey(): U256ColumnBuilder> { - return new U256ColumnBuilder>( + return new U256ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): U256ColumnBuilder> { - return new U256ColumnBuilder>( + return new U256ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: bigint + ): U256ColumnBuilder> { + return new U256ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -511,41 +648,45 @@ export class I8Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.I8); } - index( - algorithm?: N - ): I8ColumnBuilder> { - return new I8ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): I8ColumnBuilder>; + index>( + algorithm: N + ): I8ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I8ColumnBuilder> { + return new I8ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): I8ColumnBuilder> { - return new I8ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new I8ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): I8ColumnBuilder> { - return new I8ColumnBuilder>( + return new I8ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): I8ColumnBuilder> { - return new I8ColumnBuilder>( + return new I8ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: number + ): I8ColumnBuilder> { + return new I8ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -556,41 +697,45 @@ export class I16Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.I16); } - index( - algorithm?: N - ): I16ColumnBuilder> { - return new I16ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): I16ColumnBuilder>; + index>( + algorithm: N + ): I16ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I16ColumnBuilder> { + return new I16ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): I16ColumnBuilder> { - return new I16ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new I16ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): I16ColumnBuilder> { - return new I16ColumnBuilder>( + return new I16ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): I16ColumnBuilder> { - return new I16ColumnBuilder>( + return new I16ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: number + ): I16ColumnBuilder> { + return new I16ColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) ); } } @@ -598,44 +743,49 @@ export class I16Builder export class I32Builder extends TypeBuilder implements + TypeBuilder, Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.I32); } - index( - algorithm?: N - ): I32ColumnBuilder> { - return new I32ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): I32ColumnBuilder>; + index>( + algorithm: N + ): I32ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I32ColumnBuilder> { + return new I32ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): I32ColumnBuilder> { - return new I32ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new I32ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): I32ColumnBuilder> { - return new I32ColumnBuilder>( + return new I32ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): I32ColumnBuilder> { - return new I32ColumnBuilder>( + return new I32ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: number + ): I32ColumnBuilder> { + return new I32ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -646,41 +796,45 @@ export class I64Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.I64); } - index( - algorithm?: N - ): I64ColumnBuilder> { - return new I64ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): I64ColumnBuilder>; + index>( + algorithm: N + ): I64ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I64ColumnBuilder> { + return new I64ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): I64ColumnBuilder> { - return new I64ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new I64ColumnBuilder(this, set(defaultMetadata, { isUnique: true })); } primaryKey(): I64ColumnBuilder> { - return new I64ColumnBuilder>( + return new I64ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): I64ColumnBuilder> { - return new I64ColumnBuilder>( + return new I64ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: bigint + ): I64ColumnBuilder> { + return new I64ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -691,41 +845,48 @@ export class I128Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.I128); } - index( - algorithm?: N - ): I128ColumnBuilder> { - return new I128ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): I128ColumnBuilder>; + index>( + algorithm: N + ): I128ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I128ColumnBuilder> { + return new I128ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): I128ColumnBuilder> { - return new I128ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new I128ColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); } primaryKey(): I128ColumnBuilder> { - return new I128ColumnBuilder>( + return new I128ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): I128ColumnBuilder> { - return new I128ColumnBuilder>( + return new I128ColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: bigint + ): I128ColumnBuilder> { + return new I128ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -736,55 +897,84 @@ export class I256Builder Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { constructor() { super(AlgebraicType.I256); } - index( - algorithm?: N - ): I256ColumnBuilder> { - return new I256ColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): I256ColumnBuilder>; + index>( + algorithm: N + ): I256ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I256ColumnBuilder> { + return new I256ColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): I256ColumnBuilder> { - return new I256ColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new I256ColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); } primaryKey(): I256ColumnBuilder> { - return new I256ColumnBuilder>( + return new I256ColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { isPrimaryKey: true }) ); } autoInc(): I256ColumnBuilder> { - return new I256ColumnBuilder>( + return new I256ColumnBuilder( this, - { - ...defaultMetadata, - isAutoIncrement: true, - } + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: bigint + ): I256ColumnBuilder> { + return new I256ColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) ); } } -export class F32Builder extends TypeBuilder { +export class F32Builder + extends TypeBuilder + implements Defaultable +{ constructor() { super(AlgebraicType.F32); } + default( + value: number + ): F32ColumnBuilder> { + return new F32ColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } } -export class F64Builder extends TypeBuilder { +export class F64Builder + extends TypeBuilder + implements Defaultable +{ constructor() { super(AlgebraicType.F64); } + default( + value: number + ): F64ColumnBuilder> { + return new F64ColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } } export class BoolBuilder @@ -792,32 +982,42 @@ export class BoolBuilder implements Indexable, Uniqueable, - PrimaryKeyable + PrimaryKeyable, + Defaultable { constructor() { super(AlgebraicType.Bool); } - index( - algorithm?: N - ): BoolColumnBuilder> { - return new BoolColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): BoolColumnBuilder>; + index>( + algorithm: N + ): BoolColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): BoolColumnBuilder> { + return new BoolColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): BoolColumnBuilder> { - return new BoolColumnBuilder>(this, { - ...defaultMetadata, - isUnique: true, - }); + return new BoolColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); } primaryKey(): BoolColumnBuilder> { - return new BoolColumnBuilder>( + return new BoolColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } + default( + value: boolean + ): BoolColumnBuilder> { + return new BoolColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } @@ -827,47 +1027,55 @@ export class StringBuilder implements Indexable, Uniqueable, - PrimaryKeyable + PrimaryKeyable, + Defaultable { constructor() { super(AlgebraicType.String); } - index( - algorithm?: N - ): StringColumnBuilder> { - return new StringColumnBuilder>(this, { - ...defaultMetadata, - indexType: algorithm, - }); + index(): StringColumnBuilder>; + index>( + algorithm: N + ): StringColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): StringColumnBuilder> { + return new StringColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); } unique(): StringColumnBuilder> { - return new StringColumnBuilder>( + return new StringColumnBuilder( this, - { - ...defaultMetadata, - isUnique: true, - } + set(defaultMetadata, { isUnique: true }) ); } primaryKey(): StringColumnBuilder< Set > { - return new StringColumnBuilder>( + return new StringColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } + default( + value: string + ): StringColumnBuilder> { + return new StringColumnBuilder( this, - { - ...defaultMetadata, - isPrimaryKey: true, - } + set(defaultMetadata, { defaultValue: value }) ); } } -export class ArrayBuilder< - Element extends TypeBuilder, -> extends TypeBuilder< - Array, - { tag: 'Array'; value: Element['spacetimeType'] } -> { +export class ArrayBuilder> + extends TypeBuilder< + Array>, + { tag: 'Array'; value: InferSpacetimeTypeOfTypeBuilder } + > + implements Defaultable>, any> +{ /** * The phantom element type of the array for TypeScript */ @@ -876,20 +1084,65 @@ export class ArrayBuilder< constructor(element: Element) { super(AlgebraicType.Array(element.algebraicType)); } + default( + value: Array> + ): ArrayColumnBuilder> { + return new ArrayColumnBuilder( + this.element, + set(defaultMetadata, { defaultValue: value }) + ); + } } -export class ProductBuilder extends TypeBuilder< - TypeScriptTypeFromElementsObj, - { - tag: 'Product'; - value: { elements: ElementsArrayFromElementsObj }; - } -> { +export class OptionBuilder> + extends TypeBuilder< + InferTypeOfTypeBuilder | undefined, + OptionAlgebraicType + > + implements + Defaultable | undefined, OptionAlgebraicType> +{ /** - * The phantom element types of the product for TypeScript + * The phantom value type of the option for TypeScript */ - readonly elements!: Elements; + readonly value!: Value; + + constructor(value: Value) { + let innerType: AlgebraicType; + if (value instanceof ColumnBuilder) { + innerType = value.typeBuilder.algebraicType; + } else { + innerType = value.algebraicType; + } + super(Option.getAlgebraicType(innerType)); + } + default( + value: InferTypeOfTypeBuilder | undefined + ): OptionColumnBuilder< + InferTypeOfTypeBuilder, + Set< + DefaultMetadata, + 'defaultValue', + InferTypeOfTypeBuilder | undefined + > + > { + return new OptionColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } +} +export class ProductBuilder + extends TypeBuilder< + TypeScriptTypeFromElementsObj, + { + tag: 'Product'; + value: { elements: ElementsArrayFromElementsObj }; + } + > + implements Defaultable, any> +{ constructor(elements: Elements) { function elementsArrayFromElementsObj(obj: Obj) { return Object.entries(obj).map(([name, { algebraicType }]) => ({ @@ -903,21 +1156,54 @@ export class ProductBuilder extends TypeBuilder< }) ); } + default( + value: TypeScriptTypeFromElementsObj + ): ProductColumnBuilder> { + return new ProductColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } +} + +export class RowBuilder extends TypeBuilder< + TypeScriptTypeFromRowObj, + { + tag: 'Product'; + value: { elements: ElementsArrayFromRowObj }; + } +> { + constructor(row: Row) { + function elementsArrayFromRowObj(obj: Obj) { + return Object.entries(obj).map(([name, innerObj]) => { + let innerType: AlgebraicType; + if (innerObj instanceof ColumnBuilder) { + innerType = innerObj.typeBuilder.algebraicType; + } else { + innerType = innerObj.algebraicType; + } + return { + name, + algebraicType: innerType!, + }; + }); + } + super( + AlgebraicType.Product({ + elements: elementsArrayFromRowObj(row) as ElementsArrayFromRowObj, + }) + ); + } } export class SumBuilder extends TypeBuilder< TypeScriptTypeFromVariantsObj, { tag: 'Sum'; value: { variants: VariantsArrayFromVariantsObj } } > { - /** - * The phantom variant types of the sum for TypeScript - */ - readonly variants!: Variants; - constructor(variants: Variants) { function variantsArrayFromVariantsObj( variants: Variants - ): SumTypeVariant[] { + ) { return Object.entries(variants).map(([name, { algebraicType }]) => ({ name, algebraicType, @@ -929,664 +1215,1278 @@ export class SumBuilder extends TypeBuilder< }) ); } -} - -/** - * The type of index types that can be applied to a column. - * `undefined` is the default - */ -type IndexTypes = 'btree' | 'hash' | undefined; - -/** - * Metadata describing column constraints and index type - */ -export type ColumnMetadata = { - isPrimaryKey?: true; - isUnique?: true; - isAutoIncrement?: true; - isScheduleAt?: true; - indexType?: IndexTypes; -}; - -/** - * Default metadata state type for a newly created column - */ -type DefaultMetadata = object; - -/** - * Default metadata state value for a newly created column - */ -const defaultMetadata: DefaultMetadata = {}; - -/** - * A column builder allows you to incrementally specify constraints - * and metadata for a column in a type-safe way. - * - * It carries both a phantom TypeScript type (the `Type`) and - * runtime algebraic type information. - */ -export class ColumnBuilder< - Type, - SpacetimeType extends AlgebraicType, - M extends ColumnMetadata = DefaultMetadata, -> { - /** - * The TypeScript phantom type. This is not stored at runtime, - * but is visible to the compiler - */ - readonly columnMetadataType!: M; - - typeBuilder: TypeBuilder; - columnMetadata: ColumnMetadata; - - constructor( - typeBuilder: TypeBuilder, - metadata?: ColumnMetadata - ) { - this.typeBuilder = typeBuilder; - this.columnMetadata = metadata ?? defaultMetadata; + default( + value: TypeScriptTypeFromVariantsObj + ): SumColumnBuilder> { + return new SumColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); } } -export class U8ColumnBuilder - extends ColumnBuilder +export class IdentityBuilder + extends TypeBuilder implements - Indexable, - Uniqueable, + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + constructor() { + super(Identity.getAlgebraicType()); + } + index(): IdentityColumnBuilder>; + index>( + algorithm: N + ): IdentityColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): IdentityColumnBuilder> { + return new IdentityColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); + } + unique(): IdentityColumnBuilder> { + return new IdentityColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); + } + primaryKey(): IdentityColumnBuilder< + Set + > { + return new IdentityColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } + autoInc(): IdentityColumnBuilder< + Set + > { + return new IdentityColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: Identity + ): IdentityColumnBuilder> { + return new IdentityColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } +} + +export class ConnectionIdBuilder + extends TypeBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + constructor() { + super(ConnectionId.getAlgebraicType()); + } + index(): ConnectionIdColumnBuilder< + Set + >; + index>( + algorithm: N + ): ConnectionIdColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): ConnectionIdColumnBuilder> { + return new ConnectionIdColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); + } + unique(): ConnectionIdColumnBuilder> { + return new ConnectionIdColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); + } + primaryKey(): ConnectionIdColumnBuilder< + Set + > { + return new ConnectionIdColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } + autoInc(): ConnectionIdColumnBuilder< + Set + > { + return new ConnectionIdColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: ConnectionId + ): ConnectionIdColumnBuilder< + Set + > { + return new ConnectionIdColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } +} + +export class TimestampBuilder + extends TypeBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + constructor() { + super(Timestamp.getAlgebraicType()); + } + index(): TimestampColumnBuilder>; + index>( + algorithm: N + ): TimestampColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): TimestampColumnBuilder> { + return new TimestampColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); + } + unique(): TimestampColumnBuilder> { + return new TimestampColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); + } + primaryKey(): TimestampColumnBuilder< + Set + > { + return new TimestampColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } + autoInc(): TimestampColumnBuilder< + Set + > { + return new TimestampColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: Timestamp + ): TimestampColumnBuilder> { + return new TimestampColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } +} + +export class TimeDurationBuilder + extends TypeBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + constructor() { + super(TimeDuration.getAlgebraicType()); + } + index(): TimeDurationColumnBuilder< + Set + >; + index>( + algorithm: N + ): TimeDurationColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): TimeDurationColumnBuilder> { + return new TimeDurationColumnBuilder( + this, + set(defaultMetadata, { indexType: algorithm }) + ); + } + unique(): TimeDurationColumnBuilder> { + return new TimeDurationColumnBuilder( + this, + set(defaultMetadata, { isUnique: true }) + ); + } + primaryKey(): TimeDurationColumnBuilder< + Set + > { + return new TimeDurationColumnBuilder( + this, + set(defaultMetadata, { isPrimaryKey: true }) + ); + } + autoInc(): TimeDurationColumnBuilder< + Set + > { + return new TimeDurationColumnBuilder( + this, + set(defaultMetadata, { isAutoIncrement: true }) + ); + } + default( + value: TimeDuration + ): TimeDurationColumnBuilder< + Set + > { + return new TimeDurationColumnBuilder( + this, + set(defaultMetadata, { defaultValue: value }) + ); + } +} + +/** + * The type of index types that can be applied to a column. + * `undefined` is the default + */ +export type IndexTypes = 'btree' | 'direct' | undefined; + +/** + * Metadata describing column constraints and index type + */ +export type ColumnMetadata = { + isPrimaryKey?: true; + isUnique?: true; + isAutoIncrement?: true; + isScheduleAt?: true; + indexType?: IndexTypes; + defaultValue?: Type; +}; + +/** + * Default metadata state type for a newly created column + */ +type DefaultMetadata = object; + +/** + * Default metadata state value for a newly created column + */ +const defaultMetadata: ColumnMetadata = {}; + +/** + * A column builder allows you to incrementally specify constraints + * and metadata for a column in a type-safe way. + * + * It carries both a phantom TypeScript type (the `Type`) and + * runtime algebraic type information. + */ +export class ColumnBuilder< + Type, + SpacetimeType extends AlgebraicType, + M extends ColumnMetadata = DefaultMetadata, +> { + typeBuilder: TypeBuilder; + columnMetadata: M; + + constructor(typeBuilder: TypeBuilder, metadata: M) { + this.typeBuilder = typeBuilder; + this.columnMetadata = metadata; + } +} + +export class U8ColumnBuilder = DefaultMetadata> + extends ColumnBuilder + implements + Indexable, + Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): U8ColumnBuilder> { - return new U8ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): U8ColumnBuilder>; + index>( + algorithm: N + ): U8ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U8ColumnBuilder> { + return new U8ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): U8ColumnBuilder> { - return new U8ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new U8ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): U8ColumnBuilder> { - return new U8ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isPrimaryKey: true, - }); + return new U8ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); } autoInc(): U8ColumnBuilder> { - return new U8ColumnBuilder>( + return new U8ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: number): U8ColumnBuilder> { + return new U8ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class U16ColumnBuilder +export class U16ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): U16ColumnBuilder> { - return new U16ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): U16ColumnBuilder>; + index>( + algorithm: N + ): U16ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U16ColumnBuilder> { + return new U16ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): U16ColumnBuilder> { - return new U16ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new U16ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): U16ColumnBuilder> { - return new U16ColumnBuilder>( + return new U16ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): U16ColumnBuilder> { - return new U16ColumnBuilder>( + return new U16ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: number): U16ColumnBuilder> { + return new U16ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class U32ColumnBuilder +export class U32ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): U32ColumnBuilder> { - return new U32ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): U32ColumnBuilder>; + index>( + algorithm: N + ): U32ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U32ColumnBuilder> { + return new U32ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): U32ColumnBuilder> { - return new U32ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new U32ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): U32ColumnBuilder> { - return new U32ColumnBuilder>( + return new U32ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): U32ColumnBuilder> { - return new U32ColumnBuilder>( + return new U32ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: number): U32ColumnBuilder> { + return new U32ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class U64ColumnBuilder +export class U64ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): U64ColumnBuilder> { - return new U64ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): U64ColumnBuilder>; + index>( + algorithm: N + ): U64ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U64ColumnBuilder> { + return new U64ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): U64ColumnBuilder> { - return new U64ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new U64ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): U64ColumnBuilder> { - return new U64ColumnBuilder>( + return new U64ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): U64ColumnBuilder> { - return new U64ColumnBuilder>( + return new U64ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: bigint): U64ColumnBuilder> { + return new U64ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class U128ColumnBuilder +export class U128ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): U128ColumnBuilder> { - return new U128ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): U128ColumnBuilder>; + index>( + algorithm: N + ): U128ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U128ColumnBuilder> { + return new U128ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): U128ColumnBuilder> { - return new U128ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new U128ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): U128ColumnBuilder> { - return new U128ColumnBuilder>( + return new U128ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): U128ColumnBuilder> { - return new U128ColumnBuilder>( + return new U128ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: bigint): U128ColumnBuilder> { + return new U128ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class U256ColumnBuilder +export class U256ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): U256ColumnBuilder> { - return new U256ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): U256ColumnBuilder>; + index>( + algorithm: N + ): U256ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): U256ColumnBuilder> { + return new U256ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): U256ColumnBuilder> { - return new U256ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new U256ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): U256ColumnBuilder> { - return new U256ColumnBuilder>( + return new U256ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): U256ColumnBuilder> { - return new U256ColumnBuilder>( + return new U256ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: bigint): U256ColumnBuilder> { + return new U256ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class I8ColumnBuilder +export class I8ColumnBuilder = DefaultMetadata> extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): I8ColumnBuilder> { - return new I8ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): I8ColumnBuilder>; + index>( + algorithm: N + ): I8ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I8ColumnBuilder> { + return new I8ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): I8ColumnBuilder> { - return new I8ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new I8ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): I8ColumnBuilder> { - return new I8ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isPrimaryKey: true, - }); + return new I8ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); } autoInc(): I8ColumnBuilder> { - return new I8ColumnBuilder>( + return new I8ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: number): I8ColumnBuilder> { + return new I8ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class I16ColumnBuilder +export class I16ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): I16ColumnBuilder> { - return new I16ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): I16ColumnBuilder>; + index>( + algorithm: N + ): I16ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I16ColumnBuilder> { + return new I16ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): I16ColumnBuilder> { - return new I16ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new I16ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): I16ColumnBuilder> { - return new I16ColumnBuilder>( + return new I16ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): I16ColumnBuilder> { - return new I16ColumnBuilder>( + return new I16ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: number): I16ColumnBuilder> { + return new I16ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class I32ColumnBuilder +export class I32ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): I32ColumnBuilder> { - return new I32ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): I32ColumnBuilder>; + index>( + algorithm: N + ): I32ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I32ColumnBuilder> { + return new I32ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): I32ColumnBuilder> { - return new I32ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new I32ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): I32ColumnBuilder> { - return new I32ColumnBuilder>( + return new I32ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): I32ColumnBuilder> { - return new I32ColumnBuilder>( + return new I32ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: number): I32ColumnBuilder> { + return new I32ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class I64ColumnBuilder +export class I64ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): I64ColumnBuilder> { - return new I64ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): I64ColumnBuilder>; + index>( + algorithm: N + ): I64ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I64ColumnBuilder> { + return new I64ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): I64ColumnBuilder> { - return new I64ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new I64ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): I64ColumnBuilder> { - return new I64ColumnBuilder>( + return new I64ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): I64ColumnBuilder> { - return new I64ColumnBuilder>( + return new I64ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: bigint): I64ColumnBuilder> { + return new I64ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class I128ColumnBuilder +export class I128ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index< - M extends ColumnMetadata = DefaultMetadata, - N extends IndexTypes = 'btree', - >(algorithm?: N): I128ColumnBuilder> { - return new I128ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): I128ColumnBuilder>; + index>( + algorithm: N + ): I128ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I128ColumnBuilder> { + return new I128ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): I128ColumnBuilder> { - return new I128ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new I128ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): I128ColumnBuilder> { - return new I128ColumnBuilder>( + return new I128ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): I128ColumnBuilder> { - return new I128ColumnBuilder>( + return new I128ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: bigint): I128ColumnBuilder> { + return new I128ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class I256ColumnBuilder +export class I256ColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, PrimaryKeyable, - AutoIncrementable + AutoIncrementable, + Defaultable { - index( - algorithm?: N - ): I256ColumnBuilder> { - return new I256ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): I256ColumnBuilder>; + index>( + algorithm: N + ): I256ColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): I256ColumnBuilder> { + return new I256ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): I256ColumnBuilder> { - return new I256ColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new I256ColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): I256ColumnBuilder> { - return new I256ColumnBuilder>( + return new I256ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } autoInc(): I256ColumnBuilder> { - return new I256ColumnBuilder>( + return new I256ColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isAutoIncrement: true, - } + set(this.columnMetadata, { isAutoIncrement: true }) ); } + default(value: bigint): I256ColumnBuilder> { + return new I256ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } export class F32ColumnBuilder< - M extends ColumnMetadata = DefaultMetadata, -> extends ColumnBuilder {} + M extends ColumnMetadata = DefaultMetadata, + > + extends ColumnBuilder + implements Defaultable +{ + default(value: number): F32ColumnBuilder> { + return new F32ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} + export class F64ColumnBuilder< - M extends ColumnMetadata = DefaultMetadata, -> extends ColumnBuilder {} -export class BoolColumnBuilder + M extends ColumnMetadata = DefaultMetadata, + > + extends ColumnBuilder + implements Defaultable +{ + default(value: number): F64ColumnBuilder> { + return new F64ColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} + +export class BoolColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, - PrimaryKeyable + PrimaryKeyable, + Defaultable { - index< - M extends ColumnMetadata = DefaultMetadata, - N extends IndexTypes = 'btree', - >(algorithm?: N): BoolColumnBuilder> { - return new BoolColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): BoolColumnBuilder>; + index>( + algorithm: N + ): BoolColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): BoolColumnBuilder> { + return new BoolColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): BoolColumnBuilder> { - return new BoolColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new BoolColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): BoolColumnBuilder> { - return new BoolColumnBuilder>( + return new BoolColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } + default(value: boolean): BoolColumnBuilder> { + return new BoolColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } -export class StringColumnBuilder +export class StringColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > extends ColumnBuilder implements Indexable, Uniqueable, - PrimaryKeyable + PrimaryKeyable, + Defaultable { - index< - M extends ColumnMetadata = DefaultMetadata, - N extends IndexTypes = 'btree', - >(algorithm?: N): StringColumnBuilder> { - return new StringColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - indexType: algorithm, - }); + index(): StringColumnBuilder>; + index>( + algorithm: N + ): StringColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): StringColumnBuilder> { + return new StringColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); } unique(): StringColumnBuilder> { - return new StringColumnBuilder>(this.typeBuilder, { - ...this.columnMetadata, - isUnique: true, - }); + return new StringColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); } primaryKey(): StringColumnBuilder> { - return new StringColumnBuilder>( + return new StringColumnBuilder( this.typeBuilder, - { - ...this.columnMetadata, - isPrimaryKey: true, - } + set(this.columnMetadata, { isPrimaryKey: true }) ); } + default(value: string): StringColumnBuilder> { + return new StringColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } } export class ArrayColumnBuilder< - Element extends TypeBuilder, - M extends ColumnMetadata = DefaultMetadata, -> extends ColumnBuilder< - Array, - { tag: 'Array'; value: Element['spacetimeType'] }, - M -> {} + Element extends TypeBuilder, + M extends ColumnMetadata< + Array> + > = DefaultMetadata, + > + extends ColumnBuilder< + Array>, + { tag: 'Array'; value: InferSpacetimeTypeOfTypeBuilder }, + M + > + implements + Defaultable< + Array>, + AlgebraicTypeVariants.Array + > +{ + default( + value: Array> + ): ArrayColumnBuilder< + Element, + Set>> + > { + return new ArrayColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} + +export class OptionColumnBuilder< + Value extends TypeBuilder, + M extends ColumnMetadata< + InferTypeOfTypeBuilder | undefined + > = DefaultMetadata, + > + extends ColumnBuilder< + InferTypeOfTypeBuilder | undefined, + OptionAlgebraicType, + M + > + implements + Defaultable | undefined, OptionAlgebraicType> +{ + default( + value: InferTypeOfTypeBuilder | undefined + ): OptionColumnBuilder< + InferTypeOfTypeBuilder, + Set | undefined> + > { + return new OptionColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} export class ProductColumnBuilder< - Elements extends Array<{ name: string; algebraicType: AlgebraicType }>, - M extends ColumnMetadata = DefaultMetadata, -> extends ColumnBuilder< - { [K in Elements[number]['name']]: any }, - { tag: 'Product'; value: { elements: Elements } }, - M -> {} + Elements extends ElementsObj, + M extends ColumnMetadata< + TypeScriptTypeFromElementsObj + > = DefaultMetadata, + > + extends ColumnBuilder< + TypeScriptTypeFromElementsObj, + { + tag: 'Product'; + value: { elements: ElementsArrayFromElementsObj }; + }, + M + > + implements + Defaultable< + TypeScriptTypeFromElementsObj, + AlgebraicTypeVariants.Product + > +{ + default( + value: TypeScriptTypeFromElementsObj + ): ProductColumnBuilder> { + return new ProductColumnBuilder( + this.typeBuilder, + set(defaultMetadata, { defaultValue: value }) + ); + } +} export class SumColumnBuilder< - Variants extends Array<{ name: string; algebraicType: AlgebraicType }>, - M extends ColumnMetadata = DefaultMetadata, -> extends ColumnBuilder< - { - [K in Variants[number]['name']]: { tag: K; value: any }; - }[Variants[number]['name']], - { tag: 'Sum'; value: { variants: Variants } }, - M -> {} + Variants extends VariantsObj, + M extends ColumnMetadata< + TypeScriptTypeFromVariantsObj + > = DefaultMetadata, + > + extends ColumnBuilder< + TypeScriptTypeFromVariantsObj, + { tag: 'Sum'; value: { variants: VariantsArrayFromVariantsObj } }, + M + > + implements + Defaultable< + TypeScriptTypeFromVariantsObj, + AlgebraicTypeVariants.Sum + > +{ + default( + value: TypeScriptTypeFromVariantsObj + ): SumColumnBuilder> { + return new SumColumnBuilder( + this.typeBuilder, + set(defaultMetadata, { defaultValue: value }) + ); + } +} + +export class IdentityColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > + extends ColumnBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + index(): IdentityColumnBuilder>; + index>( + algorithm: N + ): IdentityColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): IdentityColumnBuilder> { + return new IdentityColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); + } + unique(): IdentityColumnBuilder> { + return new IdentityColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); + } + primaryKey(): IdentityColumnBuilder> { + return new IdentityColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); + } + default( + value: Identity + ): IdentityColumnBuilder> { + return new IdentityColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} + +export class ConnectionIdColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > + extends ColumnBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + index(): ConnectionIdColumnBuilder>; + index>( + algorithm: N + ): ConnectionIdColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): ConnectionIdColumnBuilder> { + return new ConnectionIdColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); + } + unique(): ConnectionIdColumnBuilder> { + return new ConnectionIdColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); + } + primaryKey(): ConnectionIdColumnBuilder> { + return new ConnectionIdColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); + } + default( + value: ConnectionId + ): ConnectionIdColumnBuilder> { + return new ConnectionIdColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} + +export class TimestampColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > + extends ColumnBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + index(): TimestampColumnBuilder>; + index>( + algorithm: N + ): TimestampColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): TimestampColumnBuilder> { + return new TimestampColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); + } + unique(): TimestampColumnBuilder> { + return new TimestampColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); + } + primaryKey(): TimestampColumnBuilder> { + return new TimestampColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); + } + default( + value: Timestamp + ): TimestampColumnBuilder> { + return new TimestampColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} + +export class TimeDurationColumnBuilder< + M extends ColumnMetadata = DefaultMetadata, + > + extends ColumnBuilder + implements + Indexable, + Uniqueable, + PrimaryKeyable, + Defaultable +{ + index(): TimeDurationColumnBuilder>; + index>( + algorithm: N + ): TimeDurationColumnBuilder>; + index( + algorithm: IndexTypes = 'btree' + ): TimeDurationColumnBuilder> { + return new TimeDurationColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { indexType: algorithm }) + ); + } + unique(): TimeDurationColumnBuilder> { + return new TimeDurationColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isUnique: true }) + ); + } + primaryKey(): TimeDurationColumnBuilder> { + return new TimeDurationColumnBuilder( + this.typeBuilder, + set(this.columnMetadata, { isPrimaryKey: true }) + ); + } + default( + value: TimeDuration + ): TimeDurationColumnBuilder> { + return new TimeDurationColumnBuilder(this.typeBuilder, { + ...this.columnMetadata, + defaultValue: value, + }); + } +} /** * A collection of factory functions for creating various SpacetimeDB algebraic types @@ -1605,7 +2505,7 @@ export class SumColumnBuilder< * * @see {@link TypeBuilder} */ -const t = { +export const t = { /** * Creates a new `Bool` {@link AlgebraicType} to be used in table definitions * Represented as `boolean` in TypeScript. @@ -1735,7 +2635,27 @@ const t = { * @returns A new {@link ProductBuilder} instance */ object(obj: Obj): ProductBuilder { - return new ProductBuilder(obj); + return new ProductBuilder(obj); + }, + + /** + * Creates a new `Row` {@link AlgebraicType} to be used in table definitions. Row types in SpacetimeDB + * are similar to `Product` types, but are specifically used to define the schema of a table row. + * Properties of the object must also be {@link TypeBuilder} or {@link ColumnBuilder}s. + * + * You can represent a `Row` as either a {@link RowObj} or an {@link RowBuilder} type when + * defining a table schema. + * + * The {@link RowBuilder} type is useful when you want to create a type which can be used anywhere + * a {@link TypeBuilder} is accepted, such as in nested objects or arrays, or as the argument + * to a scheduled function. + * + * @param obj The object defining the properties of the row, whose property + * values must be {@link TypeBuilder}s or {@link ColumnBuilder}s. + * @returns A new {@link RowBuilder} instance + */ + row(obj: Obj): RowBuilder { + return new RowBuilder(obj); }, /** @@ -1747,7 +2667,7 @@ const t = { array>( e: Element ): ArrayBuilder { - return new ArrayBuilder(e); + return new ArrayBuilder(e); }, /** @@ -1760,7 +2680,16 @@ const t = { * @returns A new {@link SumBuilder} instance */ enum(obj: Obj): SumBuilder { - return new SumBuilder(obj); + return new SumBuilder(obj); + }, + + /** + * This is a special helper function for conveniently creating {@link Product} type columns with no fields. + * + * @returns A new {@link ProductBuilder} instance with no fields. + */ + unit(): ProductBuilder<{}> { + return new ProductBuilder({}); }, /** @@ -1770,37 +2699,33 @@ const t = { scheduleAt: (): ColumnBuilder< ScheduleAt, ReturnType, - Omit & { isScheduleAt: true } + Omit, 'isScheduleAt'> & { isScheduleAt: true } > => { - return new ColumnBuilder< - ScheduleAt, - ReturnType, - Omit & { isScheduleAt: true } - >( - new TypeBuilder< - ScheduleAt, - ReturnType - >(ScheduleAt.getAlgebraicType()), - { - ...defaultMetadata, - isScheduleAt: true, - } + return new ColumnBuilder( + new TypeBuilder(ScheduleAt.getAlgebraicType()), + set(defaultMetadata, { isScheduleAt: true }) ); }, + /** + * This is a convenience method for creating a column with the {@link Option} type. + * You can create a column of the same type by constructing an enum with a `some` and `none` variant. + * @param value The type of the value contained in the `some` variant of the `Option`. + * @returns A new {@link OptionBuilder} instance with the {@link Option} type. + */ + option>( + value: Value + ): OptionBuilder { + return new OptionBuilder(value); + }, + /** * This is a convenience method for creating a column with the {@link Identity} type. * You can create a column of the same type by constructing an `object` with a single `__identity__` element. * @returns A new {@link TypeBuilder} instance with the {@link Identity} type. */ - identity: (): TypeBuilder< - Identity, - AlgebraicTypeVariants.Product & { value: typeof Identity } - > => { - return new TypeBuilder< - Identity, - AlgebraicTypeVariants.Product & { value: typeof Identity } - >(Identity.getAlgebraicType()); + identity: (): IdentityBuilder => { + return new IdentityBuilder(); }, /** @@ -1808,14 +2733,8 @@ const t = { * You can create a column of the same type by constructing an `object` with a single `__connection_id__` element. * @returns A new {@link TypeBuilder} instance with the {@link ConnectionId} type. */ - connectionId: (): TypeBuilder< - string, - AlgebraicTypeVariants.Product & { value: typeof ConnectionId } - > => { - return new TypeBuilder< - string, - AlgebraicTypeVariants.Product & { value: typeof ConnectionId } - >(ConnectionId.getAlgebraicType()); + connectionId: (): ConnectionIdBuilder => { + return new ConnectionIdBuilder(); }, /** @@ -1823,14 +2742,8 @@ const t = { * You can create a column of the same type by constructing an `object` with a single `__timestamp_micros_since_unix_epoch__` element. * @returns A new {@link TypeBuilder} instance with the {@link Timestamp} type. */ - timestamp: (): TypeBuilder< - Timestamp, - AlgebraicTypeVariants.Product & { value: typeof Timestamp } - > => { - return new TypeBuilder< - Timestamp, - AlgebraicTypeVariants.Product & { value: typeof Timestamp } - >(Timestamp.getAlgebraicType()); + timestamp: (): TimestampBuilder => { + return new TimestampBuilder(); }, /** @@ -1838,14 +2751,8 @@ const t = { * You can create a column of the same type by constructing an `object` with a single `__time_duration_micros__` element. * @returns A new {@link TypeBuilder} instance with the {@link TimeDuration} type. */ - timeDuration: (): TypeBuilder< - TimeDuration, - AlgebraicTypeVariants.Product & { value: typeof TimeDuration } - > => { - return new TypeBuilder< - TimeDuration, - AlgebraicTypeVariants.Product & { value: typeof TimeDuration } - >(TimeDuration.getAlgebraicType()); + timeDuration: (): TimeDurationBuilder => { + return new TimeDurationBuilder(); }, } as const; export default t; diff --git a/crates/bindings-typescript/src/server/type_util.ts b/crates/bindings-typescript/src/server/type_util.ts index b2c7192b69b..bf88c183d5d 100644 --- a/crates/bindings-typescript/src/server/type_util.ts +++ b/crates/bindings-typescript/src/server/type_util.ts @@ -10,15 +10,33 @@ export type Set = Prettify< Omit & { [K in F]: V } >; -type Equals = - (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 - ? true - : false; +/** + * Sets a field in an object + * @param x The original object + * @param t The object containing the field to set + * @returns A new object with the field set + */ +export function set( + x: T, + t: { [k in F]: V } +): Set { + return { ...x, ...t }; +} + +/** + * Helper to extract the value types from an object type + */ +export type Values = T[keyof T]; -export type DifferenceFromDefault = Prettify<{ - [K in keyof T as K extends keyof D - ? Equals extends true - ? never - : K - : K]: T[K]; -}>; +/** + * A Result type representing either a success or an error. + * - `ok: true` with `val`: Indicates a successful operation with the resulting value. + * - `ok: false` with `err`: Indicates a failed operation with the associated error. + */ +// TODO: Should we use the same `{ tag: 'Ok', value: T } | { tag: 'Err', value: E }` style as we have for other sum types? +export type Result = { ok: true; val: T } | { ok: false; err: E }; + +/** + * A helper type to collapse a tuple into a single type if it has only one element. + */ +export type CollapseTuple = A extends [infer T] ? T : A; diff --git a/crates/bindings-typescript/test-app/tsconfig.app.json b/crates/bindings-typescript/test-app/tsconfig.app.json index 26662381ec1..563997710d1 100644 --- a/crates/bindings-typescript/test-app/tsconfig.app.json +++ b/crates/bindings-typescript/test-app/tsconfig.app.json @@ -4,7 +4,7 @@ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, diff --git a/crates/bindings-typescript/tests/binary_read_write.test.ts b/crates/bindings-typescript/tests/binary_read_write.test.ts index 413714edc05..ecf316b56b7 100644 --- a/crates/bindings-typescript/tests/binary_read_write.test.ts +++ b/crates/bindings-typescript/tests/binary_read_write.test.ts @@ -1,5 +1,21 @@ import { describe, expect, test } from 'vitest'; -import { BinaryReader, BinaryWriter } from '../src/index'; +import { + AlgebraicType, + BinaryReader, + BinaryWriter, + ConnectionId, + TimeDuration, + Timestamp, +} from '../src/index'; +import * as ws from '../src/sdk/client_api'; +import { + anIdentity, + bobIdentity, + encodeCreatePlayerArgs, + encodeUser, + sallyIdentity, +} from './utils'; +import { ServerMessage } from '../src/sdk/client_api'; /* // Generated by the following Rust code: @@ -107,4 +123,61 @@ describe('BinaryReader/Writer', () => { expect(writer.getBuffer()).toEqual(arr); } }); + + test('correctly serializes and then deserializes a complicated web socket message', () => { + const user1 = { identity: bobIdentity, username: 'bob' }; + const user2 = { + identity: sallyIdentity, + username: 'sally', + }; + const binary = [...encodeUser(user1)].concat([...encodeUser(user2)]); + const transactionUpdate = ws.ServerMessage.TransactionUpdate({ + status: ws.UpdateStatus.Committed({ + tables: [ + { + tableId: 35, + tableName: 'user', + numRows: BigInt(1), + updates: [ + ws.CompressableQueryUpdate.Uncompressed({ + deletes: { + sizeHint: ws.RowSizeHint.FixedSize(0), // not used + rowsData: new Uint8Array([]), + }, + // FIXME: this test is evil: an initial subscription can never contain deletes or updates. + inserts: { + sizeHint: ws.RowSizeHint.FixedSize(0), // not used + rowsData: new Uint8Array(binary), + }, + }), + ], + }, + ], + }), + timestamp: new Timestamp(1681391805281203n), + callerIdentity: anIdentity, + callerConnectionId: ConnectionId.random(), + reducerCall: { + reducerName: 'create_player', + reducerId: 0, + args: encodeCreatePlayerArgs('A Player', { x: 2, y: 3 }), + requestId: 0, + }, + energyQuantaUsed: { quanta: BigInt(33841000) }, + totalHostExecutionDuration: new TimeDuration(BigInt(1234567890)), + }); + const writer = new BinaryWriter(1024); + AlgebraicType.serializeValue( + writer, + ServerMessage.getTypeScriptAlgebraicType(), + transactionUpdate + ); + const rawBytes = writer.getBuffer(); + + const deserializedTransactionUpdate = AlgebraicType.deserializeValue( + new BinaryReader(rawBytes), + ServerMessage.getTypeScriptAlgebraicType() + ); + expect(deserializedTransactionUpdate).toEqual(transactionUpdate); + }); }); diff --git a/crates/bindings-typescript/tests/db_connection.test.ts b/crates/bindings-typescript/tests/db_connection.test.ts index d680ec781f5..02dff03d690 100644 --- a/crates/bindings-typescript/tests/db_connection.test.ts +++ b/crates/bindings-typescript/tests/db_connection.test.ts @@ -9,22 +9,18 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { ConnectionId } from '../src'; import { Timestamp } from '../src'; import { TimeDuration } from '../src'; -import { AlgebraicType } from '../src'; -import { BinaryWriter } from '../src'; import * as ws from '../src/sdk/client_api'; import type { ReducerEvent } from '../src/sdk/db_connection_impl'; import { Identity } from '../src'; import WebsocketTestAdapter from '../src/sdk/websocket_test_adapter'; - -const anIdentity = Identity.fromString( - '0000000000000000000000000000000000000000000000000000000000000069' -); -const bobIdentity = Identity.fromString( - '0000000000000000000000000000000000000000000000000000000000000b0b' -); -const sallyIdentity = Identity.fromString( - '000000000000000000000000000000000000000000000000000000000006a111' -); +import { + anIdentity, + bobIdentity, + encodeCreatePlayerArgs, + encodePlayer, + encodeUser, + sallyIdentity, +} from './utils'; class Deferred { #isResolved: boolean = false; @@ -69,25 +65,6 @@ class Deferred { beforeEach(() => {}); -function encodePlayer(value: Player): Uint8Array { - const writer = new BinaryWriter(1024); - Player.serialize(writer, value); - return writer.getBuffer(); -} - -function encodeUser(value: User): Uint8Array { - const writer = new BinaryWriter(1024); - User.serialize(writer, value); - return writer.getBuffer(); -} - -function encodeCreatePlayerArgs(name: string, location: Point): Uint8Array { - const writer = new BinaryWriter(1024); - AlgebraicType.serializeValue(writer, AlgebraicType.String, name); - Point.serialize(writer, location); - return writer.getBuffer(); -} - describe('DbConnection', () => { test('call onConnectError callback after websocket connection failed to be established', async () => { const onConnectErrorPromise = new Deferred(); diff --git a/crates/bindings-typescript/tests/utils.ts b/crates/bindings-typescript/tests/utils.ts new file mode 100644 index 00000000000..19461b384cf --- /dev/null +++ b/crates/bindings-typescript/tests/utils.ts @@ -0,0 +1,34 @@ +import { AlgebraicType, BinaryWriter, Identity } from '../src'; +import { Player, Point, User } from '../test-app/src/module_bindings'; + +export const anIdentity = Identity.fromString( + '0000000000000000000000000000000000000000000000000000000000000069' +); +export const bobIdentity = Identity.fromString( + '0000000000000000000000000000000000000000000000000000000000000b0b' +); +export const sallyIdentity = Identity.fromString( + '000000000000000000000000000000000000000000000000000000000006a111' +); + +export function encodePlayer(value: Player): Uint8Array { + const writer = new BinaryWriter(1024); + Player.serialize(writer, value); + return writer.getBuffer(); +} + +export function encodeUser(value: User): Uint8Array { + const writer = new BinaryWriter(1024); + User.serialize(writer, value); + return writer.getBuffer(); +} + +export function encodeCreatePlayerArgs( + name: string, + location: Point +): Uint8Array { + const writer = new BinaryWriter(1024); + AlgebraicType.serializeValue(writer, AlgebraicType.String, name); + Point.serialize(writer, location); + return writer.getBuffer(); +} diff --git a/crates/bindings-typescript/tsup.config.ts b/crates/bindings-typescript/tsup.config.ts index 68dde97bba0..67de49121b1 100644 --- a/crates/bindings-typescript/tsup.config.ts +++ b/crates/bindings-typescript/tsup.config.ts @@ -120,6 +120,7 @@ export default defineConfig([ platform: 'neutral', // flip to 'node' if you actually rely on Node builtins treeshake: 'smallest', external: ['undici'], + noExternal: ['base64-js'], outExtension, esbuildOptions: commonEsbuildTweaks(), }, diff --git a/crates/bindings/src/table.rs b/crates/bindings/src/table.rs index fd281160ba3..019a71dc961 100644 --- a/crates/bindings/src/table.rs +++ b/crates/bindings/src/table.rs @@ -947,7 +947,7 @@ fn insert(mut row: T::Row, mut buf: IterBuf) -> Result { T::UniqueConstraintViolation::get().map(TryInsertError::UniqueConstraintViolation) } - // sys::Errno::AUTO_INC_OVERFLOW => Tbl::AutoIncOverflow::get().map(TryInsertError::AutoIncOverflow), + sys::Errno::AUTO_INC_OVERFLOW => T::AutoIncOverflow::get().map(TryInsertError::AutoIncOverflow), _ => None, }; err.unwrap_or_else(|| panic!("unexpected insertion error: {e}")) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index df63923eb59..0274423abf3 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -46,8 +46,19 @@ pub fn cli() -> clap::Command { .short('b') .conflicts_with("project_path") .conflicts_with("build_options") + .conflicts_with("js_file") .help("The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project."), ) + .arg( + Arg::new("js_file") + .value_parser(clap::value_parser!(PathBuf)) + .long("js-path") + .short('j') + .conflicts_with("project_path") + .conflicts_with("build_options") + .conflicts_with("wasm_file") + .help("UNSTABLE: The system path (absolute or relative) to the javascript file we should publish, instead of building the project."), + ) .arg( Arg::new("num_replicas") .value_parser(clap::value_parser!(u8)) @@ -84,6 +95,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let force = args.get_flag("force"); let anon_identity = args.get_flag("anon_identity"); let wasm_file = args.get_one::("wasm_file"); + let js_file = args.get_one::("js_file"); let database_host = config.get_host_url(server)?; let build_options = args.get_one::("build_options").unwrap(); let num_replicas = args.get_one::("num_replicas"); @@ -115,13 +127,24 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E )); } - let path_to_wasm = if let Some(path) = wasm_file { - println!("Skipping build. Instead we are publishing {}", path.display()); - path.clone() + // Decide program file path and read program. + // Optionally build the program. + let (path_to_program, host_type) = if let Some(path) = wasm_file { + println!("(WASM) Skipping build. Instead we are publishing {}", path.display()); + (path.clone(), None) + } else if let Some(path) = js_file { + println!("(JS) Skipping build. Instead we are publishing {}", path.display()); + (path.clone(), Some("Js")) } else { - build::exec_with_argstring(config.clone(), path_to_project, build_options).await? + let path = build::exec_with_argstring(config.clone(), path_to_project, build_options).await?; + (path, None) }; - let program_bytes = fs::read(path_to_wasm)?; + let program_bytes = fs::read(path_to_program)?; + + // The host type is not the default (WASM). + if let Some(host_type) = host_type { + builder = builder.query(&[("host_type", host_type)]); + } let server_address = { let url = Url::parse(&database_host)?; diff --git a/crates/client-api/Cargo.toml b/crates/client-api/Cargo.toml index 13c6bb816da..be32c90e9c3 100644 --- a/crates/client-api/Cargo.toml +++ b/crates/client-api/Cargo.toml @@ -64,3 +64,6 @@ toml.workspace = true [lints] workspace = true + +[features] +unstable = [] diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 5ff5102d355..77eb81a03ab 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -500,6 +500,8 @@ pub struct PublishDatabaseQueryParams { token: Option, #[serde(default)] policy: MigrationPolicy, + #[serde(default)] + host_type: HostType, } use spacetimedb_client_api_messages::http::SqlStmtResult; @@ -537,10 +539,23 @@ pub async fn publish( num_replicas, token, policy, + host_type, }): Query, Extension(auth): Extension, body: Bytes, ) -> axum::response::Result> { + // Feature gate V8 modules. + // The host must've been compiled with the `unstable` feature. + // TODO(v8): ungate this when V8 is ready to ship. + #[cfg(not(feature = "unstable"))] + if host_type == HostType::Js { + return Err(( + StatusCode::BAD_REQUEST, + "JS host type requires a host with unstable features", + ) + .into()); + } + // You should not be able to publish to a database that you do not own // so, unless you are the owner, this will fail. @@ -645,7 +660,7 @@ pub async fn publish( database_identity, program_bytes: body.into(), num_replicas, - host_type: HostType::Wasm, + host_type, }, policy, ) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 142a19e7484..924a6ee53d9 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -49,6 +49,7 @@ bytemuck.workspace = true bytes.workspace = true bytestring.workspace = true chrono.workspace = true +convert_case.workspace = true crossbeam-channel.workspace = true crossbeam-queue.workspace = true derive_more.workspace = true diff --git a/crates/core/src/host/instance_env.rs b/crates/core/src/host/instance_env.rs index 4d6863a7f27..bcc552003ba 100644 --- a/crates/core/src/host/instance_env.rs +++ b/crates/core/src/host/instance_env.rs @@ -1,13 +1,14 @@ use super::scheduler::{get_schedule_from_row, ScheduleError, Scheduler}; -use crate::database_logger::{BacktraceProvider, LogLevel, Record}; +use crate::database_logger::{BacktraceFrame, BacktraceProvider, LogLevel, ModuleBacktrace, Record}; use crate::db::relational_db::{MutTx, RelationalDB}; use crate::error::{DBError, DatastoreError, IndexError, NodesError}; +use crate::host::wasm_common::TimingSpan; use crate::replica_context::ReplicaContext; use core::mem; use parking_lot::{Mutex, MutexGuard}; use smallvec::SmallVec; use spacetimedb_datastore::locking_tx_datastore::MutTxId; -use spacetimedb_lib::Timestamp; +use spacetimedb_lib::{Identity, Timestamp}; use spacetimedb_primitives::{ColId, ColList, IndexId, TableId}; use spacetimedb_sats::{ bsatn::{self, ToBsatn}, @@ -18,6 +19,7 @@ use spacetimedb_table::indexes::RowPointer; use spacetimedb_table::table::RowRef; use std::ops::DerefMut; use std::sync::Arc; +use std::vec::IntoIter; #[derive(Clone)] pub struct InstanceEnv { @@ -170,6 +172,11 @@ impl InstanceEnv { } } + /// Returns the database's identity. + pub fn database_identity(&self) -> &Identity { + &self.replica_ctx.database.database_identity + } + /// Signal to this `InstanceEnv` that a reducer call is beginning. pub fn start_reducer(&mut self, ts: Timestamp) { self.start_time = ts; @@ -180,7 +187,7 @@ impl InstanceEnv { } #[tracing::instrument(level = "trace", skip_all)] - pub fn console_log(&self, level: LogLevel, record: &Record, bt: &dyn BacktraceProvider) { + pub(crate) fn console_log(&self, level: LogLevel, record: &Record, bt: &dyn BacktraceProvider) { self.replica_ctx.logger.write(level, record, bt); log::trace!( "MOD({}): {}", @@ -189,6 +196,35 @@ impl InstanceEnv { ); } + /// End a console timer by logging the span at INFO level. + pub(crate) fn console_timer_end(&self, span: &TimingSpan, function: Option<&str>) { + let elapsed = span.start.elapsed(); + let message = format!("Timing span {:?}: {:?}", &span.name, elapsed); + + /// A backtrace provider that provides nothing. + struct Noop; + impl BacktraceProvider for Noop { + fn capture(&self) -> Box { + Box::new(Noop) + } + } + impl ModuleBacktrace for Noop { + fn frames(&self) -> Vec> { + Vec::new() + } + } + + let record = Record { + ts: chrono::Utc::now(), + target: None, + filename: None, + line_number: None, + function, + message: &message, + }; + self.console_log(LogLevel::Info, &record, &Noop); + } + /// Project `cols` in `row_ref` encoded in BSATN to `buffer` /// and return the full length of the BSATN. /// @@ -469,6 +505,33 @@ impl InstanceEnv { Ok(chunks) } + + pub fn fill_buffer_from_iter( + iter: &mut IntoIter>, + mut buffer: &mut [u8], + chunk_pool: &mut ChunkPool, + ) -> usize { + let mut written = 0; + // Fill the buffer as much as possible. + while let Some(chunk) = iter.as_slice().first() { + let Some((buf_chunk, rest)) = buffer.split_at_mut_checked(chunk.len()) else { + // Cannot fit chunk into the buffer, + // either because we already filled it too much, + // or because it is too small. + break; + }; + buf_chunk.copy_from_slice(chunk); + written += chunk.len(); + buffer = rest; + + // Advance the iterator, as we used a chunk. + // SAFETY: We peeked one `chunk`, so there must be one at least. + let chunk = unsafe { iter.next().unwrap_unchecked() }; + chunk_pool.put(chunk); + } + + written + } } impl TxSlot { diff --git a/crates/core/src/host/module_common.rs b/crates/core/src/host/module_common.rs index e93b68c1172..d5826ff54c5 100644 --- a/crates/core/src/host/module_common.rs +++ b/crates/core/src/host/module_common.rs @@ -5,6 +5,7 @@ use crate::{ energy::EnergyMonitor, host::{ module_host::{DynModule, ModuleInfo}, + wasm_common::{module_host_actor::DescribeError, DESCRIBE_MODULE_DUNDER}, Scheduler, }, module_host_context::ModuleCreationContext, @@ -88,3 +89,22 @@ impl DynModule for ModuleCommon { &self.scheduler } } + +/// Runs the describer of modules in `run` and does some logging around it. +pub(crate) fn run_describer( + log_traceback: impl FnOnce(&str, &str, &anyhow::Error), + run: impl FnOnce() -> anyhow::Result, +) -> Result { + let describer_func_name = DESCRIBE_MODULE_DUNDER; + let start = std::time::Instant::now(); + log::trace!("Start describer \"{describer_func_name}\"..."); + + let result = run(); + + let duration = start.elapsed(); + log::trace!("Describer \"{}\" ran: {} us", describer_func_name, duration.as_micros()); + + result + .inspect_err(|err| log_traceback("describer", describer_func_name, err)) + .map_err(DescribeError::RuntimeError) +} diff --git a/crates/core/src/host/v8/de.rs b/crates/core/src/host/v8/de.rs index 3103cf14095..5291ddc0406 100644 --- a/crates/core/src/host/v8/de.rs +++ b/crates/core/src/host/v8/de.rs @@ -2,7 +2,8 @@ use super::error::{exception_already_thrown, ExcResult, ExceptionThrown, ExceptionValue, Throwable, TypeError}; use super::from_value::{cast, FromValue}; -use super::key_cache::{get_or_create_key_cache, KeyCache}; +use super::string_const::{TAG, VALUE}; +use convert_case::{Case, Casing}; use core::fmt; use core::iter::{repeat_n, RepeatN}; use core::marker::PhantomData; @@ -11,39 +12,37 @@ use derive_more::From; use spacetimedb_sats::de::{self, ArrayVisitor, DeserializeSeed, ProductVisitor, SliceVisitor, SumVisitor}; use spacetimedb_sats::{i256, u256}; use std::borrow::{Borrow, Cow}; -use v8::{Array, HandleScope, Local, Name, Object, Uint8Array, Value}; +use v8::{Array, Local, Name, Object, PinScope, Uint8Array, Value}; /// Deserializes a `T` from `val` in `scope`, using `seed` for any context needed. pub(super) fn deserialize_js_seed<'de, T: DeserializeSeed<'de>>( - scope: &mut HandleScope<'de>, + scope: &mut PinScope<'de, '_>, val: Local<'_, Value>, seed: T, ) -> ExcResult { - let key_cache = get_or_create_key_cache(scope); - let key_cache = &mut *key_cache.borrow_mut(); - let de = Deserializer::new(scope, val, key_cache); + let de = Deserializer::new(scope, val); seed.deserialize(de).map_err(|e| e.throw(scope)) } /// Deserializes a `T` from `val` in `scope`. pub(super) fn deserialize_js<'de, T: de::Deserialize<'de>>( - scope: &mut HandleScope<'de>, + scope: &mut PinScope<'de, '_>, val: Local<'_, Value>, ) -> ExcResult { deserialize_js_seed(scope, val, PhantomData) } /// Deserializes from V8 values. -struct Deserializer<'this, 'scope> { - common: DeserializerCommon<'this, 'scope>, +struct Deserializer<'this, 'scope, 'isolate> { + common: DeserializerCommon<'this, 'scope, 'isolate>, input: Local<'scope, Value>, } -impl<'this, 'scope> Deserializer<'this, 'scope> { +impl<'this, 'scope, 'isolate> Deserializer<'this, 'scope, 'isolate> { /// Creates a new deserializer from `input` in `scope`. - fn new(scope: &'this mut HandleScope<'scope>, input: Local<'_, Value>, key_cache: &'this mut KeyCache) -> Self { + fn new(scope: &'this mut PinScope<'scope, 'isolate>, input: Local<'_, Value>) -> Self { let input = Local::new(scope, input); - let common = DeserializerCommon { scope, key_cache }; + let common = DeserializerCommon { scope }; Deserializer { input, common } } } @@ -51,19 +50,14 @@ impl<'this, 'scope> Deserializer<'this, 'scope> { /// Things shared between various [`Deserializer`]s. /// /// The lifetime `'scope` is that of the scope of values deserialized. -struct DeserializerCommon<'this, 'scope> { +struct DeserializerCommon<'this, 'scope, 'isolate> { /// The scope of values to deserialize. - scope: &'this mut HandleScope<'scope>, - /// A cache for frequently used strings. - key_cache: &'this mut KeyCache, + scope: &'this mut PinScope<'scope, 'isolate>, } -impl<'scope> DeserializerCommon<'_, 'scope> { - fn reborrow(&mut self) -> DeserializerCommon<'_, 'scope> { - DeserializerCommon { - scope: self.scope, - key_cache: self.key_cache, - } +impl<'scope, 'isolate> DeserializerCommon<'_, 'scope, 'isolate> { + fn reborrow(&mut self) -> DeserializerCommon<'_, 'scope, 'isolate> { + DeserializerCommon { scope: self.scope } } } @@ -76,7 +70,7 @@ enum Error<'scope> { } impl<'scope> Throwable<'scope> for Error<'scope> { - fn throw(self, scope: &mut HandleScope<'scope>) -> ExceptionThrown { + fn throw(self, scope: &PinScope<'scope, '_>) -> ExceptionThrown { match self { Self::Unthrown(exception) => exception.throw(scope), Self::Thrown(thrown) => thrown, @@ -92,7 +86,7 @@ impl de::Error for Error<'_> { } /// Returns a scratch buffer to fill when deserializing strings. -fn scratch_buf() -> [MaybeUninit; N] { +pub(crate) fn scratch_buf() -> [MaybeUninit; N] { [const { MaybeUninit::uninit() }; N] } @@ -118,7 +112,7 @@ macro_rules! deserialize_primitive { }; } -impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'scope> { +impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'scope, '_> { type Error = Error<'scope>; // Deserialization of primitive types defers to `FromValue`. @@ -139,6 +133,10 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco deserialize_primitive!(deserialize_f32, f32); fn deserialize_product>(self, visitor: V) -> Result { + if visitor.product_len() == 0 && self.input.is_null_or_undefined() { + return visitor.visit_seq_product(de::UnitAccess::new()); + } + let object = cast!( self.common.scope, self.input, @@ -156,12 +154,21 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco } fn deserialize_sum>(self, visitor: V) -> Result { - let scope = &mut *self.common.scope; + let scope = &*self.common.scope; + + if visitor.is_option() { + return if self.input.is_null_or_undefined() { + visitor.visit_sum(de::NoneAccess::new()) + } else { + visitor.visit_sum(de::SomeAccess::new(self)) + }; + } + let sum_name = visitor.sum_name().unwrap_or(""); // We expect a canonical representation of a sum value in JS to be // `{ tag: "foo", value: a_value_for_foo }`. - let tag_field = self.common.key_cache.tag(scope); + let tag_field = TAG.string(scope); let object = cast!(scope, self.input, Object, "object for sum type `{}`", sum_name)?; // Extract the `tag` field. It needs to contain a string. @@ -171,7 +178,7 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco let tag = cast!(scope, tag, v8::String, "string for sum tag of `{}`", sum_name)?; // Extract the `value` field. - let value_field = self.common.key_cache.value(scope); + let value_field = VALUE.string(scope); let value = object .get(scope, value_field.into()) .ok_or_else(exception_already_thrown)?; @@ -187,7 +194,7 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco fn deserialize_str>(self, visitor: V) -> Result { let val = cast!(self.common.scope, self.input, v8::String, "`string`")?; let mut buf = scratch_buf::<64>(); - match val.to_rust_cow_lossy(self.common.scope, &mut buf) { + match val.to_rust_cow_lossy(&mut *self.common.scope, &mut buf) { Cow::Borrowed(s) => visitor.visit(s), Cow::Owned(string) => visitor.visit_owned(string), } @@ -212,8 +219,8 @@ impl<'de, 'this, 'scope: 'de> de::Deserializer<'de> for Deserializer<'this, 'sco /// Provides access to the field names and values in a JS object /// under the assumption that it's a product. -struct ProductAccess<'this, 'scope> { - common: DeserializerCommon<'this, 'scope>, +struct ProductAccess<'this, 'scope, 'isolate> { + common: DeserializerCommon<'this, 'scope, 'isolate>, /// The input object being deserialized. object: Local<'scope, Object>, /// A field's value, to deserialize next in [`NamedProductAccess::get_field_value_seed`]. @@ -223,7 +230,7 @@ struct ProductAccess<'this, 'scope> { } // Creates an interned [`v8::String`]. -pub(super) fn v8_interned_string<'scope>(scope: &mut HandleScope<'scope>, field: &str) -> Local<'scope, v8::String> { +pub(super) fn v8_interned_string<'scope>(scope: &PinScope<'scope, '_>, field: &str) -> Local<'scope, v8::String> { // Internalized v8 strings are significantly faster than "normal" v8 strings // since v8 deduplicates re-used strings minimizing new allocations // see: https://github.com/v8/v8/blob/14ac92e02cc3db38131a57e75e2392529f405f2f/include/v8.h#L3165-L3171 @@ -232,22 +239,22 @@ pub(super) fn v8_interned_string<'scope>(scope: &mut HandleScope<'scope>, field: /// Normalizes `field` into an interned `v8::String`. pub(super) fn intern_field_name<'scope>( - scope: &mut HandleScope<'scope>, + scope: &PinScope<'scope, '_>, field: Option<&str>, index: usize, ) -> Local<'scope, Name> { let field = match field { - Some(field) => Cow::Borrowed(field), - None => Cow::Owned(format!("{index}")), + Some(field) => field.to_case(Case::Camel), + None => format!("{index}"), }; v8_interned_string(scope, &field).into() } -impl<'de, 'scope: 'de> de::NamedProductAccess<'de> for ProductAccess<'_, 'scope> { +impl<'de, 'scope: 'de> de::NamedProductAccess<'de> for ProductAccess<'_, 'scope, '_> { type Error = Error<'scope>; fn get_field_ident>(&mut self, visitor: V) -> Result, Self::Error> { - let scope = &mut *self.common.scope; + let scope = &*self.common.scope; let mut field_names = visitor.field_names(); while let Some(field) = field_names.nth(self.index) { // Get and advance the current index. @@ -296,17 +303,17 @@ impl<'de, 'scope: 'de> de::NamedProductAccess<'de> for ProductAccess<'_, 'scope> /// Used in `Deserializer::deserialize_sum` to translate a `tag` property of a JS object /// to a variant and to provide a deserializer for its value/payload. -struct SumAccess<'this, 'scope> { - common: DeserializerCommon<'this, 'scope>, +struct SumAccess<'this, 'scope, 'isolate> { + common: DeserializerCommon<'this, 'scope, 'isolate>, /// The tag of the sum value. tag: Local<'scope, v8::String>, /// The value of the sum value. value: Local<'scope, Value>, } -impl<'de, 'this, 'scope: 'de> de::SumAccess<'de> for SumAccess<'this, 'scope> { +impl<'de, 'this, 'scope: 'de, 'isolate> de::SumAccess<'de> for SumAccess<'this, 'scope, 'isolate> { type Error = Error<'scope>; - type Variant = Deserializer<'this, 'scope>; + type Variant = Deserializer<'this, 'scope, 'isolate>; fn variant>(self, visitor: V) -> Result<(V::Output, Self::Variant), Self::Error> { // Read the `tag` property in JS. @@ -328,7 +335,7 @@ impl<'de, 'this, 'scope: 'de> de::SumAccess<'de> for SumAccess<'this, 'scope> { } } -impl<'de, 'this, 'scope: 'de> de::VariantAccess<'de> for Deserializer<'this, 'scope> { +impl<'de, 'this, 'scope: 'de> de::VariantAccess<'de> for Deserializer<'this, 'scope, '_> { type Error = Error<'scope>; fn deserialize_seed>(self, seed: T) -> Result { @@ -338,18 +345,18 @@ impl<'de, 'this, 'scope: 'de> de::VariantAccess<'de> for Deserializer<'this, 'sc /// Used by an `ArrayVisitor` to deserialize every element of a JS array /// to a SATS array. -struct ArrayAccess<'this, 'scope, T> { - common: DeserializerCommon<'this, 'scope>, +struct ArrayAccess<'this, 'scope, 'isolate, T> { + common: DeserializerCommon<'this, 'scope, 'isolate>, arr: Local<'scope, Array>, seeds: RepeatN, index: u32, } -impl<'de, 'this, 'scope, T> ArrayAccess<'this, 'scope, T> +impl<'de, 'this, 'scope, 'isolate, T> ArrayAccess<'this, 'scope, 'isolate, T> where T: DeserializeSeed<'de> + Clone, { - fn new(arr: Local<'scope, Array>, common: DeserializerCommon<'this, 'scope>, seed: T) -> Self { + fn new(arr: Local<'scope, Array>, common: DeserializerCommon<'this, 'scope, 'isolate>, seed: T) -> Self { Self { arr, common, @@ -359,7 +366,7 @@ where } } -impl<'de, 'scope: 'de, T: DeserializeSeed<'de> + Clone> de::ArrayAccess<'de> for ArrayAccess<'_, 'scope, T> { +impl<'de, 'scope: 'de, T: DeserializeSeed<'de> + Clone> de::ArrayAccess<'de> for ArrayAccess<'_, 'scope, '_, T> { type Element = T::Output; type Error = Error<'scope>; diff --git a/crates/core/src/host/v8/error.rs b/crates/core/src/host/v8/error.rs index 1021f9c5f11..1169845a9a4 100644 --- a/crates/core/src/host/v8/error.rs +++ b/crates/core/src/host/v8/error.rs @@ -1,7 +1,11 @@ //! Utilities for error handling when dealing with V8. +use crate::database_logger::{BacktraceFrame, BacktraceProvider, ModuleBacktrace}; + +use super::serialize_to_js; use core::fmt; -use v8::{Exception, HandleScope, Local, StackFrame, StackTrace, TryCatch, Value}; +use spacetimedb_sats::Serialize; +use v8::{tc_scope, Exception, HandleScope, Local, PinScope, PinnedRef, StackFrame, StackTrace, TryCatch, Value}; /// The result of trying to convert a [`Value`] in scope `'scope` to some type `T`. pub(super) type ValueResult<'scope, T> = Result>; @@ -9,11 +13,11 @@ pub(super) type ValueResult<'scope, T> = Result>; /// Types that can convert into a JS string type. pub(super) trait IntoJsString { /// Converts `self` into a JS string. - fn into_string<'scope>(self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String>; + fn into_string<'scope>(self, scope: &PinScope<'scope, '_>) -> Local<'scope, v8::String>; } impl IntoJsString for String { - fn into_string<'scope>(self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { + fn into_string<'scope>(self, scope: &PinScope<'scope, '_>) -> Local<'scope, v8::String> { v8::String::new(scope, &self).unwrap() } } @@ -27,11 +31,11 @@ pub(super) struct ExceptionValue<'scope>(Local<'scope, Value>); /// Error types that can convert into JS exception values. pub(super) trait IntoException<'scope> { /// Converts `self` into a JS exception value. - fn into_exception(self, scope: &mut HandleScope<'scope>) -> ExceptionValue<'scope>; + fn into_exception(self, scope: &PinScope<'scope, '_>) -> ExceptionValue<'scope>; } impl<'scope> IntoException<'scope> for ExceptionValue<'scope> { - fn into_exception(self, _: &mut HandleScope<'scope>) -> ExceptionValue<'scope> { + fn into_exception(self, _: &PinScope<'scope, '_>) -> ExceptionValue<'scope> { self } } @@ -41,7 +45,7 @@ impl<'scope> IntoException<'scope> for ExceptionValue<'scope> { pub struct TypeError(pub M); impl<'scope, M: IntoJsString> IntoException<'scope> for TypeError { - fn into_exception(self, scope: &mut HandleScope<'scope>) -> ExceptionValue<'scope> { + fn into_exception(self, scope: &PinScope<'scope, '_>) -> ExceptionValue<'scope> { let msg = self.0.into_string(scope); ExceptionValue(Exception::type_error(scope, msg)) } @@ -52,19 +56,73 @@ impl<'scope, M: IntoJsString> IntoException<'scope> for TypeError { pub struct RangeError(pub M); impl<'scope, M: IntoJsString> IntoException<'scope> for RangeError { - fn into_exception(self, scope: &mut HandleScope<'scope>) -> ExceptionValue<'scope> { + fn into_exception(self, scope: &PinScope<'scope, '_>) -> ExceptionValue<'scope> { let msg = self.0.into_string(scope); ExceptionValue(Exception::range_error(scope, msg)) } } +/// A catchable termination error thrown in callbacks to indicate a host error. +#[derive(Serialize)] +pub(super) struct TerminationError { + __terminated__: String, +} + +impl TerminationError { + /// Convert `anyhow::Error` to a termination error. + pub(super) fn from_error<'scope>( + scope: &PinScope<'scope, '_>, + error: &anyhow::Error, + ) -> ExcResult> { + let __terminated__ = format!("{error}"); + let error = Self { __terminated__ }; + serialize_to_js(scope, &error).map(ExceptionValue) + } +} + +/// A catchable error code thrown in callbacks +/// to indicate bad arguments to a syscall. +#[derive(Serialize)] +pub(super) struct CodeError { + __code_error__: u16, +} + +impl CodeError { + /// Create a code error from a code. + pub(super) fn from_code<'scope>( + scope: &PinScope<'scope, '_>, + __code_error__: u16, + ) -> ExcResult> { + let error = Self { __code_error__ }; + serialize_to_js(scope, &error).map(ExceptionValue) + } +} + +/// A catchable error code thrown in callbacks +/// to indicate that a buffer was too small and the minimum size required. +#[derive(Serialize)] +pub(super) struct BufferTooSmall { + __buffer_too_small__: u32, +} + +impl BufferTooSmall { + /// Create a code error from a code. + pub(super) fn from_requirement<'scope>( + scope: &PinScope<'scope, '_>, + __buffer_too_small__: u32, + ) -> ExcResult> { + let error = Self { __buffer_too_small__ }; + serialize_to_js(scope, &error).map(ExceptionValue) + } +} + #[derive(Debug)] -pub(super) struct ExceptionThrown { +pub(crate) struct ExceptionThrown { _priv: (), } /// A result where the error indicates that an exception has already been thrown in V8. -pub(super) type ExcResult = Result; +pub(crate) type ExcResult = Result; /// Indicates that the JS side had thrown an exception. pub(super) fn exception_already_thrown() -> ExceptionThrown { @@ -77,11 +135,11 @@ pub(super) trait Throwable<'scope> { /// /// If an exception has already been thrown, /// [`ExceptionThrown`] can be returned directly. - fn throw(self, scope: &mut HandleScope<'scope>) -> ExceptionThrown; + fn throw(self, scope: &PinScope<'scope, '_>) -> ExceptionThrown; } impl<'scope, T: IntoException<'scope>> Throwable<'scope> for T { - fn throw(self, scope: &mut HandleScope<'scope>) -> ExceptionThrown { + fn throw(self, scope: &PinScope<'scope, '_>) -> ExceptionThrown { let ExceptionValue(exception) = self.into_exception(scope); scope.throw_exception(exception); exception_already_thrown() @@ -126,20 +184,22 @@ pub(super) struct JsError { impl fmt::Display for JsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "js error {}", self.msg)?; - writeln!(f, "{}", self.trace)?; + if !f.alternate() { + writeln!(f, "{}", self.trace)?; + } Ok(()) } } /// A V8 stack trace that is independent of a `'scope`. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub(super) struct JsStackTrace { frames: Box<[JsStackTraceFrame]>, } impl JsStackTrace { /// Converts a V8 [`StackTrace`] into one independent of `'scope`. - fn from_trace<'scope>(scope: &mut HandleScope<'scope>, trace: Local<'scope, StackTrace>) -> Self { + pub(super) fn from_trace<'scope>(scope: &PinScope<'scope, '_>, trace: Local<'scope, StackTrace>) -> Self { let frames = (0..trace.get_frame_count()) .map(|index| { let frame = trace.get_frame(scope, index).unwrap(); @@ -148,6 +208,12 @@ impl JsStackTrace { .collect::>(); Self { frames } } + + /// Construct a backtrace from `scope`. + pub(super) fn from_current_stack_trace(scope: &PinScope<'_, '_>) -> ExcResult { + let trace = StackTrace::current_stack_trace(scope, 1024).ok_or_else(exception_already_thrown)?; + Ok(Self::from_trace(scope, trace)) + } } impl fmt::Display for JsStackTrace { @@ -160,8 +226,26 @@ impl fmt::Display for JsStackTrace { } } +impl BacktraceProvider for JsStackTrace { + fn capture(&self) -> Box { + Box::new(self.clone()) + } +} + +impl ModuleBacktrace for JsStackTrace { + fn frames(&self) -> Vec> { + self.frames + .iter() + .map(|frame| BacktraceFrame { + module_name: frame.script_name.as_deref(), + func_name: frame.fn_name.as_deref(), + }) + .collect() + } +} + /// A V8 stack trace frame that is independent of a `'scope`. -#[derive(Debug)] +#[derive(Debug, Clone)] pub(super) struct JsStackTraceFrame { line: usize, column: usize, @@ -176,7 +260,7 @@ pub(super) struct JsStackTraceFrame { impl JsStackTraceFrame { /// Converts a V8 [`StackFrame`] into one independent of `'scope`. - fn from_frame<'scope>(scope: &mut HandleScope<'scope>, frame: Local<'scope, StackFrame>) -> Self { + fn from_frame<'scope>(scope: &PinScope<'scope, '_>, frame: Local<'scope, StackFrame>) -> Self { let script_name = frame .get_script_name_or_source_url(scope) .map(|s| s.to_rust_string_lossy(scope)); @@ -195,16 +279,26 @@ impl JsStackTraceFrame { is_user_js: frame.is_user_javascript(), } } + + /// Returns the name of the function that was called. + fn fn_name(&self) -> &str { + self.fn_name.as_deref().unwrap_or("") + } + + /// Returns the name of the script where the function resides. + fn script_name(&self) -> &str { + self.script_name.as_deref().unwrap_or("") + } } impl fmt::Display for JsStackTraceFrame { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let fn_name = self.fn_name.as_deref().unwrap_or(""); - let script_name = self.script_name.as_deref().unwrap_or(""); + let fn_name = self.fn_name(); + let script_name = self.script_name(); // This isn't exactly the same format as chrome uses, // but it's close enough for now. - // TODO(centril): make it more like chrome in the future. + // TODO(v8): make it more like chrome in the future. f.write_fmt(format_args!( "at {} ({}:{}:{})", fn_name, script_name, &self.line, &self.column @@ -232,7 +326,7 @@ impl fmt::Display for JsStackTraceFrame { impl JsError { /// Turns a caught JS exception in `scope` into a [`JSError`]. - fn from_caught(scope: &mut TryCatch<'_, HandleScope<'_>>) -> Self { + fn from_caught(scope: &PinnedRef<'_, TryCatch<'_, '_, HandleScope<'_>>>) -> Self { match scope.message() { Some(message) => Self { trace: message @@ -249,13 +343,24 @@ impl JsError { } } +pub(super) fn log_traceback(func_type: &str, func: &str, e: &anyhow::Error) { + log::info!("{func_type} \"{func}\" runtime error: {e:#}"); + if let Some(js_err) = e.downcast_ref::() { + log::info!("js error {}", js_err.msg); + for (index, frame) in js_err.trace.frames.iter().enumerate() { + log::info!(" Frame #{index}: {frame}"); + } + } +} + /// Run `body` within a try-catch context and capture any JS exception thrown as a [`JsError`]. pub(super) fn catch_exception<'scope, T>( - scope: &mut HandleScope<'scope>, - body: impl FnOnce(&mut HandleScope<'scope>) -> Result>, + scope: &mut PinScope<'scope, '_>, + body: impl FnOnce(&mut PinScope<'scope, '_>) -> Result>, ) -> Result> { - let scope = &mut TryCatch::new(scope); - body(scope).map_err(|e| match e { + tc_scope!(scope, scope); + let ret = body(scope); + ret.map_err(|e| match e { ErrorOrException::Err(e) => ErrorOrException::Err(e), ErrorOrException::Exception(_) => ErrorOrException::Exception(JsError::from_caught(scope)), }) diff --git a/crates/core/src/host/v8/from_value.rs b/crates/core/src/host/v8/from_value.rs index e3322eff0e9..114345434ee 100644 --- a/crates/core/src/host/v8/from_value.rs +++ b/crates/core/src/host/v8/from_value.rs @@ -5,22 +5,19 @@ use crate::host::v8::error::ExceptionValue; use super::error::{IntoException as _, TypeError, ValueResult}; use bytemuck::{AnyBitPattern, NoUninit}; use spacetimedb_sats::{i256, u256}; -use v8::{BigInt, Boolean, HandleScope, Int32, Local, Number, Uint32, Value}; +use v8::{BigInt, Boolean, Int32, Local, Number, PinScope, Uint32, Value}; /// Types that a v8 [`Value`] can be converted into. pub(super) trait FromValue: Sized { /// Converts `val` in `scope` to `Self` if possible. - fn from_value<'scope>(val: Local<'_, Value>, scope: &mut HandleScope<'scope>) -> ValueResult<'scope, Self>; + fn from_value<'scope>(val: Local<'_, Value>, scope: &PinScope<'scope, '_>) -> ValueResult<'scope, Self>; } /// Provides a [`FromValue`] implementation. macro_rules! impl_from_value { ($ty:ty, ($val:ident, $scope:ident) => $logic:expr) => { impl FromValue for $ty { - fn from_value<'scope>( - $val: Local<'_, Value>, - $scope: &mut HandleScope<'scope>, - ) -> ValueResult<'scope, Self> { + fn from_value<'scope>($val: Local<'_, Value>, $scope: &PinScope<'scope, '_>) -> ValueResult<'scope, Self> { $logic } } @@ -29,7 +26,7 @@ macro_rules! impl_from_value { /// Tries to cast `Value` into `T` or raises a JS exception as a returned `Err` value. pub(super) fn try_cast<'scope_a, 'scope_b, T>( - scope: &mut HandleScope<'scope_a>, + scope: &PinScope<'scope_a, '_>, val: Local<'scope_b, Value>, on_err: impl FnOnce(&str) -> String, ) -> ValueResult<'scope_a, Local<'scope_b, T>> @@ -50,13 +47,13 @@ pub(super) use cast; /// Returns a JS exception value indicating that a value overflowed /// when converting to the type `rust_ty`. -fn value_overflowed<'scope>(rust_ty: &str, scope: &mut HandleScope<'scope>) -> ExceptionValue<'scope> { +fn value_overflowed<'scope>(rust_ty: &str, scope: &PinScope<'scope, '_>) -> ExceptionValue<'scope> { TypeError(format!("Value overflowed `{rust_ty}`")).into_exception(scope) } /// Returns a JS exception value indicating that a value underflowed /// when converting to the type `rust_ty`. -fn value_underflowed<'scope>(rust_ty: &str, scope: &mut HandleScope<'scope>) -> ExceptionValue<'scope> { +fn value_underflowed<'scope>(rust_ty: &str, scope: &PinScope<'scope, '_>) -> ExceptionValue<'scope> { TypeError(format!("Value underflowed `{rust_ty}`")).into_exception(scope) } @@ -120,7 +117,7 @@ int64_from_value!(i64, i64_value); /// - `bigint` is the integer to convert. fn bigint_to_bytes<'scope, const N: usize, const W: usize, const UNSIGNED: bool>( rust_ty: &str, - scope: &mut HandleScope<'scope>, + scope: &PinScope<'scope, '_>, bigint: &BigInt, ) -> ValueResult<'scope, (bool, [u8; N])> where diff --git a/crates/core/src/host/v8/key_cache.rs b/crates/core/src/host/v8/key_cache.rs deleted file mode 100644 index bd0356d8eca..00000000000 --- a/crates/core/src/host/v8/key_cache.rs +++ /dev/null @@ -1,67 +0,0 @@ -use super::de::v8_interned_string; -use core::cell::RefCell; -use std::rc::Rc; -use v8::{Global, HandleScope, Local}; - -/// Returns a `KeyCache` for the current `scope`. -/// -/// Creates the cache in the scope if it doesn't exist yet. -pub(super) fn get_or_create_key_cache(scope: &mut HandleScope<'_>) -> Rc> { - let context = scope.get_current_context(); - context.get_slot::>().unwrap_or_else(|| { - let cache = Rc::default(); - context.set_slot(Rc::clone(&cache)); - cache - }) -} - -/// A cache for frequently used strings to avoid re-interning them. -#[derive(Default)] -pub(super) struct KeyCache { - /// The `tag` property for sum values in JS. - tag: Option>, - /// The `value` property for sum values in JS. - value: Option>, - /// The `__describe_module__` property on the global proxy object. - describe_module: Option>, - /// The `__call_reducer__` property on the global proxy object. - call_reducer: Option>, -} - -impl KeyCache { - /// Returns the `tag` property name. - pub(super) fn tag<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { - Self::get_or_create_key(scope, &mut self.tag, "tag") - } - - /// Returns the `value` property name. - pub(super) fn value<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { - Self::get_or_create_key(scope, &mut self.value, "value") - } - - /// Returns the `__describe_module__` property name. - pub(super) fn describe_module<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { - Self::get_or_create_key(scope, &mut self.describe_module, "__describe_module__") - } - - /// Returns the `__call_reducer__` property name. - pub(super) fn call_reducer<'scope>(&mut self, scope: &mut HandleScope<'scope>) -> Local<'scope, v8::String> { - Self::get_or_create_key(scope, &mut self.call_reducer, "__call_reducer__") - } - - /// Returns an interned string corresponding to `string` - /// and memoizes the creation on the v8 side. - fn get_or_create_key<'scope>( - scope: &mut HandleScope<'scope>, - slot: &mut Option>, - string: &str, - ) -> Local<'scope, v8::String> { - if let Some(s) = &*slot { - v8::Local::new(scope, s) - } else { - let s = v8_interned_string(scope, string); - *slot = Some(v8::Global::new(scope, s)); - s - } - } -} diff --git a/crates/core/src/host/v8/mod.rs b/crates/core/src/host/v8/mod.rs index 92e1fb0c896..cdc393680b2 100644 --- a/crates/core/src/host/v8/mod.rs +++ b/crates/core/src/host/v8/mod.rs @@ -1,35 +1,47 @@ #![allow(dead_code)] -use super::module_common::{build_common_module_from_raw, ModuleCommon}; +use super::module_common::{build_common_module_from_raw, run_describer, ModuleCommon}; use super::module_host::{CallReducerParams, DynModule, Module, ModuleInfo, ModuleInstance, ModuleRuntime}; use super::UpdateDatabaseResult; +use crate::host::instance_env::{ChunkPool, InstanceEnv}; use crate::host::wasm_common::instrumentation::CallTimes; use crate::host::wasm_common::module_host_actor::{ - EnergyStats, ExecuteResult, ExecutionTimings, InstanceCommon, ReducerOp, + DescribeError, EnergyStats, ExecuteResult, ExecutionTimings, InstanceCommon, ReducerOp, }; -use crate::host::ArgsTuple; -use crate::{host::Scheduler, module_host_context::ModuleCreationContext, replica_context::ReplicaContext}; -use anyhow::anyhow; +use crate::host::wasm_common::{RowIters, TimingSpanSet}; +use crate::host::wasmtime::{epoch_ticker, ticks_in_duration, EPOCH_TICKS_PER_SECOND}; +use crate::host::{ArgsTuple, Scheduler}; +use crate::{module_host_context::ModuleCreationContext, replica_context::ReplicaContext}; +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; +use core::{iter, ptr, str}; use de::deserialize_js; -use error::{catch_exception, exception_already_thrown, ExcResult, Throwable}; +use error::{ + catch_exception, exception_already_thrown, log_traceback, BufferTooSmall, CodeError, JsStackTrace, + TerminationError, Throwable, +}; use from_value::cast; -use key_cache::get_or_create_key_cache; use ser::serialize_to_js; -use spacetimedb_client_api_messages::energy::{EnergyQuanta, ReducerBudget}; +use spacetimedb_client_api_messages::energy::ReducerBudget; use spacetimedb_datastore::locking_tx_datastore::MutTxId; use spacetimedb_datastore::traits::Program; -use spacetimedb_lib::RawModuleDef; -use spacetimedb_lib::{ConnectionId, Identity}; +use spacetimedb_lib::{ConnectionId, Identity, RawModuleDef, Timestamp}; use spacetimedb_schema::auto_migrate::MigrationPolicy; use std::sync::{Arc, LazyLock}; -use v8::{Context, ContextOptions, ContextScope, Function, HandleScope, Isolate, Local, Value}; +use std::time::Instant; +use string_const::str_from_ident; +use syscall::{register_host_funs, FnRet}; +use v8::{ + scope, Context, ContextScope, Function, Isolate, IsolateHandle, Local, Object, OwnedIsolate, PinScope, Value, +}; mod de; mod error; mod from_value; -mod key_cache; mod ser; +mod string_const; +mod syscall; mod to_value; /// The V8 runtime, for modules written in e.g., JS or TypeScript. @@ -59,42 +71,51 @@ struct V8RuntimeInner { } impl V8RuntimeInner { + /// Initializes the V8 platform and engine. + /// + /// Should only be called once but it isn't unsound to call it more times. fn init() -> Self { // Our current configuration: // - will pick a number of worker threads for background jobs based on the num CPUs. // - does not allow idle tasks - let platform = v8::new_default_platform(0, false).make_shared(); + let platform = v8::new_single_threaded_default_platform(false).make_shared(); // Initialize V8. Internally, this uses a global lock so it's safe that we don't. v8::V8::initialize_platform(platform); v8::V8::initialize(); Self { _priv: () } } +} +impl ModuleRuntime for V8RuntimeInner { fn make_actor(&self, mcc: ModuleCreationContext<'_>) -> anyhow::Result { - #![allow(unreachable_code, unused_variables)] - log::trace!( "Making new V8 module host actor for database {} with module {}", mcc.replica_ctx.database_identity, mcc.program.hash, ); - if true { - return Err::(anyhow!("v8_todo")); - } + // TODO(v8): determine min required ABI by module and check that it's supported? + + // TODO(v8): validate function signatures like in WASM? Is that possible with V8? + + // Convert program to a string. + let program: Arc = str::from_utf8(&mcc.program.bytes)?.into(); + + // Run the program as a script and extract the raw module def. + let desc = extract_description(&program)?; - let desc = todo!(); // Validate and create a common module rom the raw definition. let common = build_common_module_from_raw(mcc, desc)?; - Ok(JsModule { common }) + Ok(JsModule { common, program }) } } #[derive(Clone)] struct JsModule { common: ModuleCommon, + program: Arc, } impl DynModule for JsModule { @@ -110,10 +131,10 @@ impl DynModule for JsModule { impl Module for JsModule { type Instance = JsInstance; - type InitialInstances<'a> = std::iter::Empty; + type InitialInstances<'a> = iter::Empty; fn initial_instances(&mut self) -> Self::InitialInstances<'_> { - std::iter::empty() + iter::empty() } fn info(&self) -> Arc { @@ -121,13 +142,168 @@ impl Module for JsModule { } fn create_instance(&self) -> Self::Instance { - todo!() + // TODO(v8): do we care about preinits / setup or are they unnecessary? + + let common = &self.common; + let instance_env = InstanceEnv::new(common.replica_ctx().clone(), common.scheduler().clone()); + let instance = JsInstanceEnvSlot::new(JsInstanceEnv { + instance_env, + reducer_start: Instant::now(), + call_times: CallTimes::new(), + iters: <_>::default(), + reducer_name: "".into(), + chunk_pool: <_>::default(), + timing_spans: <_>::default(), + }); + + // NOTE(centril): We don't need to do `extract_description` here + // as unlike WASM, we have to recreate the isolate every time. + + let common = InstanceCommon::new(common); + let program = self.program.clone(); + + JsInstance { + common, + instance, + program, + } + } +} + +/// The [`JsInstance`]'s way of holding a [`JsInstanceEnv`] +/// with possible temporary extraction. +struct JsInstanceEnvSlot { + /// NOTE(centril): The `Option<_>` is due to moving the environment + /// into [`Isolate`]s and back. + instance: Option, +} + +impl JsInstanceEnvSlot { + /// Creates a new slot to hold `instance`. + fn new(instance: JsInstanceEnv) -> Self { + Self { + instance: Some(instance), + } + } + + const EXPECT_ENV: &str = "there should be a `JsInstanceEnv`"; + + /// Provides exclusive access to the instance's environment, + /// assuming it hasn't been moved to an [`Isolate`]. + fn get_mut(&mut self) -> &mut JsInstanceEnv { + self.instance.as_mut().expect(Self::EXPECT_ENV) + } + + /// Moves the instance's environment to `isolate`, + /// assuming it hasn't already been moved there. + fn move_to_isolate(&mut self, isolate: &mut Isolate) { + isolate.set_slot(self.instance.take().expect(Self::EXPECT_ENV)); + } + + /// Steals the instance's environment back from `isolate`, + /// assuming `isolate` still has it in a slot. + fn take_from_isolate(&mut self, isolate: &mut Isolate) { + self.instance = isolate.remove_slot(); + } +} + +/// Access the `JsInstanceEnv` temporarily bound to an [`Isolate`]. +/// +/// This assumes that the slot has been set in the isolate already. +fn env_on_isolate(isolate: &mut Isolate) -> &mut JsInstanceEnv { + isolate.get_slot_mut().expect(JsInstanceEnvSlot::EXPECT_ENV) +} + +/// The environment of a [`JsInstance`]. +struct JsInstanceEnv { + instance_env: InstanceEnv, + + /// The slab of `BufferIters` created for this instance. + iters: RowIters, + + /// Track time spent in module-defined spans. + timing_spans: TimingSpanSet, + + /// The point in time the last reducer call started at. + reducer_start: Instant, + + /// Track time spent in all wasm instance env calls (aka syscall time). + /// + /// Each function, like `insert`, will add the `Duration` spent in it + /// to this tracker. + call_times: CallTimes, + + /// The last, including current, reducer to be executed by this environment. + reducer_name: String, + + /// A pool of unused allocated chunks that can be reused. + // TODO(Centril): consider using this pool for `console_timer_start` and `bytes_sink_write`. + chunk_pool: ChunkPool, +} + +impl JsInstanceEnv { + /// Signal to this `WasmInstanceEnv` that a reducer call is beginning. + /// + /// Returns the handle used by reducers to read from `args` + /// as well as the handle used to write the error message, if any. + pub fn start_reducer(&mut self, name: &str, ts: Timestamp) { + self.reducer_start = Instant::now(); + name.clone_into(&mut self.reducer_name); + self.instance_env.start_reducer(ts); + } + + /// Returns the name of the most recent reducer to be run in this environment. + pub fn reducer_name(&self) -> &str { + &self.reducer_name + } + + /// Returns the name of the most recent reducer to be run in this environment, + /// or `None` if no reducer is actively being invoked. + fn log_record_function(&self) -> Option<&str> { + let function = self.reducer_name(); + (!function.is_empty()).then_some(function) + } + + /// Returns the name of the most recent reducer to be run in this environment. + pub fn reducer_start(&self) -> Instant { + self.reducer_start + } + + /// Signal to this `WasmInstanceEnv` that a reducer call is over. + /// This resets all of the state associated to a single reducer call, + /// and returns instrumentation records. + pub fn finish_reducer(&mut self) -> ExecutionTimings { + let total_duration = self.reducer_start.elapsed(); + + // Taking the call times record also resets timings to 0s for the next call. + let wasm_instance_env_call_times = self.call_times.take(); + + ExecutionTimings { + total_duration, + wasm_instance_env_call_times, + } + } + + /// Returns the [`ReplicaContext`] for this environment. + fn replica_ctx(&self) -> &Arc { + &self.instance_env.replica_ctx } } struct JsInstance { + /// Information common to instances of all runtimes. + /// + /// (The type is shared, the data is not.) common: InstanceCommon, - replica_ctx: Arc, + + /// The environment of the instance. + instance: JsInstanceEnvSlot, + + /// The module's program (JS code). + /// Used to startup the [`Isolate`]s. + /// + // TODO(v8): replace with snapshots. + program: Arc, } impl ModuleInstance for JsInstance { @@ -141,73 +317,204 @@ impl ModuleInstance for JsInstance { old_module_info: Arc, policy: MigrationPolicy, ) -> anyhow::Result { - let replica_ctx = &self.replica_ctx; + let replica_ctx = self.instance.get_mut().replica_ctx(); self.common .update_database(replica_ctx, program, old_module_info, policy) } fn call_reducer(&mut self, tx: Option, params: CallReducerParams) -> super::ReducerCallResult { - // TODO(centril): snapshots, module->host calls - let mut isolate = Isolate::new(<_>::default()); - let scope = &mut HandleScope::new(&mut isolate); - let context = Context::new(scope, ContextOptions::default()); - let scope = &mut ContextScope::new(scope, context); + let replica_ctx = &self.instance.get_mut().replica_ctx().clone(); + + self.common + .call_reducer_with_tx(replica_ctx, tx, params, log_traceback, |tx, op, budget| { + /// Called by a thread separate to V8 execution + /// every [`EPOCH_TICKS_PER_SECOND`] ticks (~every 1 second) + /// to log that the reducer is still running. + extern "C" fn cb_log_long_running(isolate: &mut Isolate, _: *mut c_void) { + let env = env_on_isolate(isolate); + let database = env.instance_env.replica_ctx.database_identity; + let reducer = env.reducer_name(); + let dur = env.reducer_start().elapsed(); + tracing::warn!(reducer, ?database, "JavaScript has been running for {dur:?}"); + } + + // Start timer and prepare the isolate with the env. + let mut isolate = Isolate::new(<_>::default()); + self.instance.get_mut().instance_env.start_reducer(op.timestamp); + self.instance.move_to_isolate(&mut isolate); + + // TODO(v8): snapshots + // Call the reducer. + let (mut isolate, (tx, call_result)) = with_script( + isolate, + &self.program, + EPOCH_TICKS_PER_SECOND, + cb_log_long_running, + budget, + |scope, _| { + let (tx, call_result) = env_on_isolate(scope) + .instance_env + .tx + .clone() + .set(tx, || call_call_reducer_from_op(scope, op)); + (tx, call_result) + }, + ); + + // Steal back the env and finish timings. + self.instance.take_from_isolate(&mut isolate); + let timings = self.instance.get_mut().finish_reducer(); + + // Derive energy stats. + let used = duration_to_budget(timings.total_duration); + let remaining = budget - used; + let energy = EnergyStats { budget, remaining }; + + // Fetch the currently used heap size in V8. + // The used size is ostensibly fairer than the total size. + let memory_allocation = isolate.get_heap_statistics().used_heap_size(); - self.common.call_reducer_with_tx( - &self.replica_ctx.clone(), - tx, - params, - // TODO(centril): logging. - |_ty, _fun, _err| {}, - |tx, op, _budget| { - let call_result = call_call_reducer_from_op(scope, op); - // TODO(centril): energy metrering. - let energy = EnergyStats { - used: EnergyQuanta::ZERO, - wasmtime_fuel_used: 0, - remaining: ReducerBudget::ZERO, - }; - // TODO(centril): timings. - let timings = ExecutionTimings { - total_duration: Duration::ZERO, - wasm_instance_env_call_times: CallTimes::new(), - }; let exec_result = ExecuteResult { energy, timings, - // TODO(centril): memory allocation. - memory_allocation: 0, + memory_allocation, call_result, }; (tx, exec_result) - }, - ) + }) } } +fn with_script( + isolate: OwnedIsolate, + code: &str, + callback_every: u64, + callback: InterruptCallback, + budget: ReducerBudget, + logic: impl for<'scope> FnOnce(&mut PinScope<'scope, '_>, Local<'scope, Value>) -> R, +) -> (OwnedIsolate, R) { + with_scope(isolate, callback_every, callback, budget, |scope| { + let code = v8::String::new(scope, code).unwrap(); + let script_val = v8::Script::compile(scope, code, None).unwrap().run(scope).unwrap(); + + register_host_funs(scope).unwrap(); + + logic(scope, script_val) + }) +} + +/// Sets up an isolate and run `logic` with a [`HandleScope`]. +pub(crate) fn with_scope( + mut isolate: OwnedIsolate, + callback_every: u64, + callback: InterruptCallback, + budget: ReducerBudget, + logic: impl FnOnce(&mut PinScope<'_, '_>) -> R, +) -> (OwnedIsolate, R) { + isolate.set_capture_stack_trace_for_uncaught_exceptions(true, 1024); + let isolate_handle = isolate.thread_safe_handle(); + + let with_isolate = |isolate: &mut OwnedIsolate| -> R { + scope!(let scope, isolate); + let context = Context::new(scope, Default::default()); + let scope = &mut ContextScope::new(scope, context); + logic(scope) + }; + + // let timeout_thread_cancel_flag = run_reducer_timeout(callback_every, callback, budget, isolate_handle); + + let ret = with_isolate(&mut isolate); + + // Cancel the execution timeout in `run_reducer_timeout`. + // timeout_thread_cancel_flag.store(true, Ordering::Relaxed); + + (isolate, ret) +} + +/// A callback passed to [`IsolateHandle::request_interrupt`]. +type InterruptCallback = extern "C" fn(&mut Isolate, *mut c_void); + +/// Spawns a thread that will terminate reducer execution +/// when `budget` has been used up. +/// +/// Every `callback_every` ticks, `callback` is called. +fn run_reducer_timeout( + callback_every: u64, + callback: InterruptCallback, + budget: ReducerBudget, + isolate_handle: IsolateHandle, +) -> Arc { + // When `execution_done_flag` is set, the ticker thread will stop. + let execution_done_flag = Arc::new(AtomicBool::new(false)); + let execution_done_flag2 = execution_done_flag.clone(); + + let timeout = budget_to_duration(budget); + let max_ticks = ticks_in_duration(timeout); + + let mut num_ticks = 0; + epoch_ticker(move || { + // Check if execution completed. + if execution_done_flag2.load(Ordering::Relaxed) { + return None; + } + + // We've reached the number of ticks to call `callback`. + if num_ticks % callback_every == 0 && isolate_handle.request_interrupt(callback, ptr::null_mut()) { + return None; + } + + if num_ticks == max_ticks { + // Execution still ongoing while budget has been exhausted. + // Terminate V8 execution. + // This implements "gas" for v8. + isolate_handle.terminate_execution(); + } + + num_ticks += 1; + Some(()) + }); + + execution_done_flag +} + +/// Converts a [`ReducerBudget`] to a [`Duration`]. +fn budget_to_duration(_budget: ReducerBudget) -> Duration { + // TODO(v8): This is fake logic that allows a maximum timeout. + // Replace with sensible math. + Duration::MAX +} + +/// Converts a [`Duration`] to a [`ReducerBudget`]. +fn duration_to_budget(_duration: Duration) -> ReducerBudget { + // TODO(v8): This is fake logic that allows minimum energy usage. + // Replace with sensible math. + ReducerBudget::ZERO +} + +/// Returns the global object. +fn global<'scope>(scope: &PinScope<'scope, '_>) -> Local<'scope, Object> { + scope.get_current_context().global(scope) +} + /// Returns the global property `key`. -fn get_global_property<'scope>( - scope: &mut HandleScope<'scope>, - key: Local<'scope, v8::String>, -) -> ExcResult> { - scope - .get_current_context() - .global(scope) +fn get_global_property<'scope>(scope: &PinScope<'scope, '_>, key: Local<'scope, v8::String>) -> FnRet<'scope> { + global(scope) .get(scope, key.into()) .ok_or_else(exception_already_thrown) } +/// Calls free function `fun` with `args`. fn call_free_fun<'scope>( - scope: &mut HandleScope<'scope>, + scope: &PinScope<'scope, '_>, fun: Local<'scope, Function>, args: &[Local<'scope, Value>], -) -> ExcResult> { +) -> FnRet<'scope> { let receiver = v8::undefined(scope).into(); fun.call(scope, receiver, args).ok_or_else(exception_already_thrown) } // Calls the `__call_reducer__` function on the global proxy object using `op`. -fn call_call_reducer_from_op(scope: &mut HandleScope<'_>, op: ReducerOp<'_>) -> anyhow::Result>> { +fn call_call_reducer_from_op(scope: &mut PinScope<'_, '_>, op: ReducerOp<'_>) -> anyhow::Result>> { call_call_reducer( scope, op.id.into(), @@ -220,16 +527,14 @@ fn call_call_reducer_from_op(scope: &mut HandleScope<'_>, op: ReducerOp<'_>) -> // Calls the `__call_reducer__` function on the global proxy object. fn call_call_reducer( - scope: &mut HandleScope<'_>, + scope: &mut PinScope<'_, '_>, reducer_id: u32, sender: &Identity, conn_id: &ConnectionId, timestamp: i64, reducer_args: &ArgsTuple, ) -> anyhow::Result>> { - // Get a cached version of the `__call_reducer__` property. - let key_cache = get_or_create_key_cache(scope); - let call_reducer_key = key_cache.borrow_mut().call_reducer(scope); + let call_reducer_key = str_from_ident!(__call_reducer__).string(scope); catch_exception(scope, |scope| { // Serialize the arguments. @@ -237,7 +542,7 @@ fn call_call_reducer( let sender = serialize_to_js(scope, &sender.to_u256())?; let conn_id: v8::Local<'_, v8::Value> = serialize_to_js(scope, &conn_id.to_u128())?; let timestamp = serialize_to_js(scope, ×tamp)?; - let reducer_args = serialize_to_js(scope, &reducer_args.tuple.elements)?; + let reducer_args = serialize_to_js(scope, reducer_args.get_bsatn())?; let args = &[reducer_id, sender, conn_id, timestamp, reducer_args]; // Get the function on the global proxy object and convert to a function. @@ -256,11 +561,26 @@ fn call_call_reducer( .map_err(Into::into) } +/// Extracts the raw module def by running `__describe_module__` in `program`. +fn extract_description(program: &str) -> Result { + let budget = ReducerBudget::DEFAULT_BUDGET; + let callback_every = EPOCH_TICKS_PER_SECOND; + extern "C" fn callback(_: &mut Isolate, _: *mut c_void) {} + + let (_, ret) = with_script( + Isolate::new(<_>::default()), + program, + callback_every, + callback, + budget, + |scope, _| run_describer(log_traceback, || call_describe_module(scope)), + ); + ret +} + // Calls the `__describe_module__` function on the global proxy object to extract a [`RawModuleDef`]. -fn call_describe_module(scope: &mut HandleScope<'_>) -> anyhow::Result { - // Get a cached version of the `__describe_module__` property. - let key_cache = get_or_create_key_cache(scope); - let describe_module_key = key_cache.borrow_mut().describe_module(scope); +fn call_describe_module(scope: &mut PinScope<'_, '_>) -> anyhow::Result { + let describe_module_key = str_from_ident!(__describe_module__).string(scope); catch_exception(scope, |scope| { // Get the function on the global proxy object and convert to a function. @@ -286,7 +606,7 @@ mod test { fn with_script( code: &str, - logic: impl for<'scope> FnOnce(&mut HandleScope<'scope>, Local<'scope, Value>) -> R, + logic: impl for<'scope> FnOnce(&mut PinScope<'scope, '_>, Local<'scope, Value>) -> R, ) -> R { with_scope(|scope| { let code = v8::String::new(scope, code).unwrap(); diff --git a/crates/core/src/host/v8/ser.rs b/crates/core/src/host/v8/ser.rs index 62f6130ba08..dbbbb442e7d 100644 --- a/crates/core/src/host/v8/ser.rs +++ b/crates/core/src/host/v8/ser.rs @@ -2,7 +2,8 @@ use super::de::intern_field_name; use super::error::{exception_already_thrown, ExcResult, ExceptionThrown, RangeError, Throwable, TypeError}; -use super::key_cache::{get_or_create_key_cache, KeyCache}; +use super::string_const::{TAG, VALUE}; +use super::syscall::FnRet; use super::to_value::ToValue; use derive_more::From; use spacetimedb_sats::{ @@ -10,39 +11,24 @@ use spacetimedb_sats::{ ser::{self, Serialize}, u256, }; -use v8::{Array, ArrayBuffer, HandleScope, IntegrityLevel, Local, Object, Uint8Array, Value}; +use v8::{Array, ArrayBuffer, IntegrityLevel, Local, Object, PinScope, Uint8Array, Value}; /// Serializes `value` into a V8 into `scope`. -pub(super) fn serialize_to_js<'scope>( - scope: &mut HandleScope<'scope>, - value: &impl Serialize, -) -> ExcResult> { - let key_cache = get_or_create_key_cache(scope); - let key_cache = &mut *key_cache.borrow_mut(); - value - .serialize(Serializer::new(scope, key_cache)) - .map_err(|e| e.throw(scope)) +pub(super) fn serialize_to_js<'scope>(scope: &PinScope<'scope, '_>, value: &impl Serialize) -> FnRet<'scope> { + value.serialize(Serializer::new(scope)).map_err(|e| e.throw(scope)) } /// Deserializes to V8 values. -struct Serializer<'this, 'scope> { +#[derive(Copy, Clone)] +struct Serializer<'this, 'scope, 'isolate> { /// The scope to serialize values into. - scope: &'this mut HandleScope<'scope>, - /// A cache for frequently used strings. - key_cache: &'this mut KeyCache, + scope: &'this PinScope<'scope, 'isolate>, } -impl<'this, 'scope> Serializer<'this, 'scope> { +impl<'this, 'scope, 'isolate> Serializer<'this, 'scope, 'isolate> { /// Creates a new serializer into `scope`. - pub fn new(scope: &'this mut HandleScope<'scope>, key_cache: &'this mut KeyCache) -> Self { - Self { scope, key_cache } - } - - fn reborrow(&mut self) -> Serializer<'_, 'scope> { - Serializer { - scope: self.scope, - key_cache: self.key_cache, - } + pub fn new(scope: &'this PinScope<'scope, 'isolate>) -> Self { + Self { scope } } } @@ -58,7 +44,7 @@ enum Error { } impl<'scope> Throwable<'scope> for Error { - fn throw(self, scope: &mut HandleScope<'scope>) -> ExceptionThrown { + fn throw(self, scope: &PinScope<'scope, '_>) -> ExceptionThrown { match self { Self::StringTooLarge(len) => { RangeError(format!("`{len}` bytes is too large to be a JS string")).throw(scope) @@ -92,20 +78,20 @@ macro_rules! serialize_primitive { /// However, the values of existing properties may be modified, /// which can be useful if the module wants to modify a property /// and then send the object back. -fn seal_object(scope: &mut HandleScope<'_>, object: &Object) -> ExcResult<()> { +fn seal_object(scope: &PinScope<'_, '_>, object: &Object) -> ExcResult<()> { let _ = object .set_integrity_level(scope, IntegrityLevel::Sealed) .ok_or_else(exception_already_thrown)?; Ok(()) } -impl<'this, 'scope> ser::Serializer for Serializer<'this, 'scope> { +impl<'this, 'scope, 'isolate> ser::Serializer for Serializer<'this, 'scope, 'isolate> { type Ok = Local<'scope, Value>; type Error = Error; - type SerializeArray = SerializeArray<'this, 'scope>; + type SerializeArray = SerializeArray<'this, 'scope, 'isolate>; type SerializeSeqProduct = Self::SerializeNamedProduct; - type SerializeNamedProduct = SerializeNamedProduct<'this, 'scope>; + type SerializeNamedProduct = SerializeNamedProduct<'this, 'scope, 'isolate>; // Serialization of primitive types defers to `ToValue`. serialize_primitive!(serialize_bool, bool); @@ -150,7 +136,7 @@ impl<'this, 'scope> ser::Serializer for Serializer<'this, 'scope> { } fn serialize_named_product(self, _len: usize) -> Result { - // TODO(noa): this can be more efficient if we tell it the names ahead of time + // TODO(v8, noa): this can be more efficient if we tell it the names ahead of time let object = Object::new(self.scope); Ok(SerializeNamedProduct { inner: self, @@ -160,22 +146,19 @@ impl<'this, 'scope> ser::Serializer for Serializer<'this, 'scope> { } fn serialize_variant( - mut self, + self, tag: u8, var_name: Option<&str>, value: &T, ) -> Result { // Serialize the payload. - let value_value: Local<'scope, Value> = value.serialize(self.reborrow())?; + let value_value: Local<'scope, Value> = value.serialize(self)?; // Figure out the tag. let tag_value: Local<'scope, Value> = intern_field_name(self.scope, var_name, tag as usize).into(); let values = [tag_value, value_value]; // The property keys are always `"tag"` an `"value"`. - let names = [ - self.key_cache.tag(self.scope).into(), - self.key_cache.value(self.scope).into(), - ]; + let names = [TAG.string(self.scope).into(), VALUE.string(self.scope).into()]; // Stitch together the object. let prototype = v8::null(self.scope).into(); @@ -186,19 +169,19 @@ impl<'this, 'scope> ser::Serializer for Serializer<'this, 'scope> { } /// Serializes array elements and finalizes the JS array. -struct SerializeArray<'this, 'scope> { - inner: Serializer<'this, 'scope>, +struct SerializeArray<'this, 'scope, 'isolate> { + inner: Serializer<'this, 'scope, 'isolate>, array: Local<'scope, Array>, next_index: u32, } -impl<'scope> ser::SerializeArray for SerializeArray<'_, 'scope> { +impl<'scope> ser::SerializeArray for SerializeArray<'_, 'scope, '_> { type Ok = Local<'scope, Value>; type Error = Error; fn serialize_element(&mut self, elem: &T) -> Result<(), Self::Error> { // Serialize the current `elem`ent. - let value = elem.serialize(self.inner.reborrow())?; + let value = elem.serialize(self.inner)?; // Set the value to the array slot at `index`. let index = self.next_index; @@ -216,13 +199,13 @@ impl<'scope> ser::SerializeArray for SerializeArray<'_, 'scope> { } /// Serializes into JS objects where field names are turned into property names. -struct SerializeNamedProduct<'this, 'scope> { - inner: Serializer<'this, 'scope>, +struct SerializeNamedProduct<'this, 'scope, 'isolate> { + inner: Serializer<'this, 'scope, 'isolate>, object: Local<'scope, Object>, next_index: usize, } -impl<'scope> ser::SerializeSeqProduct for SerializeNamedProduct<'_, 'scope> { +impl<'scope> ser::SerializeSeqProduct for SerializeNamedProduct<'_, 'scope, '_> { type Ok = Local<'scope, Value>; type Error = Error; @@ -235,7 +218,7 @@ impl<'scope> ser::SerializeSeqProduct for SerializeNamedProduct<'_, 'scope> { } } -impl<'scope> ser::SerializeNamedProduct for SerializeNamedProduct<'_, 'scope> { +impl<'scope> ser::SerializeNamedProduct for SerializeNamedProduct<'_, 'scope, '_> { type Ok = Local<'scope, Value>; type Error = Error; @@ -245,10 +228,10 @@ impl<'scope> ser::SerializeNamedProduct for SerializeNamedProduct<'_, 'scope> { elem: &T, ) -> Result<(), Self::Error> { // Serialize the field value. - let value = elem.serialize(self.inner.reborrow())?; + let value = elem.serialize(self.inner)?; // Figure out the object property to use. - let scope = &mut *self.inner.scope; + let scope = self.inner.scope; let index = self.next_index; self.next_index += 1; let key = intern_field_name(scope, field_name, index).into(); diff --git a/crates/core/src/host/v8/string_const.rs b/crates/core/src/host/v8/string_const.rs new file mode 100644 index 00000000000..8dc5ac6ee3f --- /dev/null +++ b/crates/core/src/host/v8/string_const.rs @@ -0,0 +1,33 @@ +use v8::{Local, OneByteConst, PinScope, String}; + +/// A string known at compile time to be ASCII. +pub(super) struct StringConst(OneByteConst); + +impl StringConst { + /// Returns a new string that is known to be ASCII and static. + pub(super) const fn new(string: &'static str) -> Self { + Self(String::create_external_onebyte_const(string.as_bytes())) + } + + /// Converts the string to a V8 string. + pub(super) fn string<'scope>(&'static self, scope: &PinScope<'scope, '_>) -> Local<'scope, String> { + String::new_from_onebyte_const(scope, &self.0) + .expect("`create_external_onebyte_const` should've asserted `.len() < kMaxLength`") + } +} + +/// Converts an identifier to a compile-time ASCII string. +#[macro_export] +macro_rules! str_from_ident { + ($ident:ident) => {{ + const STR: &$crate::host::v8::string_const::StringConst = + &$crate::host::v8::string_const::StringConst::new(stringify!($ident)); + STR + }}; +} +pub(super) use str_from_ident; + +/// The `tag` property of a sum object in JS. +pub(super) const TAG: &StringConst = str_from_ident!(tag); +/// The `value` property of a sum object in JS. +pub(super) const VALUE: &StringConst = str_from_ident!(value); diff --git a/crates/core/src/host/v8/syscall.rs b/crates/core/src/host/v8/syscall.rs new file mode 100644 index 00000000000..43e38282965 --- /dev/null +++ b/crates/core/src/host/v8/syscall.rs @@ -0,0 +1,1069 @@ +use super::de::{deserialize_js, scratch_buf}; +use super::error::{ExcResult, ExceptionThrown}; +use super::ser::serialize_to_js; +use super::string_const::{str_from_ident, StringConst}; +use super::{ + env_on_isolate, exception_already_thrown, global, BufferTooSmall, CodeError, JsInstanceEnv, JsStackTrace, + TerminationError, Throwable, +}; +use crate::database_logger::{LogLevel, Record}; +use crate::error::NodesError; +use crate::host::instance_env::InstanceEnv; +use crate::host::wasm_common::instrumentation::span; +use crate::host::wasm_common::{err_to_errno_and_log, RowIterIdx, TimingSpan, TimingSpanIdx}; +use crate::host::AbiCall; +use spacetimedb_lib::Identity; +use spacetimedb_primitives::{errno, ColId, IndexId, TableId}; +use spacetimedb_sats::Serialize; +use v8::{Function, FunctionCallbackArguments, Local, PinScope, Value}; + +/// Registers all module -> host syscalls. +pub(super) fn register_host_funs(scope: &mut PinScope<'_, '_>) -> ExcResult<()> { + macro_rules! register { + ($wrapper:ident, $abi_call:expr, $fun:ident) => { + register_host_fun(scope, str_from_ident!($fun), |s, a| { + $wrapper($abi_call, s, a, $fun) + })?; + }; + } + + register!(with_sys_result, AbiCall::TableIdFromName, table_id_from_name); + register!(with_sys_result, AbiCall::IndexIdFromName, index_id_from_name); + register!( + with_sys_result, + AbiCall::DatastoreTableRowCount, + datastore_table_row_count + ); + register!( + with_sys_result, + AbiCall::DatastoreTableScanBsatn, + datastore_table_scan_bsatn + ); + register!( + with_sys_result, + AbiCall::DatastoreIndexScanRangeBsatn, + datastore_index_scan_range_bsatn + ); + register!(with_sys_result, AbiCall::RowIterBsatnAdvance, row_iter_bsatn_advance); + register!(with_span, AbiCall::RowIterBsatnClose, row_iter_bsatn_close); + register!(with_sys_result, AbiCall::DatastoreInsertBsatn, datastore_insert_bsatn); + register!(with_sys_result, AbiCall::DatastoreUpdateBsatn, datastore_update_bsatn); + register!( + with_sys_result, + AbiCall::DatastoreDeleteByIndexScanRangeBsatn, + datastore_delete_by_index_scan_range_bsatn + ); + register!( + with_sys_result, + AbiCall::DatastoreDeleteAllByEqBsatn, + datastore_delete_all_by_eq_bsatn + ); + register!( + with_span, + AbiCall::VolatileNonatomicScheduleImmediate, + volatile_nonatomic_schedule_immediate + ); + register!(with_span, AbiCall::ConsoleLog, console_log); + register!(with_span, AbiCall::ConsoleTimerStart, console_timer_start); + register!(with_span, AbiCall::ConsoleTimerEnd, console_timer_end); + register!(with_sys_result, AbiCall::Identity, identity); + Ok(()) +} + +/// The return type of a module -> host syscall. +pub(super) type FnRet<'scope> = ExcResult>; + +/// Registers a module -> host syscall in `scope` +/// where the function has `name` and `body` +fn register_host_fun( + scope: &mut PinScope<'_, '_>, + name: &'static StringConst, + body: impl Copy + for<'scope> Fn(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>) -> FnRet<'scope>, +) -> ExcResult<()> { + let name = name.string(scope).into(); + let fun = Function::new(scope, adapt_fun(body)) + .ok_or_else(exception_already_thrown)? + .into(); + global(scope) + .set(scope, name, fun) + .ok_or_else(exception_already_thrown)?; + Ok(()) +} + +/// A flag set in [`handle_nodes_error`]. +/// The flag should be checked in every module -> host ABI. +/// If the flag is set, the call is prevented. +struct TerminationFlag; + +/// Adapts `fun`, which returns a [`Value`] to one that works on [`v8::ReturnValue`]. +fn adapt_fun( + fun: impl Copy + for<'scope> Fn(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>) -> FnRet<'scope>, +) -> impl Copy + for<'scope> Fn(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>, v8::ReturnValue) { + move |scope, args, mut rv| { + // If the flag was set in `handle_nodes_error`, + // we need to block all module -> host ABI calls. + if scope.get_slot::().is_some() { + let err = anyhow::anyhow!("execution is being terminated"); + if let Ok(exception) = TerminationError::from_error(scope, &err) { + exception.throw(scope); + } + return; + } + + // Set the result `value` on success. + if let Ok(value) = fun(scope, args) { + rv.set(value); + } + } +} + +/// Either an exception, already thrown, or [`NodesError`] arising from [`InstanceEnv`]. +#[derive(derive_more::From)] +enum SysCallError { + Error(NodesError), + Exception(ExceptionThrown), +} + +type SysCallResult = Result; + +/// Wraps `run` in [`with_span`] and returns the return value of `run` to JS. +/// Handles [`SysCallError`] if it occurs by throwing exceptions into JS. +fn with_sys_result<'scope, O: Serialize>( + abi_call: AbiCall, + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, + run: impl FnOnce(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>) -> SysCallResult, +) -> FnRet<'scope> { + match with_span(abi_call, scope, args, run) { + Ok(ret) => serialize_to_js(scope, &ret), + Err(SysCallError::Exception(exc)) => Err(exc), + Err(SysCallError::Error(error)) => Err(throw_nodes_error(abi_call, scope, error)), + } +} + +/// Turns a [`NodesError`] into a thrown exception. +fn throw_nodes_error(abi_call: AbiCall, scope: &mut PinScope<'_, '_>, error: NodesError) -> ExceptionThrown { + let res = match err_to_errno_and_log::(abi_call, error) { + Ok(code) => CodeError::from_code(scope, code), + Err(err) => { + // Terminate execution ASAP and throw a catchable exception (`TerminationError`). + // Unfortunately, JS execution won't be terminated once the callback returns, + // so we set a slot that all callbacks immediately check + // to ensure that the module won't be able to do anything to the host + // while it's being terminated (eventually). + scope.terminate_execution(); + scope.set_slot(TerminationFlag); + TerminationError::from_error(scope, &err) + } + }; + collapse_exc_thrown(scope, res) +} + +/// Collapses `res` where the `Ok(x)` where `x` is throwable. +fn collapse_exc_thrown<'scope>( + scope: &PinScope<'scope, '_>, + res: ExcResult>, +) -> ExceptionThrown { + let (Ok(thrown) | Err(thrown)) = res.map(|ev| ev.throw(scope)); + thrown +} + +/// Tracks the span of `body` under the label `abi_call`. +fn with_span<'scope, R>( + abi_call: AbiCall, + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, + body: impl FnOnce(&mut PinScope<'scope, '_>, FunctionCallbackArguments<'scope>) -> R, +) -> R { + // Start the span. + let span_start = span::CallSpanStart::new(abi_call); + + // Call `fun` with `args` in `scope`. + let result = body(scope, args); + + // Track the span of this call. + let span = span_start.end(); + span::record_span(&mut env_on_isolate(scope).call_times, span); + + result +} + +/// Module ABI that finds the `TableId` for a table name. +/// +/// # Signature +/// +/// ```ignore +/// table_id_from_name(name: string) -> u32 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_TABLE +/// } +/// ``` +/// +/// # Types +/// +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns an `u32` containing the id of the table. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_TABLE`] +/// when `name` is not the name of a table. +/// +/// Throws a `TypeError` if: +/// - `name` is not `string`. +fn table_id_from_name(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult { + let name: String = deserialize_js(scope, args.get(0))?; + Ok(env_on_isolate(scope).instance_env.table_id_from_name(&name)?) +} + +/// Module ABI that finds the `IndexId` for an index name. +/// +/// # Signature +/// +/// ```ignore +/// index_id_from_name(name: string) -> u32 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_INDEX +/// } +/// ``` +/// +/// # Types +/// +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns an `u32` containing the id of the index. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_INDEX`] +/// when `name` is not the name of an index. +/// +/// Throws a `TypeError`: +/// - if `name` is not `string`. +fn index_id_from_name(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult { + let name: String = deserialize_js(scope, args.get(0))?; + Ok(env_on_isolate(scope).instance_env.index_id_from_name(&name)?) +} + +/// Module ABI that returns the number of rows currently in table identified by `table_id`. +/// +/// # Signature +/// +/// ```ignore +/// datastore_table_row_count(table_id: u32) -> u64 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_TABLE +/// } +/// ``` +/// +/// # Types +/// +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// - `u64` is `bigint` in JS restricted to unsigned 64-bit integers. +/// +/// # Returns +/// +/// Returns a `u64` containing the number of rows in the table. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_TABLE`] +/// when `table_id` is not a known ID of a table. +/// +/// Throws a `TypeError` if: +/// - `table_id` is not a `u32`. +fn datastore_table_row_count(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult { + let table_id: TableId = deserialize_js(scope, args.get(0))?; + Ok(env_on_isolate(scope).instance_env.datastore_table_row_count(table_id)?) +} + +/// Module ABI that starts iteration on each row, as BSATN-encoded, +/// of a table identified by `table_id`. +/// +/// # Signature +/// +/// ```ignore +/// datastore_table_scan_bsatn(table_id: u32) -> u32 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_TABLE +/// } +/// ``` +/// +/// # Types +/// +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// - `u64` is `bigint` in JS restricted to unsigned 64-bit integers. +/// +/// # Returns +/// +/// Returns a `u32` that is the iterator handle. +/// This handle can be advanced by [`row_iter_bsatn_advance`]. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_TABLE`] +/// when `table_id` is not a known ID of a table. +/// +/// Throws a `TypeError`: +/// - if `table_id` is not a `u32`. +fn datastore_table_scan_bsatn(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult { + let table_id: TableId = deserialize_js(scope, args.get(0))?; + + let env = env_on_isolate(scope); + // Collect the iterator chunks. + let chunks = env + .instance_env + .datastore_table_scan_bsatn_chunks(&mut env.chunk_pool, table_id)?; + + // Register the iterator and get back the index to write to `out`. + // Calls to the iterator are done through dynamic dispatch. + Ok(env.iters.insert(chunks.into_iter()).0) +} + +/// Module ABI that finds all rows in the index identified by `index_id`, +/// according to `prefix`, `rstart`, and `rend`. +/// +/// The index itself has a schema/type. +/// The `prefix` is decoded to the initial `prefix_elems` `AlgebraicType`s +/// whereas `rstart` and `rend` are decoded to the `prefix_elems + 1` `AlgebraicType` +/// where the `AlgebraicValue`s are wrapped in `Bound`. +/// That is, `rstart, rend` are BSATN-encoded `Bound`s. +/// +/// Matching is then defined by equating `prefix` +/// to the initial `prefix_elems` columns of the index +/// and then imposing `rstart` as the starting bound +/// and `rend` as the ending bound on the `prefix_elems + 1` column of the index. +/// Remaining columns of the index are then unbounded. +/// Note that the `prefix` in this case can be empty (`prefix_elems = 0`), +/// in which case this becomes a ranged index scan on a single-col index +/// or even a full table scan if `rstart` and `rend` are both unbounded. +/// +/// The relevant table for the index is found implicitly via the `index_id`, +/// which is unique for the module. +/// +/// On success, the iterator handle is written to the `out` pointer. +/// This handle can be advanced by [`row_iter_bsatn_advance`]. +/// +/// # Non-obvious queries +/// +/// For an index on columns `[a, b, c]`: +/// +/// - `a = x, b = y` is encoded as a prefix `[x, y]` +/// and a range `Range::Unbounded`, +/// or as a prefix `[x]` and a range `rstart = rend = Range::Inclusive(y)`. +/// - `a = x, b = y, c = z` is encoded as a prefix `[x, y]` +/// and a range `rstart = rend = Range::Inclusive(z)`. +/// - A sorted full scan is encoded as an empty prefix +/// and a range `Range::Unbounded`. +/// +/// # Signature +/// +/// ```ignore +/// datastore_index_scan_range_bsatn( +/// index_id: u32, +/// prefix: u8[], +/// prefix_elems: u16, +/// rstart: u8[], +/// rend: u8[], +/// ) -> u32 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_INDEX | BSATN_DECODE_ERROR +/// } +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// - `u64` is `bigint` in JS restricted to unsigned 64-bit integers. +/// +/// # Returns +/// +/// Returns a `u32` that is the iterator handle. +/// This handle can be advanced by [`row_iter_bsatn_advance`]. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_INDEX`] +/// when `index_id` is not a known ID of an index. +/// +/// - [`spacetimedb_primitives::errno::BSATN_DECODE_ERROR`] +/// when `prefix` cannot be decoded to +/// a `prefix_elems` number of `AlgebraicValue` +/// typed at the initial `prefix_elems` `AlgebraicType`s of the index's key type. +/// Or when `rstart` or `rend` cannot be decoded to an `Bound` +/// where the inner `AlgebraicValue`s are +/// typed at the `prefix_elems + 1` `AlgebraicType` of the index's key type. +/// +/// Throws a `TypeError` if: +/// - `table_id` is not a `u32`. +/// - `prefix`, `rstart`, and `rend` are not arrays of `u8`s. +/// - `prefix_elems` is not a `u16`. +fn datastore_index_scan_range_bsatn( + scope: &mut PinScope<'_, '_>, + args: FunctionCallbackArguments<'_>, +) -> SysCallResult { + let index_id: IndexId = deserialize_js(scope, args.get(0))?; + let mut prefix: Vec = deserialize_js(scope, args.get(1))?; + let prefix_elems: ColId = deserialize_js(scope, args.get(2))?; + let rstart: Vec = deserialize_js(scope, args.get(3))?; + let rend: Vec = deserialize_js(scope, args.get(4))?; + + if prefix_elems.idx() == 0 { + prefix = Vec::new(); + } + + let env = env_on_isolate(scope); + + // Find the relevant rows. + let chunks = env.instance_env.datastore_index_scan_range_bsatn_chunks( + &mut env.chunk_pool, + index_id, + &prefix, + prefix_elems, + &rstart, + &rend, + )?; + + // Insert the encoded + concatenated rows into a new buffer and return its id. + Ok(env.iters.insert(chunks.into_iter()).0) +} + +/// Throws `{ __code_error__: NO_SUCH_ITER }`. +fn no_such_iter(scope: &PinScope<'_, '_>) -> ExceptionThrown { + let res = CodeError::from_code(scope, errno::NO_SUCH_ITER.get()); + collapse_exc_thrown(scope, res) +} + +/// Module ABI that reads rows from the given iterator registered under `iter`. +/// +/// Takes rows from the iterator with id `iter` +/// and returns them encoded in the BSATN format. +/// +/// The rows returned take up at most `buffer_max_len` bytes. +/// A row is never broken up between calls. +/// +/// Aside from the BSATN, +/// the function also returns `true` when the iterator been exhausted +/// and there are no more rows to read. +/// This leads to the iterator being immediately destroyed. +/// Conversely, `false` is returned if there are more rows to read. +/// Note that the host is free to reuse allocations in a pool, +/// destroying the handle logically does not entail that memory is necessarily reclaimed. +/// +/// # Signature +/// +/// ```ignore +/// row_iter_bsatn_advance(iter: u32, buffer_max_len: u32) -> (boolean, u8[]) throws +/// { __code_error__: NO_SUCH_ITER } | { __buffer_too_small__: number } +/// ``` +/// +/// # Types +/// +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns `(exhausted: boolean, rows_bsatn: u8[])` where: +/// - `exhausted` is `true` if there are no more rows to read, +/// - `rows_bsatn` are the BSATN-encoded row bytes, concatenated. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_ITER`] +/// when `iter` is not a valid iterator. +/// +/// Throws `{ __buffer_too_small__: number }` +/// when there are rows left but they cannot fit in `buffer`. +/// When this occurs, `__buffer_too_small__` contains the size of the next item in the iterator. +/// To make progress, the caller should call `row_iter_bsatn_advance` +/// with `buffer_max_len >= __buffer_too_small__` and try again. +/// +/// Throws a `TypeError` if: +/// - `iter` and `buffer_max_len` are not `u32`s. +fn row_iter_bsatn_advance<'scope>( + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, +) -> SysCallResult<(bool, Vec)> { + let row_iter_idx: u32 = deserialize_js(scope, args.get(0))?; + let row_iter_idx = RowIterIdx(row_iter_idx); + let buffer_max_len: u32 = deserialize_js(scope, args.get(1))?; + + // Retrieve the iterator by `row_iter_idx`, or error. + let env = env_on_isolate(scope); + let Some(iter) = env.iters.get_mut(row_iter_idx) else { + return Err(no_such_iter(scope).into()); + }; + + // Allocate a buffer with `buffer_max_len` capacity. + let mut buffer = vec![0; buffer_max_len as usize]; + // Fill the buffer as much as possible. + let written = InstanceEnv::fill_buffer_from_iter(iter, &mut buffer, &mut env.chunk_pool); + buffer.truncate(written); + + match (written, iter.as_slice().first().map(|c| c.len().try_into().unwrap())) { + // Nothing was written and the iterator is not exhausted. + (0, Some(min_len)) => { + let exc = BufferTooSmall::from_requirement(scope, min_len)?; + Err(exc.throw(scope).into()) + } + // The iterator is exhausted, destroy it, and tell the caller. + (_, None) => { + env.iters.take(row_iter_idx); + Ok((true, buffer)) + } + // Something was written, but the iterator is not exhausted. + (_, Some(_)) => Ok((false, buffer)), + } +} + +/// Module ABI that destroys the iterator registered under `iter`. +/// +/// Once `row_iter_bsatn_close` is called on `iter`, the `iter` is invalid. +/// That is, `row_iter_bsatn_close(iter)` the second time will yield `NO_SUCH_ITER`. +/// +/// # Signature +/// +/// ```ignore +/// row_iter_bsatn_close(iter: u32) -> undefined throws { +/// __code_error__: NO_SUCH_ITER +/// } +/// ``` +/// +/// # Types +/// +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns nothing. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_ITER`] +/// when `iter` is not a valid iterator. +/// +/// Throws a `TypeError` if: +/// - `iter` is not a `u32`. +fn row_iter_bsatn_close<'scope>( + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, +) -> FnRet<'scope> { + let row_iter_idx: u32 = deserialize_js(scope, args.get(0))?; + let row_iter_idx = RowIterIdx(row_iter_idx); + + // Retrieve the iterator by `row_iter_idx`, or error. + let env = env_on_isolate(scope); + + // Retrieve the iterator by `row_iter_idx`, or error. + if env.iters.take(row_iter_idx).is_none() { + return Err(no_such_iter(scope)); + } else { + // TODO(Centril): consider putting these into a pool for reuse. + } + + Ok(v8::undefined(scope).into()) +} + +/// Module ABI that inserts a row into the table identified by `table_id`, +/// where the `row` is an array of bytes. +/// +/// The byte array `row` must be a BSATN-encoded `ProductValue` +/// typed at the table's `ProductType` row-schema. +/// +/// To handle auto-incrementing columns, +/// when the call is successful, +/// the an array of bytes is returned, containing the generated sequence values. +/// These values are written as a BSATN-encoded `pv: ProductValue`. +/// Each `v: AlgebraicValue` in `pv` is typed at the sequence's column type. +/// The `v`s in `pv` are ordered by the order of the columns, in the schema of the table. +/// When the table has no sequences, +/// this implies that the `pv`, and thus `row`, will be empty. +/// +/// # Signature +/// +/// ```ignore +/// datastore_insert_bsatn(table_id: u32, row: u8[]) -> u8[] throws { +/// __code_error__: +/// NOT_IN_TRANSACTION +/// | NOT_SUCH_TABLE +/// | BSATN_DECODE_ERROR +/// | UNIQUE_ALREADY_EXISTS +/// | SCHEDULE_AT_DELAY_TOO_LONG +/// } +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns the generated sequence values encoded in BSATN (see above). +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// - [`spacetimedb_primitives::errno::NOT_SUCH_TABLE`] +/// when `table_id` is not a known ID of a table. +/// - [`spacetimedb_primitives::errno::`BSATN_DECODE_ERROR`] +/// when `row` cannot be decoded to a `ProductValue`. +/// typed at the `ProductType` the table's schema specifies. +/// - [`spacetimedb_primitives::errno::`UNIQUE_ALREADY_EXISTS`] +/// when inserting `row` would violate a unique constraint. +/// - [`spacetimedb_primitives::errno::`SCHEDULE_AT_DELAY_TOO_LONG`] +/// when the delay specified in the row was too long. +/// +/// Throws a `TypeError` if: +/// - `table_id` is not a `u32`. +/// - `row` is not an array of `u8`s. +fn datastore_insert_bsatn(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult> { + let table_id: TableId = deserialize_js(scope, args.get(0))?; + let mut row: Vec = deserialize_js(scope, args.get(1))?; + + // Insert the row into the DB and write back the generated column values. + let env: &mut JsInstanceEnv = env_on_isolate(scope); + let row_len = env.instance_env.insert(table_id, &mut row)?; + row.truncate(row_len); + + Ok(row) +} + +/// Module ABI that updates a row into the table identified by `table_id`, +/// where the `row` is an array of bytes. +/// +/// The byte array `row` must be a BSATN-encoded `ProductValue` +/// typed at the table's `ProductType` row-schema. +/// +/// The row to update is found by projecting `row` +/// to the type of the *unique* index identified by `index_id`. +/// If no row is found, the error `NO_SUCH_ROW` is returned. +/// +/// To handle auto-incrementing columns, +/// when the call is successful, +/// the `row` is written back to with the generated sequence values. +/// These values are written as a BSATN-encoded `pv: ProductValue`. +/// Each `v: AlgebraicValue` in `pv` is typed at the sequence's column type. +/// The `v`s in `pv` are ordered by the order of the columns, in the schema of the table. +/// When the table has no sequences, +/// this implies that the `pv`, and thus `row`, will be empty. +/// +/// # Signature +/// +/// ```ignore +/// datastore_update_bsatn(table_id: u32, index_id: u32, row: u8[]) -> u8[] throws { +/// __code_error__: +/// NOT_IN_TRANSACTION +/// | NOT_SUCH_TABLE +/// | NO_SUCH_INDEX +/// | INDEX_NOT_UNIQUE +/// | BSATN_DECODE_ERROR +/// | NO_SUCH_ROW +/// | UNIQUE_ALREADY_EXISTS +/// | SCHEDULE_AT_DELAY_TOO_LONG +/// } +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns the generated sequence values encoded in BSATN (see above). +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// - [`spacetimedb_primitives::errno::NOT_SUCH_TABLE`] +/// when `table_id` is not a known ID of a table. +/// - [`spacetimedb_primitives::errno::NO_SUCH_INDEX`] +/// when `index_id` is not a known ID of an index. +/// - [`spacetimedb_primitives::errno::INDEX_NOT_UNIQUE`] +/// when the index was not unique. +/// - [`spacetimedb_primitives::errno::`BSATN_DECODE_ERROR`] +/// when `row` cannot be decoded to a `ProductValue`. +/// typed at the `ProductType` the table's schema specifies +/// or when it cannot be projected to the index identified by `index_id`. +/// - [`spacetimedb_primitives::errno::`NO_SUCH_ROW`] +/// when the row was not found in the unique index. +/// - [`spacetimedb_primitives::errno::`UNIQUE_ALREADY_EXISTS`] +/// when inserting `row` would violate a unique constraint. +/// - [`spacetimedb_primitives::errno::`SCHEDULE_AT_DELAY_TOO_LONG`] +/// when the delay specified in the row was too long. +/// +/// Throws a `TypeError` if: +/// - `table_id` is not a `u32`. +/// - `row` is not an array of `u8`s. +fn datastore_update_bsatn(scope: &mut PinScope<'_, '_>, args: FunctionCallbackArguments<'_>) -> SysCallResult> { + let table_id: TableId = deserialize_js(scope, args.get(0))?; + let index_id: IndexId = deserialize_js(scope, args.get(1))?; + let mut row: Vec = deserialize_js(scope, args.get(2))?; + + // Insert the row into the DB and write back the generated column values. + let env: &mut JsInstanceEnv = env_on_isolate(scope); + let row_len = env.instance_env.update(table_id, index_id, &mut row)?; + row.truncate(row_len); + + Ok(row) +} + +/// Module ABI that deletes all rows found in the index identified by `index_id`, +/// according to `prefix`, `rstart`, and `rend`. +/// +/// This syscall will delete all the rows found by +/// [`datastore_index_scan_range_bsatn`] with the same arguments passed, +/// including `prefix_elems`. +/// See `datastore_index_scan_range_bsatn` for details. +/// +/// # Signature +/// +/// ```ignore +/// datastore_index_scan_range_bsatn( +/// index_id: u32, +/// prefix: u8[], +/// prefix_elems: u16, +/// rstart: u8[], +/// rend: u8[], +/// ) -> u32 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_INDEX | BSATN_DECODE_ERROR +/// } +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns a `u32` that is the number of rows deleted. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_INDEX`] +/// when `index_id` is not a known ID of an index. +/// +/// - [`spacetimedb_primitives::errno::BSATN_DECODE_ERROR`] +/// when `prefix` cannot be decoded to +/// a `prefix_elems` number of `AlgebraicValue` +/// typed at the initial `prefix_elems` `AlgebraicType`s of the index's key type. +/// Or when `rstart` or `rend` cannot be decoded to an `Bound` +/// where the inner `AlgebraicValue`s are +/// typed at the `prefix_elems + 1` `AlgebraicType` of the index's key type. +/// +/// Throws a `TypeError` if: +/// - `table_id` is not a `u32`. +/// - `prefix`, `rstart`, and `rend` are not arrays of `u8`s. +/// - `prefix_elems` is not a `u16`. +fn datastore_delete_by_index_scan_range_bsatn( + scope: &mut PinScope<'_, '_>, + args: FunctionCallbackArguments<'_>, +) -> SysCallResult { + let index_id: IndexId = deserialize_js(scope, args.get(0))?; + let mut prefix: Vec = deserialize_js(scope, args.get(1))?; + let prefix_elems: ColId = deserialize_js(scope, args.get(2))?; + let rstart: Vec = deserialize_js(scope, args.get(3))?; + let rend: Vec = deserialize_js(scope, args.get(4))?; + + if prefix_elems.idx() == 0 { + prefix = Vec::new(); + } + + let env = env_on_isolate(scope); + + // Delete the relevant rows. + Ok(env + .instance_env + .datastore_delete_by_index_scan_range_bsatn(index_id, &prefix, prefix_elems, &rstart, &rend)?) +} + +/// Module ABI that deletes those rows, in the table identified by `table_id`, +/// that match any row in `relation`. +/// +/// Matching is defined by first BSATN-decoding +/// the array of bytes `relation` to a `Vec` +/// according to the row schema of the table +/// and then using `Ord for AlgebraicValue`. +/// A match happens when `Ordering::Equal` is returned from `fn cmp`. +/// This occurs exactly when the row's BSATN-encoding is equal to the encoding of the `ProductValue`. +/// +/// # Signature +/// +/// ```ignore +/// datastore_delete_all_by_eq_bsatn(table_id: u32, relation: u8[]) -> u32 throws { +/// __code_error__: NOT_IN_TRANSACTION | NO_SUCH_INDEX | BSATN_DECODE_ERROR +/// } +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u16` is `number` in JS restricted to unsigned 16-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns a `u32` that is the number of rows deleted. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NOT_IN_TRANSACTION`] +/// when called outside of a transaction. +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_TABLE`] +/// when `table_id` is not a known ID of a table. +/// +/// - [`spacetimedb_primitives::errno::BSATN_DECODE_ERROR`] +/// when `relation` cannot be decoded to `Vec` +/// where each `ProductValue` is typed at the `ProductType` the table's schema specifies. +/// +/// Throws a `TypeError` if: +/// - `table_id` is not a `u32`. +/// - `relation` is not an array of `u8`s. +fn datastore_delete_all_by_eq_bsatn( + scope: &mut PinScope<'_, '_>, + args: FunctionCallbackArguments<'_>, +) -> SysCallResult { + let table_id: TableId = deserialize_js(scope, args.get(0))?; + let relation: Vec = deserialize_js(scope, args.get(1))?; + + let env = env_on_isolate(scope); + Ok(env.instance_env.datastore_delete_all_by_eq_bsatn(table_id, &relation)?) +} + +/// # Signature +/// +/// ```ignore +/// volatile_nonatomic_schedule_immediate(reducer_name: string, args: u8[]) -> undefined +/// ``` +fn volatile_nonatomic_schedule_immediate<'scope>( + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, +) -> FnRet<'scope> { + let name: String = deserialize_js(scope, args.get(0))?; + let args: Vec = deserialize_js(scope, args.get(1))?; + + let env = env_on_isolate(scope); + env.instance_env + .scheduler + .volatile_nonatomic_schedule_immediate(name, crate::host::ReducerArgs::Bsatn(args.into())); + + Ok(v8::undefined(scope).into()) +} + +/// Module ABI that logs at `level` a `message` message occurring +/// at the parent stack frame. +/// +/// The `message` is interpreted lossily as a UTF-8 string. +/// +/// # Signature +/// +/// ```ignore +/// console_log(level: u8, message: string) -> u32 +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns nothing. +fn console_log<'scope>(scope: &mut PinScope<'scope, '_>, args: FunctionCallbackArguments<'scope>) -> FnRet<'scope> { + let level: u32 = deserialize_js(scope, args.get(0))?; + + let msg = args.get(1).cast::(); + let mut buf = scratch_buf::<128>(); + let msg = msg.to_rust_cow_lossy(scope, &mut buf); + + let frame: Local<'_, v8::StackFrame> = v8::StackTrace::current_stack_trace(scope, 2) + .ok_or_else(exception_already_thrown)? + .get_frame(scope, 1) + .ok_or_else(exception_already_thrown)?; + let mut buf = scratch_buf::<32>(); + let filename = frame + .get_script_name(scope) + .map(|s| s.to_rust_cow_lossy(scope, &mut buf)); + + let level = (level as u8).into(); + let trace = if level == LogLevel::Panic { + JsStackTrace::from_current_stack_trace(scope)? + } else { + <_>::default() + }; + + let env = env_on_isolate(scope); + + let function = env.log_record_function(); + let record = Record { + // TODO: figure out whether to use walltime now or logical reducer now (env.reducer_start) + ts: chrono::Utc::now(), + target: None, + filename: filename.as_deref(), + line_number: Some(frame.get_line_number() as u32), + function, + message: &msg, + }; + + env.instance_env.console_log(level, &record, &trace); + + Ok(v8::undefined(scope).into()) +} + +/// Module ABI that begins a timing span with `name`. +/// +/// When the returned `ConsoleTimerId` is passed to [`console_timer_end`], +/// the duration between the calls will be printed to the module's logs. +/// +/// The `name` is interpreted lossily as a UTF-8 string. +/// +/// # Signature +/// +/// ```ignore +/// console_timer_start(name: string) -> u32 +/// ``` +/// +/// # Types +/// +/// - `u8` is `number` in JS restricted to unsigned 8-bit integers. +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns a `u32` that is the `ConsoleTimerId`. +/// +/// # Throws +/// +/// Throws a `TypeError` if: +/// - `name` is not a `string`. +fn console_timer_start<'scope>( + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, +) -> FnRet<'scope> { + let name = args.get(0).cast::(); + let mut buf = scratch_buf::<128>(); + let name = name.to_rust_cow_lossy(scope, &mut buf).into_owned(); + + let env = env_on_isolate(scope); + let span_id = env.timing_spans.insert(TimingSpan::new(name)).0; + serialize_to_js(scope, &span_id) +} + +/// Module ABI that ends a timing span with `span_id`. +/// +/// # Signature +/// +/// ```ignore +/// console_timer_end(span_id: u32) -> undefined throws { +/// __code_error__: NO_SUCH_CONSOLE_TIMER +/// } +/// ``` +/// +/// # Types +///s +/// - `u32` is `number` in JS restricted to unsigned 32-bit integers. +/// +/// # Returns +/// +/// Returns nothing. +/// +/// # Throws +/// +/// Throws `{ __code_error__: u16 }` where `__code_error__` is: +/// +/// - [`spacetimedb_primitives::errno::NO_SUCH_CONSOLE_TIMER`] +/// when `span_id` doesn't refer to an active timing span. +/// +/// Throws a `TypeError` if: +/// - `span_id` is not a `u32`. +fn console_timer_end<'scope>( + scope: &mut PinScope<'scope, '_>, + args: FunctionCallbackArguments<'scope>, +) -> FnRet<'scope> { + let span_id: u32 = deserialize_js(scope, args.get(0))?; + + let env = env_on_isolate(scope); + let Some(span) = env.timing_spans.take(TimingSpanIdx(span_id)) else { + let exc = CodeError::from_code(scope, errno::NO_SUCH_CONSOLE_TIMER.get())?; + return Err(exc.throw(scope)); + }; + let function = env.log_record_function(); + env.instance_env.console_timer_end(&span, function); + + Ok(v8::undefined(scope).into()) +} + +/// Module ABI that returns the module identity. +/// +/// # Signature +/// +/// ```ignore +/// identity() -> { __identity__: u256 } +/// ``` +/// +/// # Types +/// +/// - `u256` is `bigint` in JS restricted to unsigned 256-bit integers. +/// +/// # Returns +/// +/// Returns the module identity. +fn identity<'scope>(scope: &mut PinScope<'scope, '_>, _: FunctionCallbackArguments<'scope>) -> SysCallResult { + Ok(*env_on_isolate(scope).instance_env.database_identity()) +} diff --git a/crates/core/src/host/v8/to_value.rs b/crates/core/src/host/v8/to_value.rs index e97dd491eba..9fecfa5a090 100644 --- a/crates/core/src/host/v8/to_value.rs +++ b/crates/core/src/host/v8/to_value.rs @@ -2,20 +2,20 @@ use bytemuck::{NoUninit, Pod}; use spacetimedb_sats::{i256, u256}; -use v8::{BigInt, Boolean, HandleScope, Integer, Local, Number, Value}; +use v8::{BigInt, Boolean, Integer, Local, Number, PinScope, Value}; /// Types that can be converted to a v8-stack-allocated [`Value`]. /// The conversion can be done without the possibility for error. pub(super) trait ToValue { /// Converts `self` within `scope` (a sort of stack management in V8) to a [`Value`]. - fn to_value<'scope>(&self, scope: &mut HandleScope<'scope>) -> Local<'scope, Value>; + fn to_value<'scope>(&self, scope: &PinScope<'scope, '_>) -> Local<'scope, Value>; } /// Provides a [`ToValue`] implementation. macro_rules! impl_to_value { ($ty:ty, ($val:ident, $scope:ident) => $logic:expr) => { impl ToValue for $ty { - fn to_value<'scope>(&self, $scope: &mut HandleScope<'scope>) -> Local<'scope, Value> { + fn to_value<'scope>(&self, $scope: &PinScope<'scope, '_>) -> Local<'scope, Value> { let $val = *self; $logic.into() } @@ -48,7 +48,7 @@ impl_to_value!(u64, (val, scope) => BigInt::new_from_u64(scope, val)); /// /// The `sign` is passed along to the `BigInt`. fn le_bytes_to_bigint<'scope, const N: usize, const W: usize>( - scope: &mut HandleScope<'scope>, + scope: &PinScope<'scope, '_>, sign: bool, le_bytes: [u8; N], ) -> Local<'scope, BigInt> @@ -73,7 +73,7 @@ pub(super) const WORD_MIN: u64 = i64::MIN as u64; /// `i64::MIN` becomes `-1 * WORD_MIN * (2^64)^0 = -1 * WORD_MIN` /// `i128::MIN` becomes `-1 * (0 * (2^64)^0 + WORD_MIN * (2^64)^1) = -1 * WORD_MIN * 2^64` /// `i256::MIN` becomes `-1 * (0 * (2^64)^0 + 0 * (2^64)^1 + WORD_MIN * (2^64)^2) = -1 * WORD_MIN * (2^128)` -fn signed_min_bigint<'scope, const WORDS: usize>(scope: &mut HandleScope<'scope>) -> Local<'scope, BigInt> { +fn signed_min_bigint<'scope, const WORDS: usize>(scope: &PinScope<'scope, '_>) -> Local<'scope, BigInt> { let words = &mut [0u64; WORDS]; if let [.., last] = words.as_mut_slice() { *last = WORD_MIN; @@ -110,14 +110,14 @@ pub(in super::super) mod test { use core::fmt::Debug; use proptest::prelude::*; use spacetimedb_sats::proptest::{any_i256, any_u256}; - use v8::{Context, ContextScope, HandleScope, Isolate}; + use v8::{scope, Context, ContextScope, Isolate}; /// Sets up V8 and runs `logic` with a [`HandleScope`]. - pub(in super::super) fn with_scope(logic: impl FnOnce(&mut HandleScope<'_>) -> R) -> R { + pub(in super::super) fn with_scope(logic: impl FnOnce(&mut PinScope<'_, '_>) -> R) -> R { V8Runtime::init_for_test(); let isolate = &mut Isolate::new(<_>::default()); isolate.set_capture_stack_trace_for_uncaught_exceptions(true, 1024); - let scope = &mut HandleScope::new(isolate); + scope!(let scope, isolate); let context = Context::new(scope, Default::default()); let scope = &mut ContextScope::new(scope, context); diff --git a/crates/core/src/host/wasm_common.rs b/crates/core/src/host/wasm_common.rs index f2b29310ff9..08a492f9a4b 100644 --- a/crates/core/src/host/wasm_common.rs +++ b/crates/core/src/host/wasm_common.rs @@ -320,7 +320,7 @@ impl ResourceSlab { decl_index!(RowIterIdx => std::vec::IntoIter>); pub(super) type RowIters = ResourceSlab; -pub(super) struct TimingSpan { +pub(crate) struct TimingSpan { pub start: Instant, pub name: String, } @@ -337,6 +337,7 @@ impl TimingSpan { decl_index!(TimingSpanIdx => TimingSpan); pub(super) type TimingSpanSet = ResourceSlab; +/// Converts a [`NodesError`] to an error code, if possible. pub fn err_to_errno(err: &NodesError) -> Option { match err { NodesError::NotInTransaction => Some(errno::NOT_IN_TRANSACTION), @@ -362,6 +363,18 @@ pub fn err_to_errno(err: &NodesError) -> Option { } } +/// Converts a [`NodesError`] to an error code and logs, if possible. +pub fn err_to_errno_and_log>(func: AbiCall, err: NodesError) -> anyhow::Result { + let Some(errno) = err_to_errno(&err) else { + return Err(AbiRuntimeError { func, err }.into()); + }; + log::debug!( + "abi call to {func} returned an errno: {errno} ({})", + errno::strerror(errno).unwrap_or("") + ); + Ok(errno.get().into()) +} + #[derive(Debug, thiserror::Error)] #[error("runtime error calling {func}: {err}")] pub struct AbiRuntimeError { diff --git a/crates/core/src/host/wasm_common/instrumentation.rs b/crates/core/src/host/wasm_common/instrumentation.rs index e2d3550e626..cf5c00fdc7c 100644 --- a/crates/core/src/host/wasm_common/instrumentation.rs +++ b/crates/core/src/host/wasm_common/instrumentation.rs @@ -118,3 +118,8 @@ impl CallTimes { std::mem::replace(self, Self::new()) } } + +#[cfg(not(feature = "spacetimedb-wasm-instance-env-times"))] +pub use noop as span; +#[cfg(feature = "spacetimedb-wasm-instance-env-times")] +pub use op as span; diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index db5a26eb86a..1621e3a2948 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -8,7 +8,7 @@ use tracing::span::EnteredSpan; use super::instrumentation::CallTimes; use crate::client::ClientConnectionSender; use crate::database_logger; -use crate::energy::{EnergyMonitor, EnergyQuanta, ReducerBudget, ReducerFingerprint}; +use crate::energy::{EnergyMonitor, ReducerBudget, ReducerFingerprint}; use crate::host::instance_env::InstanceEnv; use crate::host::module_common::{build_common_module_from_raw, ModuleCommon}; use crate::host::module_host::{ @@ -60,11 +60,17 @@ pub trait WasmInstance: Send + Sync + 'static { } pub struct EnergyStats { - pub used: EnergyQuanta, - pub wasmtime_fuel_used: u64, + pub budget: ReducerBudget, pub remaining: ReducerBudget, } +impl EnergyStats { + /// Returns the used energy amount. + fn used(&self) -> ReducerBudget { + (self.budget.get() - self.remaining.get()).into() + } +} + pub struct ExecutionTimings { pub total_duration: Duration, pub wasm_instance_env_call_times: CallTimes, @@ -160,16 +166,7 @@ impl WasmModuleHostActor { impl WasmModuleHostActor { fn make_from_instance(&self, instance: T::Instance) -> WasmModuleInstance { - let common = InstanceCommon { - info: self.common.info(), - energy_monitor: self.common.energy_monitor(), - // will be updated on the first reducer call - allocated_memory: 0, - metric_wasm_memory_bytes: WORKER_METRICS - .wasm_memory_bytes - .with_label_values(self.common.database_identity()), - trapped: false, - }; + let common = InstanceCommon::new(&self.common); WasmModuleInstance { instance, common } } } @@ -273,6 +270,19 @@ pub(crate) struct InstanceCommon { } impl InstanceCommon { + pub(crate) fn new(module: &ModuleCommon) -> Self { + Self { + info: module.info(), + energy_monitor: module.energy_monitor(), + // Will be updated on the first reducer call. + allocated_memory: 0, + metric_wasm_memory_bytes: WORKER_METRICS + .wasm_memory_bytes + .with_label_values(module.database_identity()), + trapped: false, + } + } + #[tracing::instrument(level = "trace", skip_all)] pub(crate) fn update_database( &mut self, @@ -407,14 +417,16 @@ impl InstanceCommon { call_result, } = result; + let energy_used = energy.used(); + let energy_quanta_used = energy_used.into(); vm_metrics.report( - energy.wasmtime_fuel_used, + energy_used.get(), timings.total_duration, &timings.wasm_instance_env_call_times, ); self.energy_monitor - .record_reducer(&energy_fingerprint, energy.used, timings.total_duration); + .record_reducer(&energy_fingerprint, energy_quanta_used, timings.total_duration); if self.allocated_memory != memory_allocation { self.metric_wasm_memory_bytes.set(memory_allocation as i64); self.allocated_memory = memory_allocation; @@ -422,7 +434,7 @@ impl InstanceCommon { reducer_span .record("timings.total_duration", tracing::field::debug(timings.total_duration)) - .record("energy.used", tracing::field::debug(energy.used)); + .record("energy.used", tracing::field::debug(energy_used)); maybe_log_long_running_reducer(reducer_name, timings.total_duration); reducer_span.exit(); @@ -481,7 +493,7 @@ impl InstanceCommon { args, }, status, - energy_quanta_used: energy.used, + energy_quanta_used, host_execution_duration: timings.total_duration, request_id, timer, @@ -490,7 +502,7 @@ impl InstanceCommon { ReducerCallResult { outcome: ReducerOutcome::from(&event.status), - energy_used: energy.used, + energy_used: energy_quanta_used, execution_duration: timings.total_duration, } } diff --git a/crates/core/src/host/wasmtime/mod.rs b/crates/core/src/host/wasmtime/mod.rs index 5a61d5e23ab..06d80f85da1 100644 --- a/crates/core/src/host/wasmtime/mod.rs +++ b/crates/core/src/host/wasmtime/mod.rs @@ -27,7 +27,23 @@ pub struct WasmtimeRuntime { const EPOCH_TICK_LENGTH: Duration = Duration::from_millis(10); -const EPOCH_TICKS_PER_SECOND: u64 = Duration::from_secs(1).div_duration_f64(EPOCH_TICK_LENGTH) as u64; +pub(crate) const EPOCH_TICKS_PER_SECOND: u64 = ticks_in_duration(Duration::from_secs(1)); + +pub(crate) const fn ticks_in_duration(duration: Duration) -> u64 { + duration.div_duration_f64(EPOCH_TICK_LENGTH) as u64 +} + +pub(crate) fn epoch_ticker(mut on_tick: impl 'static + Send + FnMut() -> Option<()>) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(EPOCH_TICK_LENGTH); + loop { + interval.tick().await; + let Some(()) = on_tick() else { + return; + }; + } + }); +} impl WasmtimeRuntime { pub fn new(data_dir: Option<&ServerDataDir>) -> Self { @@ -53,13 +69,10 @@ impl WasmtimeRuntime { let engine = Engine::new(&config).unwrap(); let weak_engine = engine.weak(); - tokio::spawn(async move { - let mut interval = tokio::time::interval(EPOCH_TICK_LENGTH); - loop { - interval.tick().await; - let Some(engine) = weak_engine.upgrade() else { break }; - engine.increment_epoch(); - } + epoch_ticker(move || { + let engine = weak_engine.upgrade()?; + engine.increment_epoch(); + Some(()) }); let mut linker = Box::new(Linker::new(&engine)); diff --git a/crates/core/src/host/wasmtime/wasm_instance_env.rs b/crates/core/src/host/wasmtime/wasm_instance_env.rs index d2aabaf656e..c41c4737034 100644 --- a/crates/core/src/host/wasmtime/wasm_instance_env.rs +++ b/crates/core/src/host/wasmtime/wasm_instance_env.rs @@ -1,30 +1,20 @@ #![allow(clippy::too_many_arguments)] -use std::num::NonZeroU32; -use std::time::Instant; - +use super::{Mem, MemView, NullableMemOp, WasmError, WasmPointee, WasmPtr}; use crate::database_logger::{BacktraceFrame, BacktraceProvider, ModuleBacktrace, Record}; use crate::host::instance_env::{ChunkPool, InstanceEnv}; -use crate::host::wasm_common::instrumentation; +use crate::host::wasm_common::instrumentation::{span, CallTimes}; use crate::host::wasm_common::module_host_actor::ExecutionTimings; -use crate::host::wasm_common::{ - err_to_errno, instrumentation::CallTimes, AbiRuntimeError, RowIterIdx, RowIters, TimingSpan, TimingSpanIdx, - TimingSpanSet, -}; +use crate::host::wasm_common::{err_to_errno_and_log, RowIterIdx, RowIters, TimingSpan, TimingSpanIdx, TimingSpanSet}; use crate::host::AbiCall; use anyhow::Context as _; use spacetimedb_data_structures::map::IntMap; use spacetimedb_lib::Timestamp; use spacetimedb_primitives::{errno, ColId}; +use std::num::NonZeroU32; +use std::time::Instant; use wasmtime::{AsContext, Caller, StoreContextMut}; -use super::{Mem, MemView, NullableMemOp, WasmError, WasmPointee, WasmPtr}; - -#[cfg(not(feature = "spacetimedb-wasm-instance-env-times"))] -use instrumentation::noop as span; -#[cfg(feature = "spacetimedb-wasm-instance-env-times")] -use instrumentation::op as span; - /// A stream of bytes which the WASM module can read from /// using [`WasmInstanceEnv::bytes_source_read`]. /// @@ -123,6 +113,7 @@ pub(super) struct WasmInstanceEnv { /// The last, including current, reducer to be executed by this environment. reducer_name: String, + /// A pool of unused allocated chunks that can be reused. // TODO(Centril): consider using this pool for `console_timer_start` and `bytes_sink_write`. chunk_pool: ChunkPool, @@ -301,20 +292,11 @@ impl WasmInstanceEnv { } fn convert_wasm_result>(func: AbiCall, err: WasmError) -> RtResult { - Err(match err { - WasmError::Db(err) => match err_to_errno(&err) { - Some(errno) => { - log::debug!( - "abi call to {func} returned an errno: {errno} ({})", - errno::strerror(errno).unwrap_or("") - ); - return Ok(errno.get().into()); - } - None => anyhow::Error::from(AbiRuntimeError { func, err }), - }, - WasmError::BufferTooSmall => return Ok(errno::BUFFER_TOO_SMALL.get().into()), - WasmError::Wasm(err) => err, - }) + match err { + WasmError::Db(err) => err_to_errno_and_log(func, err), + WasmError::BufferTooSmall => Ok(errno::BUFFER_TOO_SMALL.get().into()), + WasmError::Wasm(err) => Err(err), + } } /// Call the function `run` with the name `func`. @@ -697,27 +679,12 @@ impl WasmInstanceEnv { // Read `buffer_len`, i.e., the capacity of `buffer` pointed to by `buffer_ptr`. let buffer_len = u32::read_from(mem, buffer_len_ptr)?; let write_buffer_len = |mem, len| u32::try_from(len).unwrap().write_to(mem, buffer_len_ptr); + // Get a mutable view to the `buffer`. - let mut buffer = mem.deref_slice_mut(buffer_ptr, buffer_len)?; + let buffer = mem.deref_slice_mut(buffer_ptr, buffer_len)?; - let mut written = 0; // Fill the buffer as much as possible. - while let Some(chunk) = iter.as_slice().first() { - let Some((buf_chunk, rest)) = buffer.split_at_mut_checked(chunk.len()) else { - // Cannot fit chunk into the buffer, - // either because we already filled it too much, - // or because it is too small. - break; - }; - buf_chunk.copy_from_slice(chunk); - written += chunk.len(); - buffer = rest; - - // Advance the iterator, as we used a chunk. - // SAFETY: We peeked one `chunk`, so there must be one at least. - let chunk = unsafe { iter.next().unwrap_unchecked() }; - env.chunk_pool.put(chunk); - } + let written = InstanceEnv::fill_buffer_from_iter(iter, buffer, &mut env.chunk_pool); let ret = match (written, iter.as_slice().first()) { // Nothing was written and the iterator is not exhausted. @@ -1332,24 +1299,8 @@ impl WasmInstanceEnv { let Some(span) = caller.data_mut().timing_spans.take(TimingSpanIdx(span_id)) else { return Ok(errno::NO_SUCH_CONSOLE_TIMER.get().into()); }; - - let elapsed = span.start.elapsed(); - let message = format!("Timing span {:?}: {:?}", &span.name, elapsed); let function = caller.data().log_record_function(); - - let record = Record { - ts: chrono::Utc::now(), - target: None, - filename: None, - line_number: None, - function, - message: &message, - }; - caller.data().instance_env.console_log( - crate::database_logger::LogLevel::Info, - &record, - &caller.as_context(), - ); + caller.data().instance_env.console_timer_end(&span, function); Ok(0) }) } @@ -1366,7 +1317,7 @@ impl WasmInstanceEnv { // as we want to possibly trap, but not to return an error code. Self::with_span(caller, AbiCall::Identity, |caller| { let (mem, env) = Self::mem_env(caller); - let identity = env.instance_env.replica_ctx.database.database_identity; + let identity = env.instance_env.database_identity(); // We're implicitly casting `out_ptr` to `WasmPtr` here. // (Both types are actually `u32`.) // This works because `Identity::write_to` does not require an aligned pointer, diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index 4374b708eb3..7496b6ca854 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -4,6 +4,7 @@ use super::wasm_instance_env::WasmInstanceEnv; use super::{Mem, WasmtimeFuel, EPOCH_TICKS_PER_SECOND}; use crate::energy::ReducerBudget; use crate::host::instance_env::InstanceEnv; +use crate::host::module_common::run_describer; use crate::host::wasm_common::module_host_actor::{DescribeError, InitializationError}; use crate::host::wasm_common::*; use crate::util::string_from_utf8_lossy_owned; @@ -158,29 +159,18 @@ pub struct WasmtimeInstance { impl module_host_actor::WasmInstance for WasmtimeInstance { fn extract_descriptions(&mut self) -> Result, DescribeError> { let describer_func_name = DESCRIBE_MODULE_DUNDER; - let store = &mut self.store; - let describer = self.instance.get_func(&mut *store, describer_func_name).unwrap(); - let describer = describer - .typed::(&mut *store) + let describer = self + .instance + .get_typed_func::(&mut self.store, describer_func_name) .map_err(|_| DescribeError::Signature)?; - let sink = store.data_mut().setup_standard_bytes_sink(); - - let start = std::time::Instant::now(); - log::trace!("Start describer \"{describer_func_name}\"..."); - - let result = describer.call(&mut *store, sink); + let sink = self.store.data_mut().setup_standard_bytes_sink(); - let duration = start.elapsed(); - log::trace!("Describer \"{}\" ran: {} us", describer_func_name, duration.as_micros()); - - result - .inspect_err(|err| log_traceback("describer", describer_func_name, err)) - .map_err(DescribeError::RuntimeError)?; + run_describer(log_traceback, || describer.call(&mut self.store, sink))?; // Fetch the bsatn returned by the describer call. - let bytes = store.data_mut().take_standard_bytes_sink(); + let bytes = self.store.data_mut().take_standard_bytes_sink(); Ok(bytes) } @@ -192,11 +182,8 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { #[tracing::instrument(level = "trace", skip_all)] fn call_reducer(&mut self, op: ReducerOp<'_>, budget: ReducerBudget) -> module_host_actor::ExecuteResult { let store = &mut self.store; - // note that ReducerBudget being a u64 is load-bearing here - although we convert budget right back into - // EnergyQuanta at the end of this function, from_energy_quanta clamps it to a u64 range. - // otherwise, we'd return something like `used: i128::MAX - u64::MAX`, which is inaccurate. + // Set the fuel budget in WASM. set_store_fuel(store, budget.into()); - let original_fuel = get_store_fuel(store); store.set_epoch_deadline(EPOCH_TICKS_PER_SECOND); // Prepare sender identity and connection ID, as LITTLE-ENDIAN byte arrays. @@ -231,14 +218,10 @@ impl module_host_actor::WasmInstance for WasmtimeInstance { let call_result = call_result.map(|code| handle_error_sink_code(code, error)); + // Compute fuel and heap usage. let remaining_fuel = get_store_fuel(store); - let remaining: ReducerBudget = remaining_fuel.into(); - let energy = module_host_actor::EnergyStats { - used: (budget - remaining).into(), - wasmtime_fuel_used: original_fuel.0 - remaining_fuel.0, - remaining, - }; + let energy = module_host_actor::EnergyStats { budget, remaining }; let memory_allocation = store.data().get_mem().memory.data_size(&store); module_host_actor::ExecuteResult { diff --git a/crates/core/src/messages/control_db.rs b/crates/core/src/messages/control_db.rs index 8299875e339..8f92f350324 100644 --- a/crates/core/src/messages/control_db.rs +++ b/crates/core/src/messages/control_db.rs @@ -75,9 +75,12 @@ pub struct NodeStatus { /// SEE: pub state: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize, serde::Deserialize, +)] #[repr(i32)] pub enum HostType { + #[default] Wasm = 0, Js = 1, } diff --git a/crates/primitives/src/errno.rs b/crates/primitives/src/errno.rs index b00cea7a579..540aab6bdf4 100644 --- a/crates/primitives/src/errno.rs +++ b/crates/primitives/src/errno.rs @@ -22,6 +22,7 @@ macro_rules! errnos { SCHEDULE_AT_DELAY_TOO_LONG(13, "Specified delay in scheduling row was too long"), INDEX_NOT_UNIQUE(14, "The index was not unique"), NO_SUCH_ROW(15, "The row was not found, e.g., in an update call"), + AUTO_INC_OVERFLOW(16, "The auto-increment sequence overflowed"), ); }; } diff --git a/crates/sats/src/de.rs b/crates/sats/src/de.rs index 719e0e01186..7a511b1ee25 100644 --- a/crates/sats/src/de.rs +++ b/crates/sats/src/de.rs @@ -719,12 +719,7 @@ impl<'de, E: Error> SumAccess<'de> for NoneAccess { impl<'de, E: Error> VariantAccess<'de> for NoneAccess { type Error = E; fn deserialize_seed>(self, seed: T) -> Result { - use crate::algebraic_value::de::*; - seed.deserialize(ValueDeserializer::new(crate::AlgebraicValue::unit())) - .map_err(|err| match err { - ValueDeserializeError::MismatchedType => E::custom("mismatched type"), - ValueDeserializeError::Custom(err) => E::custom(err), - }) + seed.deserialize(UnitAccess::new()) } } @@ -753,3 +748,126 @@ impl<'de, D: Deserializer<'de>> VariantAccess<'de> for SomeAccess { seed.deserialize(self.0) } } + +pub struct UnitAccess(PhantomData); + +impl UnitAccess { + /// Returns a new [`UnitAccess`]. + pub fn new() -> Self { + Self(PhantomData) + } +} + +impl Default for UnitAccess { + fn default() -> Self { + Self::new() + } +} + +impl<'de, E: Error> SeqProductAccess<'de> for UnitAccess { + type Error = E; + + fn next_element_seed>(&mut self, _seed: T) -> Result, Self::Error> { + Ok(None) + } +} + +impl<'de, E: Error> NamedProductAccess<'de> for UnitAccess { + type Error = E; + + fn get_field_ident>(&mut self, _visitor: V) -> Result, Self::Error> { + Ok(None) + } + + fn get_field_value_seed>(&mut self, _seed: T) -> Result { + unreachable!() + } +} + +impl<'de, E: Error> Deserializer<'de> for UnitAccess { + type Error = E; + + fn deserialize_product>(self, visitor: V) -> Result { + visitor.visit_seq_product(self) + } + + fn deserialize_sum>(self, _visitor: V) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_bool(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_u8(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_u16(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_u32(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_u64(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_u128(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_u256(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_i8(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_i16(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_i32(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_i64(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_i128(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_i256(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_f32(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_f64(self) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_str>(self, _visitor: V) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_bytes>(self, _visitor: V) -> Result { + Err(E::custom("invalid type")) + } + + fn deserialize_array_seed, T: DeserializeSeed<'de> + Clone>( + self, + _visitor: V, + _seed: T, + ) -> Result { + Err(E::custom("invalid type")) + } +} diff --git a/crates/standalone/Cargo.toml b/crates/standalone/Cargo.toml index e719721721d..17b8d547062 100644 --- a/crates/standalone/Cargo.toml +++ b/crates/standalone/Cargo.toml @@ -19,6 +19,7 @@ required-features = [] # Features required to build this target (N/A for lib) [features] # Perfmaps for profiling modules perfmap = ["spacetimedb-core/perfmap"] +unstable = ["spacetimedb-client-api/unstable"] [dependencies] spacetimedb-client-api-messages.workspace = true diff --git a/docs/package.json b/docs/package.json index db8f28b3e3d..49d91dc3b7c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,7 @@ }, "scripts": { "build": "tsc --project ./tsconfig.build.json && pnpm fix-markdown && prettier --write docs/nav.js", - "fix-markdown": "scripts/markdown-fix.mjs docs/cli-reference.md", + "fix-markdown": "node scripts/markdown-fix.mjs docs/cli-reference.md", "format": "pnpm fix-markdown && prettier . --write --ignore-path .prettierignore", "lint": "eslint . && prettier . --check --ignore-path .prettierignore", "check-links": "tsx scripts/checkLinks.ts" diff --git a/modules/module-test-ts/package-lock.json b/modules/module-test-ts/package-lock.json new file mode 100644 index 00000000000..69a1f0ac828 --- /dev/null +++ b/modules/module-test-ts/package-lock.json @@ -0,0 +1,113 @@ +{ + "name": "sdk-test-module", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sdk-test-module", + "license": "ISC", + "dependencies": { + "fast-text-encoding": "^1.0.0" + }, + "devDependencies": { + "@types/fast-text-encoding": "^1.0.3", + "tsup": "^8.1.0" + } + }, + "../../node_modules/.pnpm/fast-text-encoding@1.0.6/node_modules/fast-text-encoding": { + "version": "1.0.6", + "license": "Apache-2.0" + }, + "../../node_modules/.pnpm/tsup@8.5.0_jiti@2.5.1_postcss@8.5.6_tsx@4.20.4_typescript@5.9.2/node_modules/tsup": { + "version": "8.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.25.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "0.8.0-beta.0", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.50.0", + "@rollup/plugin-json": "6.1.0", + "@swc/core": "1.10.18", + "@types/debug": "4.1.12", + "@types/node": "22.13.4", + "@types/resolve": "1.20.6", + "bumpp": "^10.0.3", + "flat": "6.0.1", + "postcss": "8.5.2", + "postcss-simple-vars": "7.0.1", + "prettier": "3.5.1", + "resolve": "1.22.10", + "rollup-plugin-dts": "6.1.1", + "sass": "1.85.0", + "strip-json-comments": "5.0.1", + "svelte": "5.19.9", + "svelte-preprocess": "6.0.3", + "terser": "^5.39.0", + "ts-essentials": "10.0.4", + "tsup": "8.3.6", + "typescript": "5.7.3", + "vitest": "3.0.6", + "wait-for-expect": "3.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@types/fast-text-encoding": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz", + "integrity": "sha512-bbGJt6IyiuyAhPOX7htQDDzv2bDGJdWyd6X+e1BcdPzU3e5jyjOdB86LoTSoE80faY9v8Wt7/ix3Sp+coRJ03Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-text-encoding": { + "resolved": "../../node_modules/.pnpm/fast-text-encoding@1.0.6/node_modules/fast-text-encoding", + "link": true + }, + "node_modules/tsup": { + "resolved": "../../node_modules/.pnpm/tsup@8.5.0_jiti@2.5.1_postcss@8.5.6_tsx@4.20.4_typescript@5.9.2/node_modules/tsup", + "link": true + } + } +} diff --git a/modules/module-test-ts/package.json b/modules/module-test-ts/package.json new file mode 100644 index 00000000000..550e23c8f4e --- /dev/null +++ b/modules/module-test-ts/package.json @@ -0,0 +1,15 @@ +{ + "name": "sdk-test-module", + "license": "ISC", + "type": "module", + "sideEffects": false, + "scripts": { + "build": "tsup" + }, + "dependencies": { + "fast-text-encoding": "^1.0.0" + }, + "devDependencies": { + "tsup": "^8.1.0" + } +} diff --git a/modules/module-test-ts/src/index.ts b/modules/module-test-ts/src/index.ts new file mode 100644 index 00000000000..25da1a7b06d --- /dev/null +++ b/modules/module-test-ts/src/index.ts @@ -0,0 +1,378 @@ +// ───────────────────────────────────────────────────────────────────────────── +// IMPORTS +// ───────────────────────────────────────────────────────────────────────────── +import { ScheduleAt } from '../../../crates/bindings-typescript/src'; +import { schema, table, t, type Infer, type InferTypeOfRow } from '../../../crates/bindings-typescript/src/server'; + +// ───────────────────────────────────────────────────────────────────────────── +// TYPE ALIASES +// ───────────────────────────────────────────────────────────────────────────── +// Rust: pub type TestAlias = TestA +export type TestAlias = TestA; + +// ───────────────────────────────────────────────────────────────────────────── +// SUPPORT TYPES (SpacetimeType equivalents) +// ───────────────────────────────────────────────────────────────────────────── + +// Rust: #[derive(SpacetimeType)] pub struct TestB { foo: String } +export const testB = t.object({ + foo: t.string(), +}); +export type TestB = Infer; + +// Rust: #[derive(SpacetimeType)] #[sats(name = "Namespace.TestC")] enum TestC { Foo, Bar } +// TODO: support enum name attribute in TS bindings +export const testC = t.enum({ + Foo: t.unit(), + Bar: t.unit(), +}); +export type TestC = Infer; + +// Rust: const DEFAULT_TEST_C: TestC = TestC::Foo; +export const DEFAULT_TEST_C: TestC = { tag: 'Foo', value: {} } as const; + +// Rust: #[derive(SpacetimeType)] pub struct Baz { pub field: String } +export const Baz = t.object({ + field: t.string(), +}); +export type Baz = Infer; + +// Rust: #[derive(SpacetimeType)] pub enum Foobar { Baz(Baz), Bar, Har(u32) } +export const foobar = t.enum({ + Baz: Baz, + Bar: t.unit(), + Har: t.u32(), +}); +export type Foobar = Infer; + +// Rust: #[derive(SpacetimeType)] #[sats(name = "Namespace.TestF")] enum TestF { Foo, Bar, Baz(String) } +// TODO: support enum name attribute in TS bindings +export const testF = t.enum({ + Foo: t.unit(), + Bar: t.unit(), + Baz: t.string(), +}); +export type TestF = Infer; + +// Rust: #[derive(Deserialize)] pub struct Foo<'a> { pub field: &'a str } +// In TS we simply model as a struct and provide a BSATN deserializer placeholder. +export const Foo = t.object({ field: t.string() }); +export type Foo = Infer; +export function Foo_baz_bsatn(_bytes: Uint8Array): Foo { + // If your bindings expose a bsatn decode helper, use it here. + // return bsatn.fromSlice(bytes, Foo); + throw new Error('Implement BSATN decode for Foo if needed'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// TABLE ROW DEFINITIONS (shape only) +// ───────────────────────────────────────────────────────────────────────────── + +// Rust: #[spacetimedb::table(name = person, public, index(name = age, btree(columns = [age])))] +const personRow = { + id: t.u32().primaryKey().autoInc(), + name: t.string(), + age: t.u8(), +}; + +// Rust: #[spacetimedb::table(name = test_a, index(name = foo, btree(columns = [x])))] +export const testA = t.row({ + x: t.u32(), + y: t.u32(), + z: t.string(), +}); +export type TestA = Infer; + +// Rust: #[table(name = test_d, public)] struct TestD { #[default(Some(DEFAULT_TEST_C))] test_c: Option, } +// NOTE: If your Option default requires wrapping, adjust to your bindings’ Option encoding. +const testDRow = { + test_c: t.option(testC).default(DEFAULT_TEST_C as unknown as any), +}; +export type TestD = InferTypeOfRow; + +// Rust: #[spacetimedb::table(name = test_e)] #[derive(Debug)] +const testERow = { + id: t.u64().primaryKey().autoInc(), + name: t.string(), +}; + +// Rust: #[table(name = test_f, public)] pub struct TestFoobar { pub field: Foobar } +const testFRow = { + field: foobar, +}; + +// Rust: #[spacetimedb::table(name = private_table, private)] +const privateTableRow = { + name: t.string(), +}; + +// Rust: #[spacetimedb::table(name = points, private, index(name = multi_column_index, btree(columns = [x, y])))] +const pointsRow = { + x: t.i64(), + y: t.i64(), +}; + +// Rust: #[spacetimedb::table(name = pk_multi_identity)] +const pkMultiIdentityRow = { + id: t.u32().primaryKey(), + other: t.u32().unique().autoInc(), +}; + +// Rust: #[spacetimedb::table(name = repeating_test_arg, scheduled(repeating_test))] +export const repeatingTestArg = t.row({ + scheduled_id: t.u64().primaryKey().autoInc(), + scheduled_at: t.scheduleAt(), + prev_time: t.timestamp(), +}); +export type RepeatingTestArg = Infer; + +// Rust: #[spacetimedb::table(name = has_special_stuff)] +const hasSpecialStuffRow = { + identity: t.identity(), + connection_id: t.connectionId(), +}; + +// Rust: two tables with the same row type: player & logged_out_player +const playerLikeRow = { + identity: t.identity().primaryKey(), + player_id: t.u64().autoInc().unique(), + name: t.string().unique(), +}; + +// ───────────────────────────────────────────────────────────────────────────── +// SCHEMA (tables + indexes + visibility) +// ───────────────────────────────────────────────────────────────────────────── +export const spacetimedb = schema( + // person (public) with btree index on age + table( + { name: 'person', public: true, indexes: [{ name: 'age', algorithm: 'btree', columns: ['age'] }] }, + personRow + ), + + // test_a with index foo on x + table( + { name: 'test_a', indexes: [{ name: 'foo', algorithm: 'btree', columns: ['x'] }] }, + testA + ), + + // test_d (public) with default(Some(DEFAULT_TEST_C)) option field + table({ name: 'test_d', public: true }, testDRow), + + // test_e, default private, with primary key id auto_inc and btree index on name + table( + { name: 'test_e', public: false, indexes: [{ name: 'name', algorithm: 'btree', columns: ['name'] }] }, + testERow + ), + + // test_f (public) with Foobar field + table({ name: 'test_f', public: true }, testFRow), + + // private_table (explicit private) + table({ name: 'private_table', public: false }, privateTableRow), + + // points (private) with multi-column btree index (x, y) + table( + { name: 'points', public: false, indexes: [{ name: 'multi_column_index', algorithm: 'btree', columns: ['x', 'y'] }] }, + pointsRow + ), + + // pk_multi_identity with multiple constraints + table({ name: 'pk_multi_identity' }, pkMultiIdentityRow), + + // repeating_test_arg table with scheduled(repeating_test) + table({ name: 'repeating_test_arg', scheduled: 'repeating_test' } as any, repeatingTestArg), + + // has_special_stuff with Identity and ConnectionId + table({ name: 'has_special_stuff' }, hasSpecialStuffRow), + + // Two tables with the same row type: player and logged_out_player + table({ name: 'player', public: true }, playerLikeRow), + table({ name: 'logged_out_player', public: true }, playerLikeRow) +); + +// ───────────────────────────────────────────────────────────────────────────── +// REDUCERS (mirroring Rust order & behavior) +// ───────────────────────────────────────────────────────────────────────────── + +// init +spacetimedb.reducer('init', {}, (ctx) => { + ctx.db.repeating_test_arg.insert({ + prev_time: ctx.timestamp, + scheduled_id: 0n, // u64 autoInc placeholder (engine will assign) + scheduled_at: ScheduleAt.interval(1000000n), // 1000ms + }); +}); + +// repeating_test +spacetimedb.reducer('repeating_test', { arg: repeatingTestArg }, (ctx, { arg }) => { + const delta = ctx.timestamp.since(arg.prev_time); // adjust if API differs + console.trace(`Timestamp: ${ctx.timestamp}, Delta time: ${delta}`); +}); + +// add(name, age) +spacetimedb.reducer('add', { name: t.string(), age: t.u8() }, (ctx, { name, age }) => { + ctx.db.person.insert({ id: 0, name, age }); +}); + +// say_hello() +spacetimedb.reducer('say_hello', {}, (ctx) => { + for (const person of ctx.db.person.iter()) { + console.info(`Hello, ${person.name}!`); + } + console.info('Hello, World!'); +}); + +// list_over_age(age) +spacetimedb.reducer('list_over_age', { age: t.u8() }, (ctx, { age }) => { + // Prefer an index-based scan if exposed by bindings; otherwise iterate. + for (const person of ctx.db.person.iter()) { + if (person.age >= age) { + console.info(`${person.name} has age ${person.age} >= ${age}`); + } + } +}); + +// log_module_identity() +spacetimedb.reducer('log_module_identity', {}, (ctx) => { + console.info(`Module identity: ${ctx.identity}`); +}); + +// test(arg: TestAlias(TestA), arg2: TestB, arg3: TestC, arg4: TestF) +spacetimedb.reducer('test', { arg: testA, arg2: testB, arg3: testC, arg4: testF }, (ctx, { arg, arg2, arg3, arg4 }) => { + console.info('BEGIN'); + console.info(`sender: ${ctx.sender}`); + console.info(`timestamp: ${ctx.timestamp}`); + console.info(`bar: ${arg2.foo}`); + + // TestC + if (arg3.tag === 'Foo') console.info('Foo'); + else if (arg3.tag === 'Bar') console.info('Bar'); + + // TestF + if (arg4.tag === 'Foo') console.info('Foo'); + else if (arg4.tag === 'Bar') console.info('Bar'); + else if (arg4.tag === 'Baz') console.info(arg4.value); + + // Insert test_a rows + for (let i = 0; i < 1000; i++) { + ctx.db.test_a.insert({ x: (i >>> 0) + arg.x, y: (i >>> 0) + arg.y, z: 'Yo' }); + } + + const rowCountBefore = ctx.db.test_a.count(); + console.info(`Row count before delete: ${rowCountBefore}`); + + // Delete rows by the indexed column `x` in [5,10) + let numDeleted = 0; + for (let x = 5; x < 10; x++) { + // Prefer index deletion if available; fallback to filter+delete + for (const row of ctx.db.test_a.iter()) { + if (row.x === x) { + if (ctx.db.test_a.delete(row)) numDeleted++; + } + } + } + + const rowCountAfter = ctx.db.test_a.count(); + if (Number(rowCountBefore) !== Number(rowCountAfter) + numDeleted) { + console.error( + `Started with ${rowCountBefore} rows, deleted ${numDeleted}, and wound up with ${rowCountAfter} rows... huh?` + ); + } + + // try_insert TestE { id: 0, name: "Tyler" } + try { + const inserted = ctx.db.test_e.tryInsert({ id: 0n, name: 'Tyler' }); + console.info(`Inserted: ${JSON.stringify(inserted)}`); + } catch (err) { + console.info(`Error: ${String(err)}`); + } + + console.info(`Row count after delete: ${rowCountAfter}`); + + const otherRowCount = ctx.db.test_a.count(); + console.info(`Row count filtered by condition: ${otherRowCount}`); + + console.info('MultiColumn'); + + for (let i = 0; i < 1000; i++) { + ctx.db.points.insert({ x: BigInt(i) + BigInt(arg.x), y: BigInt(i) + BigInt(arg.y) }); + } + + let multiRowCount = 0; + for (const row of ctx.db.points.iter()) { + if (row.x >= 0n && row.y <= 200n) multiRowCount++; + } + console.info(`Row count filtered by multi-column condition: ${multiRowCount}`); + + console.info('END'); +}); + +// add_player(name) -> Result<(), String> +spacetimedb.reducer('add_player', { name: t.string() }, (ctx, { name }) => { + const rec = { id: 0n as bigint, name }; + const inserted = ctx.db.test_e.insert(rec); // id autoInc => always creates a new one + // No-op re-upsert by id index if your bindings support it. + if (ctx.db.test_e.id?.update) ctx.db.test_e.id.update(inserted); +}); + +// delete_player(id) -> Result<(), String> +spacetimedb.reducer('delete_player', { id: t.u64() }, (ctx, { id }) => { + const ok = ctx.db.test_e.id.delete(id); + if (!ok) throw new Error(`No TestE row with id ${id}`); +}); + +// delete_players_by_name(name) -> Result<(), String> +spacetimedb.reducer('delete_players_by_name', { name: t.string() }, (ctx, { name }) => { + let deleted = 0; + for (const row of ctx.db.test_e.iter()) { + if (row.name === name) { + if (ctx.db.test_e.delete(row)) deleted++; + } + } + if (deleted === 0) throw new Error(`No TestE row with name ${JSON.stringify(name)}`); + console.info(`Deleted ${deleted} player(s) with name ${JSON.stringify(name)}`); +}); + +// client_connected hook +spacetimedb.reducer('client_connected', {}, (_ctx) => { + // no-op +}); + +// add_private(name) +spacetimedb.reducer('add_private', { name: t.string() }, (ctx, { name }) => { + ctx.db.private_table.insert({ name }); +}); + +// query_private() +spacetimedb.reducer('query_private', {}, (ctx) => { + for (const row of ctx.db.private_table.iter()) { + console.info(`Private, ${row.name}!`); + } + console.info('Private, World!'); +}); + +// test_btree_index_args +// (In Rust this exists to type-check various index argument forms.) +spacetimedb.reducer('test_btree_index_args', {}, (ctx) => { + const s = 'String'; + // Demonstrate scanning via iteration; prefer index access if bindings expose it. + for (const row of ctx.db.test_e.iter()) { + if (row.name === s || row.name === 'str') { + // no-op; exercising types + } + } + for (const row of ctx.db.points.iter()) { + void row; // exercise multi-column index presence + } +}); + +// assert_caller_identity_is_module_identity +spacetimedb.reducer('assert_caller_identity_is_module_identity', {}, (ctx) => { + const caller = ctx.sender; + const owner = ctx.identity; + if (String(caller) !== String(owner)) { + throw new Error(`Caller ${caller} is not the owner ${owner}`); + } else { + console.info(`Called by the owner ${owner}`); + } +}); diff --git a/modules/module-test-ts/tsconfig.json b/modules/module-test-ts/tsconfig.json new file mode 100644 index 00000000000..7085dee853a --- /dev/null +++ b/modules/module-test-ts/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + + "strict": true, + "declaration": true, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "Bundler", + "isolatedDeclarations": false, + + // This library is ESM-only, do not import commonjs modules + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + + // Crucial when using esbuild/swc/babel instead of tsc emit: + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["src/index.ts", "tests/**/*", "vitest.config.ts", "tsup.config.ts"], + "exclude": ["node_modules", "**/__tests__/*", "dist/**/*"] +} diff --git a/modules/module-test-ts/tsup.config.ts b/modules/module-test-ts/tsup.config.ts new file mode 100644 index 00000000000..c82590a9254 --- /dev/null +++ b/modules/module-test-ts/tsup.config.ts @@ -0,0 +1,28 @@ +// tsup.config.ts +import { defineConfig, type Options } from 'tsup'; + +const outExtension = (ctx: { format: string }) => ({ + js: ctx.format === 'cjs' ? '.cjs' : ctx.format === 'esm' ? '.mjs' : '.js', +}); + +export default defineConfig([ + { + entry: { index: 'src/index.ts' }, + format: ['esm'], + target: 'es2022', + outDir: 'dist', + dts: false, + sourcemap: true, + clean: true, + platform: 'neutral', // flip to 'node' if you actually rely on Node builtins + treeshake: 'smallest', + external: ['undici'], + noExternal: ['base64-js', 'fast-text-encoding'], + outExtension, + }, +]) satisfies + | Options + | Options[] + | (( + overrideOptions: Options + ) => Options | Options[] | Promise); diff --git a/modules/module-test/src/lib.rs b/modules/module-test/src/lib.rs index 7986af92841..11967b9cb26 100644 --- a/modules/module-test/src/lib.rs +++ b/modules/module-test/src/lib.rs @@ -107,6 +107,41 @@ pub struct PrivateTable { name: String, } +// TODO: Filter on Option is not yet implemented +// so this is nearly useless, but this does compile +#[spacetimedb::table(name = indexed_option_field)] +pub struct IndexedOptionField { + #[index(btree)] + field: Option +} + +#[spacetimedb::reducer] +fn indexed_option_field_example(ctx: &ReducerContext) { + // FilterableValue is not yet implemented + // let _ = ctx.db.indexed_option_field().field().filter(None); +} + +// TODO: Find on Option is not yet implemented +// so this is nearly useless, but this does compile +#[spacetimedb::table(name = unique_option_field)] +pub struct UniqueOptionField { + #[unique] + field: Option +} + +#[spacetimedb::reducer] +fn unique_option_field_example(ctx: &ReducerContext) { + // FilterableValue is not yet implemented + // let _ = ctx.db.unique_option_field().field().find(None); +} + +// TODO: not implemented yet +// #[spacetimedb::table(name = auto_inc_option_field)] +// pub struct AutoIncOptionField { +// #[auto_inc] +// field: Option +// } + #[spacetimedb::table(name = points, private, index(name = multi_column_index, btree(columns = [x, y])))] pub struct Point { x: i64, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ec33cd904..8ec761c2718 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,16 @@ importers: specifier: ^5.0.0 version: 5.0.0 + modules/module-test-ts: + dependencies: + fast-text-encoding: + specifier: ^1.0.0 + version: 1.0.6 + devDependencies: + tsup: + specifier: ^8.1.0 + version: 8.5.0(jiti@2.5.1)(postcss@8.5.6)(tsx@4.20.4)(typescript@5.9.2) + packages: '@adobe/css-tools@4.4.4': @@ -1293,6 +1303,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-text-encoding@1.0.6: + resolution: {integrity: sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3456,6 +3469,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-text-encoding@1.0.6: {} + fastq@1.19.1: dependencies: reusify: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b4ea2a3e883..e89cabfafae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,5 @@ packages: - 'crates/bindings-typescript' - 'crates/bindings-typescript/test-app' - 'crates/bindings-typescript/examples/quickstart-chat' - - 'docs' \ No newline at end of file + - 'modules/module-test-ts' + - 'docs'