Compare commits

..

241 Commits

Author SHA1 Message Date
Guido D'Orsi
c936c8c611 Merge pull request #2708 from garden-co/changeset-release/main
Version Packages
2025-08-08 19:29:12 +02:00
github-actions[bot]
58c6013770 Version Packages 2025-08-08 16:28:02 +00:00
Guido D'Orsi
3eb3291a97 Merge pull request #2714 from garden-co/revert-2712-fix/invalid-signature-allowlist
Revert "feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9"
2025-08-08 18:25:32 +02:00
Guido D'Orsi
6b659f2df3 Revert "feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9" 2025-08-08 18:25:22 +02:00
Guido D'Orsi
dcc9c9a5ec Merge pull request #2712 from garden-co/fix/invalid-signature-allowlist
feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9
2025-08-08 18:14:58 +02:00
Guido D'Orsi
fe9a244363 Merge pull request #2710 from garden-co/fix/missing-child-rotation
fix: handle missing child groups when rotating key
2025-08-08 18:12:11 +02:00
Guido D'Orsi
9440bbc058 Merge pull request #2713 from garden-co/fix/nested-discriminated-union
fix: fix nested discriminated unions
2025-08-08 18:08:34 +02:00
Guido D'Orsi
1c92cc2997 chore: improve the key fallback 2025-08-08 18:07:15 +02:00
Guido D'Orsi
33ebbf0bdd fix: fix nested discriminated unions 2025-08-08 17:58:50 +02:00
Guido D'Orsi
d630b5bde5 Merge pull request #2704 from garden-co/fix/everyone-readkey-rotation
fix: skip rotateKey when everyone has read access
2025-08-08 17:31:14 +02:00
Guido D'Orsi
1c6ae12cd9 feat: add markAsStorageSignatureToFix to make it possible to fix bad signatures caused by the storage bug fixed in 0.15.9 2025-08-08 14:53:58 +02:00
Guido D'Orsi
21bcaabd5a chore: update failing snapshot 2025-08-08 13:13:45 +02:00
Guido D'Orsi
17b4d5b668 chore: update failing snapshot 2025-08-08 13:12:51 +02:00
Guido D'Orsi
3cd15862d5 fix: handle missing child groups when rotating key 2025-08-08 13:11:40 +02:00
Guido D'Orsi
b3d1ad7201 Merge pull request #2709 from garden-co/revert-2682-gio/usage-metering
Revert "feat: add ingress/egress metering on cojosn-transport-ws"
2025-08-08 12:32:58 +02:00
Guido D'Orsi
d87df11795 fix: fallback to the latest available readkey when key_for_everyone was not being revealed when everyone has access 2025-08-08 12:16:55 +02:00
Giordano Ricci
82c2a62b2a Revert "feat: add ingress/egress metering on cojosn-transport-ws" 2025-08-08 11:03:48 +01:00
Guido D'Orsi
0a9112506e fix: fixes cilrcular import issue on group.test.ts 2025-08-08 11:40:24 +02:00
Giordano Ricci
fbc29f2f17 Merge pull request #2682 from garden-co/gio/usage-metering 2025-08-08 11:16:59 +02:00
Guido D'Orsi
f6361ee43b Merge pull request #2703 from didier/patch-2
Update Svelte setup doc to be more accurate for Svelte 5
2025-08-06 15:41:16 +02:00
Guido D'Orsi
726dbfb6df fix: heal groups with missing key for everyone 2025-08-06 13:44:10 +02:00
Guido D'Orsi
267f689f10 fix: skip rotateKey when everyone has read access 2025-08-06 12:26:36 +02:00
Giordano Ricci
893ad3ae23 comment out flaky assertion 2025-08-06 12:25:06 +02:00
Giordano Ricci
f5590b1be8 remove duplicated import 2025-08-06 12:14:10 +02:00
Giordano Ricci
17a01f57e8 move utils 2025-08-06 12:12:08 +02:00
Giordano Ricci
7318d86f52 Merge branch 'main' into gio/usage-metering 2025-08-06 12:05:05 +02:00
Didier Catz
1c8403e87a Update to new schema syntax instead of classes 2025-08-06 10:23:35 +02:00
Didier Catz
dd747c068a Use consistent quotes / semis 2025-08-06 00:30:00 +02:00
Didier Catz
1f0f230fe2 Newline 2025-08-06 00:28:37 +02:00
Didier Catz
da655cbff5 Typo 2025-08-06 00:24:45 +02:00
Didier Catz
02f6c6220e Update svelte.mdx 2025-08-06 00:10:31 +02:00
Didier Catz
0755cd198e Update svelte.mdx 2025-08-06 00:02:30 +02:00
Didier Catz
c4a8227b66 Update Svelte setup doc to be more accurate for Svelte 5 2025-08-06 00:00:55 +02:00
Giordano Ricci
86f0302233 add meta 2025-08-05 13:13:06 +01:00
Guido D'Orsi
165a6170cd Merge pull request #2700 from garden-co/changeset-release/main
Version Packages
2025-08-04 21:13:54 +02:00
github-actions[bot]
5148419df9 Version Packages 2025-08-04 19:11:13 +00:00
Guido D'Orsi
fc0ecb0968 chore: changeset 2025-08-04 21:07:48 +02:00
Guido D'Orsi
802b5a3060 chore: changeset 2025-08-04 21:06:23 +02:00
Guido D'Orsi
e47af262b3 Merge pull request #2673 from garden-co/feat/storage-wal
fix: ensure that transactions are synced in the correct order
2025-08-04 20:54:53 +02:00
Guido D'Orsi
e98b610fd0 Merge pull request #2698 from garden-co/feat/comap-pick-and-partial
feat: Add `.pick()` and `.partial()` methods to CoMapSchema
2025-08-04 14:53:38 +02:00
Guido D'Orsi
b554983558 Merge pull request #2699 from garden-co/fix/extend-circular-check
fix: fixes error when extending a group without having child groups loaded
2025-08-04 14:53:15 +02:00
Guido D'Orsi
d95dcbe7db fix: align pick to the Zod API 2025-08-04 13:24:44 +02:00
Guido D'Orsi
f9d538f049 fix: fixes error when extending a group without having child groups loaded 2025-08-04 12:37:53 +02:00
Guido D'Orsi
93e68c62f5 docs: fix a missing type alias 2025-08-04 10:52:06 +02:00
Guido D'Orsi
dadee9dcc5 test: fix flaky test 2025-08-04 10:40:03 +02:00
Guido D'Orsi
6724c4bd83 feat: add docs, remove lodash-es dependency and add tests for recursive types with pick and partial 2025-08-04 10:34:43 +02:00
NicoR
1942bd5de4 Replace lodash with lodash-es 2025-08-04 01:44:27 -03:00
NicoR
16764f6365 Add changeset 2025-08-04 01:23:00 -03:00
NicoR
b56cfc2e1f Add TS docs 2025-08-04 01:21:32 -03:00
NicoR
7091bcf9c0 Add CoMapSchema.partial 2025-08-04 01:17:25 -03:00
NicoR
436cbfa095 Add CoMapSchema.pick 2025-08-04 01:00:57 -03:00
Guido D'Orsi
104e664bbb fix: fix build errors on music player 2025-08-03 17:20:15 +02:00
Guido D'Orsi
f199b451eb chore: use inline JSON when creating covalues 2025-08-03 17:09:02 +02:00
Guido D'Orsi
70bc48458e Merge pull request #2695 from garden-co/feat/music-player-refresh
docs: exclude upgrade guides from llm.txt
2025-08-02 14:37:56 +02:00
Guido D'Orsi
f28b2a6135 docs: exclude upgrade guides 2025-08-02 14:36:55 +02:00
Guido D'Orsi
55b770b7c9 Merge pull request #2694 from garden-co/feat/music-player-refresh
feat: improve the music player UI
2025-08-02 14:29:35 +02:00
Guido D'Orsi
e6838dfb98 feat: make the music-player a PWA 2025-08-02 14:22:36 +02:00
Guido D'Orsi
5e34061fdc feat: improve the music player UI 2025-08-02 14:19:24 +02:00
Guido D'Orsi
acecffaeb2 test: fix flaky tests on the created and update time 2025-08-01 19:57:33 +02:00
Nico Rainhart
0a98d6aaf2 Merge pull request #2691 from garden-co/changeset-release/main
Version Packages
2025-08-01 12:57:33 -03:00
github-actions[bot]
4ea1a63a0a Version Packages 2025-08-01 15:47:51 +00:00
Nico Rainhart
41a4c3bc95 Merge pull request #2683 from garden-co/feat/json-create-and-set
feat: create CoValues using plain JSON objects
2025-08-01 12:45:30 -03:00
Guido D'Orsi
60d0027f9d Merge pull request #2690 from garden-co/docs/useCoState-jsDoc
docs: adds jsDocs to useCoState and useAccount react hooks
2025-08-01 16:12:39 +02:00
Guido D'Orsi
748c2ff751 Merge pull request #2688 from garden-co/changeset-release/main
Version Packages
2025-08-01 15:46:41 +02:00
github-actions[bot]
70938b0ab3 Version Packages 2025-08-01 13:42:25 +00:00
Guido D'Orsi
f2f5b55dbf Update packages/jazz-tools/src/react-core/hooks.ts
Co-authored-by: Nico Rainhart <nmrainhart@gmail.com>
2025-08-01 15:42:24 +02:00
Guido D'Orsi
3c3acae803 Merge pull request #2689 from joeinnes/2687-fix-broken-link
Fix HTTP API link in inbox.mdx. Fixes #2687
2025-08-01 15:40:07 +02:00
Guido D'Orsi
896ee3460f docs: adds jsDocs to useCoState and useAccount react hooks 2025-08-01 15:37:02 +02:00
Joe Innes
9b9bf44e2b Fix HTTP API link in inbox.mdx. Fixes #2687 2025-08-01 15:13:33 +02:00
Guido D'Orsi
392aa88d95 Merge pull request #2655 from joeinnes/docs/optional-references
Docs/optional references
2025-08-01 13:15:20 +02:00
Joe Innes
7ce82cd934 Merge branch 'main' into docs/optional-references 2025-08-01 13:13:26 +02:00
Guido D'Orsi
0c8158b91c Merge pull request #2676 from Gabrola/fix/jazz-run-exports
fix: jazz-run package.json exports
2025-08-01 13:07:30 +02:00
Guido D'Orsi
5a48c9c44c chore: improve tests titles and add comments 2025-08-01 10:14:24 +02:00
Guido D'Orsi
25c56146f5 Merge pull request #2686 from garden-co/test/logout-state
test: logout integration tests on browser
2025-08-01 09:57:18 +02:00
NicoR
c564fbb02e test: add permission tests for creating nested CoValues from JSON 2025-07-31 15:03:25 -03:00
Guido D'Orsi
12481e14c2 test: logout integration tests on browser 2025-07-31 18:56:37 +02:00
NicoR
fd2d247ff5 docs: improve examples 2025-07-31 13:08:50 -03:00
NicoR
9e9ea029b2 fix: move alert out of CodeGroup 2025-07-30 17:32:56 -03:00
NicoR
a0da272dcd fix: add missing import in docs 2025-07-30 16:51:23 -03:00
NicoR
72fbcc3262 chore: remove unnecessary import from form example 2025-07-30 15:52:07 -03:00
NicoR
f4c8cc858b docs: add sections for creating CoValues from JSON and permissions 2025-07-30 15:48:55 -03:00
Anselm
0ab4d7a20d Update meta description 2025-07-30 11:34:54 -07:00
Giordano Ricci
5c98ff4e4f use object.values 2025-07-30 19:24:45 +01:00
NicoR
4cbda689c4 refactor: update form example to use new API 2025-07-30 14:54:08 -03:00
NicoR
771b0ed914 fix: cannot create empty plain text when nested 2025-07-30 14:53:38 -03:00
NicoR
79913c3136 fix: simplify CoMapInit schema 2025-07-30 13:37:10 -03:00
NicoR
43d3511d15 Add changeset 2025-07-30 12:40:25 -03:00
NicoR
928ef14086 feat: support deeply nested optional primitive fields 2025-07-30 12:33:28 -03:00
NicoR
048dd7def0 feat: support deeply nested optional CoValue fields 2025-07-30 12:33:09 -03:00
Guido D'Orsi
51fcb8a44b test: improve the client subscription test 2025-07-30 17:29:01 +02:00
Guido D'Orsi
c5888c39f5 perf: update parent before updating children to favor batching 2025-07-30 17:14:45 +02:00
Guido D'Orsi
2defcfae67 test: mark retry unavailable states as flaky 2025-07-30 17:10:50 +02:00
NicoR
873b146d15 feat: create a child group for each created CoValue 2025-07-30 11:03:38 -03:00
Guido D'Orsi
213de11c3b feat: preserve transaction order on sync 2025-07-30 15:37:58 +02:00
Giordano Ricci
1b881cc89f cleanup tests 2025-07-30 12:00:54 +01:00
Guido D'Orsi
af295d816a chore: add comments and rename CoValueSyncQueue in LocalTransactionsSyncQueue 2025-07-30 12:54:46 +02:00
Guido D'Orsi
fe8d3497c0 chore: fix the peer attribution on storage corrections tests 2025-07-30 12:37:19 +02:00
Giordano Ricci
c2899e94ca add changeset 2025-07-30 11:36:17 +01:00
Giordano Ricci
f4be67e9b6 Merge branch 'main' into gio/usage-metering 2025-07-30 11:34:37 +01:00
Guido D'Orsi
ba9ad295b6 fix: don't consider -1 as a valid signature checkpoint 2025-07-30 12:32:07 +02:00
Giordano Ricci
9ed5a96ef8 lockfile update 2025-07-30 11:28:44 +01:00
Giordano Ricci
4272ea9019 refactor: use getTransactionSize util 2025-07-30 11:28:17 +01:00
Giordano Ricci
9509307ed1 cleanup and add egress tests 2025-07-30 11:27:24 +01:00
Giordano Ricci
be08921bc5 cleanup and add ingress tests 2025-07-30 11:01:10 +01:00
NicoR
ab1798c7bd feat: make CoValue creation from JSON type safe 2025-07-29 16:41:48 -03:00
NicoR
26ae69a242 refactor: reuse TypeOfZodSchema 2025-07-29 12:44:42 -03:00
Giordano Ricci
25be055a51 wip: basica ingress/egress metering 2025-07-29 16:39:34 +01:00
NicoR
21ad3767b9 refactor: avoid InstanceOrPrimitiveOfSchemaCoValuesNullable duplication 2025-07-29 11:48:46 -03:00
NicoR
a9383516c1 refactor: avoid InstanceOrPrimitiveOfSchema duplication 2025-07-29 11:38:09 -03:00
NicoR
bffc516c68 refactor: extract TypeOfZodSchema util 2025-07-29 11:16:14 -03:00
NicoR
9e7c0d9887 chore: clean up instantiateRefEncodedWithInit's implementation 2025-07-29 10:56:32 -03:00
NicoR
99b44d5780 feat: create CoMap with JSON discriminated union fields 2025-07-29 10:36:33 -03:00
NicoR
02db5f3b1d feat: create CoMap with JSON CoFeed fields 2025-07-29 09:13:01 -03:00
NicoR
1949a5fcd9 feat: create CoMap with JSON CoList fields 2025-07-28 17:15:43 -03:00
NicoR
dcd3b022cc feat: create CoMap with JSON plain and rich text fields 2025-07-28 16:56:43 -03:00
NicoR
a7b837c7e1 feat: create CoMap with JSON CoMap fields 2025-07-28 16:47:51 -03:00
Anselm Eickhoff
88ebcf58ab Merge pull request #2680 from garden-co/jazz-as-a-db
Jazz as a DB narrative MVP
2025-07-28 11:34:01 -07:00
Guido D'Orsi
b173e0884a feat: improve local transactions streaming calculation 2025-07-28 19:45:31 +02:00
Anselm
f379a168be Update garden co slogan 2025-07-28 10:30:18 -07:00
Anselm
bde6ac7d45 Jazz as a DB narrative MVP 2025-07-28 10:08:56 -07:00
Guido D'Orsi
231947c97a fix(sync): start a new content message when the size exceeds the recommended value 2025-07-28 18:44:13 +02:00
Guido D'Orsi
d1609cdd55 Merge pull request #2679 from garden-co/changeset-release/main
Version Packages
2025-07-28 18:12:46 +02:00
Guido D'Orsi
d5b57ad1fc fix: fix priority for content 2025-07-28 17:53:33 +02:00
github-actions[bot]
b71ab3168a Version Packages 2025-07-28 15:15:40 +00:00
Nico Rainhart
0c8f6e5039 Merge pull request #2677 from garden-co/feat/add-nullable-support
feat: Add support for nullable non-collaborative fields
2025-07-28 12:12:12 -03:00
Guido D'Orsi
0bf5c53bec fix: disable code coverage check on CI 2025-07-28 16:59:02 +02:00
Guido D'Orsi
e7b1550003 feat: perserve insert order when storing transactions on multiple covalues 2025-07-28 16:59:02 +02:00
NicoR
6a93a1b8a3 chore: add comment on why nullable date cofields are not supported 2025-07-28 11:52:44 -03:00
NicoR
9f654a2603 test: loading a map with a nullable field 2025-07-28 11:46:50 -03:00
NicoR
dbf735d9e1 Fix rebase error 2025-07-28 10:27:44 -03:00
NicoR
c62abefb66 Add changeset 2025-07-28 10:19:55 -03:00
NicoR
1453869a46 Add support for nullable non-collaborative fields 2025-07-28 10:18:39 -03:00
Youssef Gaber
239da90c9f chore: changeset 2025-07-28 17:06:01 +04:00
Youssef Gaber
972791e7a8 fix: correct jazz-run package.json exports 2025-07-28 17:05:45 +04:00
NicoR
0c0178764e chore: fix rebase errors 2025-07-28 09:34:24 -03:00
NicoR
928350b821 refactor: rename OptionalizeUndefinedKeys to PartialOnUndefined 2025-07-28 09:34:23 -03:00
NicoR
be3fd9c696 test: create CoMap with shallowly resolved CoValue 2025-07-28 09:34:23 -03:00
NicoR
269c028df0 test: add test for CoMapSchema + catchall 2025-07-28 09:34:23 -03:00
NicoR
e4df837138 refactor: rename CoMapInitZod to CoMapSchemaInit 2025-07-28 09:34:23 -03:00
NicoR
54fe6d93ba refactor: extract CoMapSchema.create's return type 2025-07-28 09:34:23 -03:00
NicoR
979689c6d8 refactor: improve CatchAll type handling in CoMapSchemas 2025-07-28 09:34:23 -03:00
NicoR
859a37868f refactor: simplify CoMapSchema.create's return type 2025-07-28 09:34:23 -03:00
NicoR
57bd32d77e refactor: simplify the type of CoMapSchema.create's init parameter 2025-07-28 09:34:23 -03:00
Nico Rainhart
e21cbccd4b Merge pull request #2674 from garden-co/changeset-release/main
Version Packages
2025-07-25 11:07:46 -03:00
github-actions[bot]
a66ab7d174 Version Packages 2025-07-25 13:34:52 +00:00
Nico Rainhart
78e91f4030 Merge pull request #2667 from garden-co/0-16
Jazz 0.16 upgrade
2025-07-25 10:32:40 -03:00
Guido D'Orsi
7a915c198e Merge pull request #2657 from garden-co/fix/root-trusting
feat: store the root id unencrypted in account
2025-07-25 14:39:59 +02:00
Guido D'Orsi
c9b0420746 Merge pull request #2672 from garden-co/chore/fix-conflicts-between-http-api-and-schema-refactoring
chore: Fix conflicts between HTTP requests and CoValue schema refactor
2025-07-25 14:39:16 +02:00
NicoR
2303f3e70a fix: schema definition error in server-http-worker example 2025-07-25 09:31:47 -03:00
NicoR
a7bc9569a3 chore: Fix conflicts between HTTP requests and CoValue schema refactor 2025-07-25 09:19:15 -03:00
NicoR
f351ba0fcd Merge remote-tracking branch 'origin/main' into chore/fix-conflicts-between-http-api-and-schema-refactoring 2025-07-25 09:12:05 -03:00
Guido D'Orsi
d3e554f491 Merge pull request #2671 from garden-co/chore/queues
chore: move cojson queues in a dedicated directory
2025-07-25 13:03:33 +02:00
Guido D'Orsi
b5e31456ad chore: trigger deploy 2025-07-25 12:25:05 +02:00
Guido D'Orsi
42d07ba7b4 docs: upgrade docs for account root id 2025-07-25 11:13:40 +02:00
Guido D'Orsi
b81b6ba69b Merge remote-tracking branch 'origin/0-16' into fix/root-trusting 2025-07-25 10:54:21 +02:00
Guido D'Orsi
1bc1759bb4 Merge remote-tracking branch 'origin/main' into chore/queues 2025-07-25 10:51:43 +02:00
Nico Rainhart
512aacdbc2 Merge pull request #2669 from garden-co/fix/simplify-circular-constraint
fix: circular constraint type check error with `Simplify`
2025-07-24 17:07:53 -03:00
NicoR
7ad843aa3e fix: circular constraint type check error with Simplify 2025-07-24 16:18:21 -03:00
Guido D'Orsi
fc027a56db Merge pull request #2650 from garden-co/refactor/covalue-zod-schema-boundary
refactor: CoValue schemas are no longer Zod schemas
2025-07-24 17:41:09 +02:00
Guido D'Orsi
959a7a3927 Merge branch '0-16' into refactor/covalue-zod-schema-boundary 2025-07-24 17:40:51 +02:00
NicoR
2548085b59 Update upgrade guide and docs with improved support for recursive refs 2025-07-24 12:24:33 -03:00
Guido D'Orsi
b27bb3e65b test: remove .only 2025-07-24 17:21:04 +02:00
Guido D'Orsi
d1efde468f Merge remote-tracking branch 'origin/main' into fix/root-trusting 2025-07-24 16:55:31 +02:00
Guido D'Orsi
2b61e853a7 test: cover recursive references without explicit return type 2025-07-24 16:43:35 +02:00
Guido D'Orsi
6f79b45544 chore: move cojson queues in a dedicated directory 2025-07-24 16:23:58 +02:00
Guido D'Orsi
2e1ff99579 chore: use import content instead of copy on acceptInvite 2025-07-24 16:04:43 +02:00
NicoR
ac782674de One more Upgrade guide tweak 2025-07-23 11:24:14 -03:00
NicoR
5eb406d54d Upgrade guide adjustments 2025-07-23 11:03:47 -03:00
NicoR
a3be832414 Remove WithHelpers export 2025-07-23 10:55:35 -03:00
NicoR
7ca8dd960e Merge branch 'main' into refactor/covalue-zod-schema-boundary 2025-07-23 10:50:08 -03:00
NicoR
62c8aff73f Fix Upgrade guide navigation 2025-07-23 10:24:53 -03:00
NicoR
7731109a28 Address Upgrade guide comments 2025-07-23 10:08:14 -03:00
NicoR
dfc4286694 Return resolved resized images on createImage 2025-07-22 15:15:02 -03:00
NicoR
970ff0d813 Fix docs 2025-07-22 14:57:57 -03:00
NicoR
eee221f563 Merge branch 'main' into refactor/covalue-zod-schema-boundary 2025-07-22 13:38:47 -03:00
NicoR
2283d375ef Update docs 2025-07-22 12:17:49 -03:00
NicoR
202e763380 Merge branch 'main' into refactor/covalue-zod-schema-boundary 2025-07-22 12:07:57 -03:00
NicoR
52bbdb37a9 Add .optional() to all CoValue schemas 2025-07-22 12:06:21 -03:00
NicoR
f5c47feeb6 Upgrade Zod to 3.25.76 instead 2025-07-22 11:33:01 -03:00
NicoR
b8b0851433 Add upgrade guide 2025-07-22 10:42:46 -03:00
NicoR
2bbb07b0bf Add changeset 2025-07-22 10:01:00 -03:00
NicoR
d3053955d8 Update docs 2025-07-22 09:34:40 -03:00
NicoR
f40484eca9 Migrate plain text, rich text, file stream and optional schemas to classes 2025-07-21 17:00:57 -03:00
NicoR
d581a59aa1 Convert CoDiscriminatedUnionSchema into a class 2025-07-21 16:39:43 -03:00
NicoR
0ca09f75c1 Convert CoFeedSchema into a class 2025-07-21 16:30:04 -03:00
NicoR
e8fcd101f2 Convert CoListSchema into a class 2025-07-21 16:24:35 -03:00
NicoR
cf43fa7529 Remove usage of withHelpers 2025-07-21 15:23:36 -03:00
NicoR
df1cdda4e8 Update zod version in all examples 2025-07-21 15:15:51 -03:00
NicoR
7a60d7bb76 Avoid NotNull duplication 2025-07-21 14:33:22 -03:00
NicoR
f8263a8358 Stop extending Zod schemas when creating core CoValue schemas 2025-07-21 14:25:23 -03:00
NicoR
f6da966922 Explain "core" CoValue schema / actual CoValue schema distinction 2025-07-21 14:21:44 -03:00
NicoR
8a2ab51543 Expose internal schemas for co.map, co.list, co.optional and co.account 2025-07-21 14:09:48 -03:00
NicoR
466e6c44ee Remove unused imports 2025-07-21 11:43:11 -03:00
NicoR
5bd8277161 Remove withHelpers schema method 2025-07-21 11:41:28 -03:00
NicoR
0ec917e453 Remove non-namespaced CoValue schema exports 2025-07-21 10:42:23 -03:00
NicoR
6326d0fc45 Export co.Image type 2025-07-21 10:30:55 -03:00
NicoR
d746b1279a Remove deprecated createCoValueObservable function 2025-07-21 10:18:54 -03:00
Guido D'Orsi
c09dcdfc76 feat: make the root trusting 2025-07-21 12:06:28 +02:00
NicoR
4402c553b6 Fix z.object bug with cyclic references 2025-07-18 15:44:54 -03:00
NicoR
e76fe343da Fix co.discriminatedUnion with cyclic references 2025-07-18 15:10:53 -03:00
NicoR
a2626a0f38 Avoid rehydrating CoValue schemas 2025-07-18 14:21:27 -03:00
NicoR
ec579bcaf7 Go back to using tuple for discriminatedUnion's options type 2025-07-18 14:19:53 -03:00
Joe Innes
6b662b0efe Type fixes for twoslash 2025-07-18 15:06:03 +02:00
NicoR
e9af90c841 Fix Zod type messing up Zod's type inference 2025-07-17 17:06:37 -03:00
NicoR
2b7c6f5aa7 Rename anySchemaToCoSchema to coValueClassFromCoValueClassOrSchema 2025-07-17 16:55:43 -03:00
NicoR
d73a3d9d46 Rename files 2025-07-17 16:52:56 -03:00
NicoR
8af39077a3 Remove CoValueSchema.getZodSchema 2025-07-17 16:44:48 -03:00
NicoR
54bd487818 PlainText, RichText and FileStream schemas are no longer Zod schemas 2025-07-17 16:30:09 -03:00
Joe Innes
a8b3ec7bb0 Add more detail regarding optional references
As the boundary becomes more defined between CoValue schemas and Zod schemas, we need to ensure folks pick the right `.optional()` between `co.optional()` for CoValues and `z.optional()` for primitives.
2025-07-17 20:24:03 +02:00
NicoR
a420b43029 Add CoDiscriminatedUnionSchema.optional() 2025-07-17 14:29:52 -03:00
NicoR
a57268de32 Add runtime check to prevent using z.object with coValues as values 2025-07-17 14:12:41 -03:00
NicoR
6b2c4ed280 Remove no longer necessary Zod re-export wrappers 2025-07-17 14:12:41 -03:00
NicoR
8d4e0027be Upgrade Zod to 4.0.5 2025-07-17 14:12:41 -03:00
NicoR
a4141da1b7 Drop support for z.optional CoValue schemas 2025-07-17 14:12:41 -03:00
NicoR
c9ca5202f9 Fix browser integration tests 2025-07-17 14:12:41 -03:00
NicoR
7b50a2e06d Avoid using core.$ZodTypeDiscriminable for CoDiscriminatedUnions 2025-07-17 14:12:40 -03:00
NicoR
43dabccb57 [WIP] Discriminable CoValue schemas no longer extend $ZodDiscriminatedUnion 2025-07-17 14:12:40 -03:00
NicoR
b6d04f56ef Preserve catchall type info in CoMapSchema 2025-07-17 14:12:40 -03:00
NicoR
628195b678 Remove unused types from test 2025-07-17 14:12:40 -03:00
NicoR
9a5d769717 Use CoValue schema types (instead of Zod's) in circular references 2025-07-17 14:12:40 -03:00
NicoR
e30a3f66bf CoValue schemas are no longer Zod schemas 2025-07-17 14:12:40 -03:00
NicoR
6327fce933 Refactor CoValue instances & Zod primitives type inference 2025-07-17 14:12:40 -03:00
NicoR
a650da4184 Fix bug with deeply nested discriminated unions 2025-07-17 14:12:40 -03:00
NicoR
6e4a94f6ce Avoid accessing CoDiscriminatedUnion Zod internals directly 2025-07-17 14:12:40 -03:00
NicoR
b73bec64bc Rename AnyCoSchemas to CoreCoValueSchemas 2025-07-17 14:12:40 -03:00
NicoR
50ae2f47c2 Remove CoValue schema cache 2025-07-17 14:12:40 -03:00
NicoR
724d8e7f30 Drop support for z.discriminatedUnion of CoValue schemas 2025-07-17 14:12:40 -03:00
NicoR
7b285ab110 Modify CoMap schema to no longer extend ZodObject 2025-07-17 14:12:40 -03:00
NicoR
01ac9b8c4c Rewrite tests that access Zod internals 2025-07-17 14:12:40 -03:00
NicoR
4e2e1ac73e Convert CoValue schemas into interfaces 2025-07-17 14:12:40 -03:00
NicoR
94960c1f65 Convert CoValue schemas into a discriminated union 2025-07-17 14:12:40 -03:00
NicoR
b5af58347b Add getZodSchema method to CoValue schemas 2025-07-17 14:12:40 -03:00
NicoR
46a84558c5 Clean up InstanceOfSchema types 2025-07-17 14:12:40 -03:00
NicoR
f93566c045 Replace references to z.core.$ZodType with AnyZodSchema 2025-07-17 14:12:39 -03:00
NicoR
d97ed603a3 Tighten CoOptionalSchema inner type 2025-07-17 14:12:39 -03:00
NicoR
8d33103182 Rename zodSchemaToCoSchema to coreSchemaToCoSchema 2025-07-17 14:12:39 -03:00
NicoR
aaa1ff978b Organize Schema Union types 2025-07-17 14:12:39 -03:00
NicoR
82655ea7a7 Stop exporting zodSchemaToCoSchema 2025-07-17 14:12:39 -03:00
NicoR
8afe3a2e02 Remove unnecessary usages of zodSchemaToCoSchema 2025-07-17 14:12:39 -03:00
NicoR
ae2adcbd15 Extract functions to create CoreCoSchemas 2025-07-17 14:12:39 -03:00
NicoR
eb0460d330 Revert https://github.com/garden-co/jazz/pull/2651 2025-07-17 14:12:30 -03:00
223 changed files with 9445 additions and 3440 deletions

View File

@@ -1,5 +1,57 @@
# passkey-svelte
## 0.0.111
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [33ebbf0]
- jazz-tools@0.16.5
## 0.0.110
### Patch Changes
- Updated dependencies [16764f6]
- jazz-tools@0.16.4
## 0.0.109
### Patch Changes
- Updated dependencies [43d3511]
- jazz-tools@0.16.3
## 0.0.108
### Patch Changes
- jazz-tools@0.16.2
## 0.0.107
### Patch Changes
- Updated dependencies [c62abef]
- jazz-tools@0.16.1
## 0.0.106
### Patch Changes
- 2bbb07b: Introduce a cleaner separation between Zod and CoValue schemas:
- Zod schemas and CoValue schemas are fully separated. Zod schemas can only be composed with other Zod schemas. CoValue schemas can be composed with either Zod or other CoValue schemas.
- `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas. Use `co.optional()` and `co.discriminatedUnion()` instead.
- Internal schema access is now simpler. You no longer need to use Zods `.def` to access internals. Use properties like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType` directly.
- CoValue schema types are now namespaced under `co.`. Non-namespaced exports have been removed
- CoMap schemas no longer incorrectly inherit from Zod. Previously, methods like `.extend()` and `.partial()` appeared available but could cause unexpected behavior. These methods are now disabled. In their place, `.optional()` has been added, and more Zod-like methods will be introduced in future releases.
- Upgraded Zod from `3.25.28` to `3.25.76`.
- Removed deprecated `withHelpers` method from CoValue schemas
- Removed deprecated `createCoValueObservable` function
- Updated dependencies [c09dcdf]
- Updated dependencies [2bbb07b]
- jazz-tools@0.16.0
## 0.0.105
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "chat-svelte",
"version": "0.0.105",
"version": "0.0.111",
"type": "module",
"private": true,
"scripts": {

View File

@@ -1,8 +1,8 @@
import { co, z } from 'jazz-tools';
import { co } from 'jazz-tools';
export const Message = co.map({
text: co.plainText(),
image: z.optional(co.image())
image: co.optional(co.image())
});
export const Chat = co.list(Message);

View File

@@ -18,7 +18,7 @@
"lucide-react": "^0.274.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "3.25.28"
"zod": "3.25.76"
},
"devDependencies": {
"@playwright/test": "^1.50.1",

View File

@@ -1,5 +1,4 @@
import { useIframeHashRouter } from "hash-slash";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { useState } from "react";
import { Errors } from "./Errors.tsx";
@@ -9,7 +8,7 @@ import {
BubbleTeaOrder,
DraftBubbleTeaOrder,
JazzAccount,
ListOfBubbleTeaAddOns,
validateDraftOrder,
} from "./schema.ts";
export function CreateOrder() {
@@ -21,20 +20,19 @@ export function CreateOrder() {
if (!me?.root) return;
const onSave = (draft: Loaded<typeof DraftBubbleTeaOrder>) => {
// validate if the draft is a valid order
const validation = DraftBubbleTeaOrder.validate(draft);
const onSave = (draft: DraftBubbleTeaOrder) => {
const validation = validateDraftOrder(draft);
setErrors(validation.errors);
if (validation.errors.length > 0) {
return;
}
// turn the draft into a real order
me.root.orders.push(draft as Loaded<typeof BubbleTeaOrder>);
me.root.orders.push(draft as BubbleTeaOrder);
// reset the draft
me.root.draft = DraftBubbleTeaOrder.create({
addOns: ListOfBubbleTeaAddOns.create([]),
addOns: [],
});
router.navigate("/");
@@ -60,7 +58,7 @@ function CreateOrderForm({
onSave,
}: {
id: string;
onSave: (draft: Loaded<typeof DraftBubbleTeaOrder>) => void;
onSave: (draft: DraftBubbleTeaOrder) => void;
}) {
const draft = useCoState(DraftBubbleTeaOrder, id, {
resolve: { addOns: true, instructions: true },

View File

@@ -1,11 +1,11 @@
import { useAccount } from "jazz-tools/react";
import { DraftBubbleTeaOrder, JazzAccount } from "./schema";
import { JazzAccount, hasChanges } from "./schema";
export function DraftIndicator() {
const { me } = useAccount(JazzAccount, {
resolve: { root: { draft: true } },
});
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
if (hasChanges(me?.root.draft)) {
return (
<div className="absolute -top-1 -right-1 bg-blue-500 border-2 border-white w-3 h-3 rounded-full dark:border-stone-925">
<span className="sr-only">You have a draft</span>

View File

@@ -1,4 +1,4 @@
import { CoPlainText, Loaded } from "jazz-tools";
import { CoPlainText } from "jazz-tools";
import {
BubbleTeaAddOnTypes,
BubbleTeaBaseTeaTypes,
@@ -10,7 +10,7 @@ export function OrderForm({
order,
onSave,
}: {
order: Loaded<typeof BubbleTeaOrder> | Loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
// Handles updates to the instructions field of the order.

View File

@@ -1,10 +1,9 @@
import { Loaded } from "jazz-tools";
import { BubbleTeaOrder } from "./schema.ts";
export function OrderThumbnail({
order,
}: {
order: Loaded<typeof BubbleTeaOrder>;
order: BubbleTeaOrder;
}) {
const { id, baseTea, addOns, instructions, deliveryDate, withMilk } = order;
const date = deliveryDate.toLocaleDateString();

View File

@@ -1,4 +1,4 @@
import { Loaded, co, z } from "jazz-tools";
import { co, z } from "jazz-tools";
export const BubbleTeaAddOnTypes = [
"Pearl",
@@ -15,13 +15,14 @@ export const BubbleTeaBaseTeaTypes = [
"Thai",
] as const;
export const ListOfBubbleTeaAddOns = co
.list(z.literal([...BubbleTeaAddOnTypes]))
.withHelpers((Self) => ({
hasChanges(list?: Loaded<typeof Self> | null) {
return list && Object.entries(list._raw.insertions).length > 0;
},
}));
export const ListOfBubbleTeaAddOns = co.list(
z.literal([...BubbleTeaAddOnTypes]),
);
export type ListOfBubbleTeaAddOns = co.loaded<typeof ListOfBubbleTeaAddOns>;
function hasAddOnsChanges(list?: ListOfBubbleTeaAddOns | null) {
return list && Object.entries(list._raw.insertions).length > 0;
}
export const BubbleTeaOrder = co.map({
baseTea: z.literal([...BubbleTeaBaseTeaTypes]),
@@ -30,37 +31,30 @@ export const BubbleTeaOrder = co.map({
withMilk: z.boolean(),
instructions: co.optional(co.plainText()),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co
.map({
baseTea: z.optional(z.literal([...BubbleTeaBaseTeaTypes])),
addOns: co.optional(ListOfBubbleTeaAddOns),
deliveryDate: z.optional(z.date()),
withMilk: z.optional(z.boolean()),
instructions: co.optional(co.plainText()),
})
.withHelpers((Self) => ({
hasChanges(order: Loaded<typeof Self> | undefined) {
return (
!!order &&
(Object.keys(order._edits).length > 1 ||
ListOfBubbleTeaAddOns.hasChanges(order.addOns))
);
},
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
validate(order: Loaded<typeof Self>) {
const errors: string[] = [];
export function validateDraftOrder(order: DraftBubbleTeaOrder) {
const errors: string[] = [];
if (!order.baseTea) {
errors.push("Please select your preferred base tea.");
}
if (!order.deliveryDate) {
errors.push("Plese select a delivery date.");
}
if (!order.baseTea) {
errors.push("Please select your preferred base tea.");
}
if (!order.deliveryDate) {
errors.push("Plese select a delivery date.");
}
return { errors };
},
}));
return { errors };
}
export function hasChanges(order?: DraftBubbleTeaOrder | null) {
return (
!!order &&
(Object.keys(order._edits).length > 1 || hasAddOnsChanges(order.addOns))
);
}
/** The root is an app-specific per-user private `CoMap`
* where you can store top-level objects for that user */
@@ -76,15 +70,9 @@ export const JazzAccount = co
})
.withMigration((account) => {
if (!account.root) {
const orders = co.list(BubbleTeaOrder).create([], account);
const draft = DraftBubbleTeaOrder.create(
{
addOns: ListOfBubbleTeaAddOns.create([], account),
instructions: co.plainText().create("", account),
},
account.root = AccountRoot.create(
{ draft: { addOns: [], instructions: "" }, orders: [] },
account,
);
account.root = AccountRoot.create({ draft, orders }, account);
}
});

View File

@@ -11,9 +11,9 @@ export const Issue = co.map({
status: z.enum(["open", "closed"]),
labels: co.list(z.string()),
reactions: ReactionsList,
file: z.optional(co.fileStream()),
image: z.optional(co.image()),
lead: z.optional(co.account()),
file: co.optional(co.fileStream()),
image: co.optional(co.image()),
lead: co.optional(co.account()),
});
export const Project = co.map({

View File

@@ -15,7 +15,7 @@
"jazz-tools": "workspace:*",
"react": "19.1.0",
"react-dom": "19.1.0",
"zod": "3.25.28"
"zod": "3.25.76"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -31,4 +31,4 @@
"vite": "^6.3.5",
"vitest": "3.1.1"
}
}
}

View File

@@ -38,6 +38,7 @@
"postcss": "^8.4.27",
"tailwindcss": "^4.1.10",
"typescript": "5.6.2",
"vite": "^6.3.5"
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.2"
}
}

View File

@@ -84,15 +84,14 @@ export const MusicaAccount = co
* You can use it to set up the account root and any other initial CoValues you need.
*/
if (account.root === undefined) {
const tracks = co.list(MusicTrack).create([]);
const rootPlaylist = Playlist.create({
tracks,
tracks: [],
title: "",
});
account.root = MusicaAccountRoot.create({
rootPlaylist,
playlists: co.list(Playlist).create([]),
playlists: [],
activeTrack: undefined,
activePlaylist: rootPlaylist,
exampleDataLoaded: false,

View File

@@ -11,6 +11,7 @@ import { uploadMusicTracks } from "./4_actions";
import { MediaPlayer } from "./5_useMediaPlayer";
import { FileUploadButton } from "./components/FileUploadButton";
import { MusicTrackRow } from "./components/MusicTrackRow";
import { PlayerControls } from "./components/PlayerControls";
import { PlaylistTitleInput } from "./components/PlaylistTitleInput";
import { SidePanel } from "./components/SidePanel";
import { Button } from "./components/ui/button";
@@ -42,7 +43,11 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const playlistId = params.playlistId ?? me?.root._refs.rootPlaylist.id;
const playlist = useCoState(Playlist, playlistId, {
resolve: { tracks: true },
resolve: {
tracks: {
$each: true,
},
},
});
const isRootPlaylist = !params.playlistId;
@@ -66,8 +71,8 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
return (
<SidebarInset className="flex flex-col h-screen text-gray-800 bg-blue-50">
<div className="flex flex-1 overflow-hidden">
<SidePanel mediaPlayer={mediaPlayer} />
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden">
<SidePanel />
<main className="flex-1 p-6 overflow-y-auto overflow-x-hidden relative">
<SidebarTrigger />
<div className="flex items-center justify-between mb-6">
{isRootPlaylist ? (
@@ -106,12 +111,12 @@ export function HomePage({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
onClick={() => {
mediaPlayer.setActiveTrack(track, playlist);
}}
showAddToPlaylist={isRootPlaylist}
/>
),
)}
</ul>
</main>
<PlayerControls mediaPlayer={mediaPlayer} />
</div>
</SidebarInset>
);

View File

@@ -1,11 +1,6 @@
import { getAudioFileData } from "@/lib/audio/getAudioFileData";
import { FileStream, Group, co } from "jazz-tools";
import {
MusicTrack,
MusicTrackWaveform,
MusicaAccount,
Playlist,
} from "./1_schema";
import { FileStream, Group } from "jazz-tools";
import { MusicTrack, MusicaAccount, Playlist } from "./1_schema";
/**
* Walkthrough: Actions
@@ -51,7 +46,7 @@ export async function uploadMusicTracks(
{
file: fileStream,
duration: data.duration,
waveform: MusicTrackWaveform.create({ data: data.waveform }, group),
waveform: { data: data.waveform },
title: file.name,
isExampleTrack,
},
@@ -73,18 +68,10 @@ export async function createNewPlaylist() {
},
});
// Since playlists are meant to be shared we associate them
// to a group which will contain the keys required to get
// access to the "owned" values
const playlistGroup = Group.create();
const playlist = Playlist.create(
{
title: "New Playlist",
tracks: co.list(MusicTrack).create([], playlistGroup),
},
playlistGroup,
);
const playlist = Playlist.create({
title: "New Playlist",
tracks: [],
});
// Again, we associate the new playlist to the
// user by pushing it into the playlists CoList
@@ -129,7 +116,7 @@ export async function removeTrackFromPlaylist(
if (track._owner._type === "Group" && playlist._owner._type === "Group") {
const trackGroup = track._owner;
await trackGroup.removeMember(playlist._owner);
trackGroup.removeMember(playlist._owner);
const index =
playlist.tracks?.findIndex(

View File

@@ -0,0 +1,59 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface ConfirmDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
variant?: "default" | "destructive";
}
export function ConfirmDialog({
isOpen,
onOpenChange,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
variant = "destructive",
}: ConfirmDialogProps) {
function handleConfirm() {
onConfirm();
onOpenChange(false);
}
function handleCancel() {
onOpenChange(false);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{cancelText}
</Button>
<Button variant={variant} onClick={handleConfirm}>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -10,27 +10,34 @@ import { cn } from "@/lib/utils";
import { Loaded } from "jazz-tools";
import { useAccount, useCoState } from "jazz-tools/react";
import { MoreHorizontal } from "lucide-react";
import { Fragment } from "react/jsx-runtime";
import { MusicTrackTitleInput } from "./MusicTrackTitleInput";
import { Fragment, useCallback, useState } from "react";
import { EditTrackDialog } from "./RenameTrackDialog";
import { Button } from "./ui/button";
function isPartOfThePlaylist(
trackId: string,
playlist: Loaded<typeof Playlist, { tracks: true }>,
) {
return Array.from(playlist.tracks._refs).some((t) => t.id === trackId);
}
export function MusicTrackRow({
trackId,
isLoading,
isPlaying,
onClick,
showAddToPlaylist,
}: {
trackId: string;
isLoading: boolean;
isPlaying: boolean;
onClick: (track: Loaded<typeof MusicTrack>) => void;
showAddToPlaylist: boolean;
}) {
const track = useCoState(MusicTrack, trackId);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
resolve: { root: { playlists: { $each: { tracks: true } } } },
});
const playlists = me?.root.playlists ?? [];
@@ -60,10 +67,18 @@ export function MusicTrackRow({
}
}
function handleEdit() {
setIsEditDialogOpen(true);
}
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDropdownOpen(true);
}, []);
return (
<li
className={"flex gap-1 hover:bg-slate-200 group py-2 px-2 cursor-pointer"}
onClick={handleTrackClick}
>
<button
className={cn(
@@ -81,50 +96,57 @@ export function MusicTrackRow({
"▶️"
)}
</button>
<MusicTrackTitleInput trackId={trackId} />
<button
onContextMenu={handleContextMenu}
onClick={handleTrackClick}
className="w-full flex items-center overflow-hidden text-ellipsis whitespace-nowrap"
>
{track?.title}
</button>
<div onClick={(evt) => evt.stopPropagation()}>
{showAddToPlaylist && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
key={`delete`}
onSelect={async () => {
if (!track) return;
deleteTrack();
}}
>
Delete
</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
<DropdownMenuItem
key={`add-${index}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
aria-label={`Open ${track?.title} menu`}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={handleEdit}>Edit</DropdownMenuItem>
{playlists.map((playlist, index) => (
<Fragment key={index}>
{isPartOfThePlaylist(trackId, playlist) ? (
<DropdownMenuItem
key={`remove-${index}`}
onSelect={() => handleRemoveFromPlaylist(playlist)}
>
Remove from {playlist.title}
</DropdownMenuItem>
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
) : (
<DropdownMenuItem
key={`add-${index}`}
onSelect={() => handleAddToPlaylist(playlist)}
>
Add to {playlist.title}
</DropdownMenuItem>
)}
</Fragment>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{track && isEditDialogOpen && (
<EditTrackDialog
track={track}
isOpen={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onDelete={deleteTrack}
/>
)}
</li>
);
}

View File

@@ -24,25 +24,25 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
const activeTrackTitle = activeTrack.title;
return (
<footer className="flex items-center justify-between p-4 gap-4 bg-white border-t border-gray-200 fixed bottom-0 left-0 right-0 w-full">
<div className="flex justify-center items-center space-x-2">
<div className="flex items-center space-x-4">
<footer className="flex items-center justify-between p-2 sm:p-4 gap-2 sm:gap-4 bg-white border-t border-gray-200 absolute bottom-0 left-0 right-0 w-full z-50">
<div className="flex justify-center items-center space-x-1 sm:space-x-2 flex-shrink-0">
<div className="flex items-center space-x-2 sm:space-x-4">
<button
onClick={mediaPlayer.playPrevTrack}
className="text-blue-600 hover:text-blue-800"
aria-label="Previous track"
>
<SkipBack size={20} />
<SkipBack size={16} className="sm:w-5 sm:h-5" />
</button>
<button
onClick={playState.toggle}
className="w-[42px] h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
className="w-8 h-8 sm:w-[42px] sm:h-[42px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700"
aria-label={isPlaying ? "Pause active track" : "Play active track"}
>
{isPlaying ? (
<Pause size={24} fill="currentColor" />
<Pause size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
) : (
<Play size={24} fill="currentColor" />
<Play size={16} className="sm:w-6 sm:h-6" fill="currentColor" />
)}
</button>
<button
@@ -50,16 +50,22 @@ export function PlayerControls({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
className="text-blue-600 hover:text-blue-800"
aria-label="Next track"
>
<SkipForward size={20} />
<SkipForward size={16} className="sm:w-5 sm:h-5" />
</button>
</div>
</div>
<div className=" sm:hidden md:flex flex-col flex-shrink-1 items-center w-[75%]">
<Waveform track={activeTrack} height={30} />
<div className="md:hidden sm:hidden lg:flex flex-1 justify-center items-center min-w-0 px-2">
<Waveform
track={activeTrack}
height={30}
className="h-5 sm:h-6 md:h-8 lg:h-10"
/>
</div>
<div className="flex flex-col items-end gap-1 text-right min-w-fit w-[25%]">
<h4 className="font-medium text-blue-800">{activeTrackTitle}</h4>
<p className="text-sm text-gray-600">
<div className="flex flex-col items-end gap-1 text-right min-w-fit flex-shrink-0">
<h4 className="font-medium text-blue-800 text-sm sm:text-base truncate max-w-32 sm:max-w-80">
{activeTrackTitle}
</h4>
<p className="text-xs sm:text-sm text-gray-600 truncate max-w-32 sm:max-w-80">
{activePlaylist?.title || "All tracks"}
</p>
</div>

View File

@@ -0,0 +1,108 @@
import { MusicTrack } from "@/1_schema";
import { updateMusicTrackTitle } from "@/4_actions";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Loaded } from "jazz-tools";
import { useState } from "react";
import { ConfirmDialog } from "./ConfirmDialog";
interface EditTrackDialogProps {
track: Loaded<typeof MusicTrack>;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onDelete: () => void;
}
export function EditTrackDialog({
track,
isOpen,
onOpenChange,
onDelete,
}: EditTrackDialogProps) {
const [newTitle, setNewTitle] = useState(track.title);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
function handleSave() {
if (track && newTitle.trim()) {
updateMusicTrackTitle(track, newTitle.trim());
onOpenChange(false);
}
}
function handleCancel() {
setNewTitle(track?.title || "");
onOpenChange(false);
}
function handleDeleteClick() {
setIsDeleteConfirmOpen(true);
}
function handleDeleteConfirm() {
onDelete();
onOpenChange(false);
}
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Enter") {
handleSave();
} else if (event.key === "Escape") {
handleCancel();
}
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Track</DialogTitle>
<DialogDescription>Edit "{track?.title}".</DialogDescription>
</DialogHeader>
<form className="py-4" onSubmit={handleSave}>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter track name..."
autoFocus
/>
</form>
<DialogFooter className="flex justify-between">
<Button
variant="destructive"
onClick={handleDeleteClick}
className="mr-auto"
>
Delete Track
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!newTitle.trim()}>
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
<ConfirmDialog
isOpen={isDeleteConfirmOpen}
onOpenChange={setIsDeleteConfirmOpen}
title="Delete Track"
description={`Are you sure you want to delete "${track.title}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
onConfirm={handleDeleteConfirm}
variant="destructive"
/>
</Dialog>
);
}

View File

@@ -1,10 +1,8 @@
import { MusicTrack, MusicaAccount } from "@/1_schema";
import { MusicaAccount } from "@/1_schema";
import { createNewPlaylist, deletePlaylist } from "@/4_actions";
import { MediaPlayer } from "@/5_useMediaPlayer";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
@@ -14,22 +12,18 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { usePlayState } from "@/lib/audio/usePlayState";
import { useAccount, useCoState } from "jazz-tools/react";
import { Home, Music, Pause, Play, Plus, Trash2 } from "lucide-react";
import { useAccount } from "jazz-tools/react";
import { Home, Music, Plus, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { AuthButton } from "./AuthButton";
export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
export function SidePanel() {
const { playlistId } = useParams();
const navigate = useNavigate();
const { me } = useAccount(MusicaAccount, {
resolve: { root: { playlists: { $each: true } } },
});
const playState = usePlayState();
const isPlaying = playState.value === "play";
function handleAllTracksClick() {
navigate(`/`);
}
@@ -50,12 +44,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
navigate(`/playlist/${playlist.id}`);
}
const activeTrack = useCoState(MusicTrack, mediaPlayer.activeTrackId, {
resolve: { waveform: true },
});
const activeTrackTitle = activeTrack?.title;
return (
<Sidebar>
<SidebarHeader>
@@ -137,29 +125,6 @@ export function SidePanel({ mediaPlayer }: { mediaPlayer: MediaPlayer }) {
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{activeTrack && (
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem className="flex justify-end">
<SidebarMenuButton
onClick={playState.toggle}
aria-label={
isPlaying ? "Pause active track" : "Play active track"
}
>
<div className="w-[28px] h-[28px] flex items-center justify-center bg-blue-600 rounded-full text-white hover:bg-blue-700">
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" />
)}
</div>
<span>{activeTrackTitle}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
)}
</Sidebar>
);
}

View File

@@ -7,6 +7,7 @@ import { useCoState } from "jazz-tools/react";
export function Waveform(props: {
track: Loaded<typeof MusicTrack>;
height: number;
className?: string;
}) {
const { track, height } = props;
const waveformData = useCoState(
@@ -36,7 +37,7 @@ export function Waveform(props: {
return (
<div
className="flex justify-center items-end w-full"
className={cn("flex justify-center items-end w-full", props.className)}
style={{
height,
gap: 1,

View File

@@ -2,6 +2,12 @@
@custom-variant dark (&:is(.dark *));
html {
overflow: hidden;
max-width: 1200px;
position: relative;
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(20 14.3% 4.1%);

View File

@@ -55,10 +55,20 @@ export class HomePage {
async editTrackTitle(trackTitle: string, newTitle: string) {
await this.page
.getByRole("textbox", {
name: `Edit track title: ${trackTitle}`,
.getByRole("button", {
name: `Open ${trackTitle} menu`,
})
.fill(newTitle);
.click();
await this.page
.getByRole("menuitem", {
name: `Edit`,
})
.click();
await this.page.getByPlaceholder("Enter track name...").fill(newTitle);
await this.page.getByRole("button", { name: "Save" }).click();
}
async createPlaylist() {

View File

@@ -1,10 +1,21 @@
import path from "path";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
strategies: "generateSW",
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"],
maximumFileSizeToCacheInBytes: 1024 * 1024 * 5,
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),

View File

@@ -7,6 +7,7 @@ import {
JazzAccount,
Organization,
Project,
validateDraftOrganization,
} from "../schema.ts";
import { Errors } from "./Errors.tsx";
import { OrganizationForm } from "./OrganizationForm.tsx";
@@ -21,8 +22,7 @@ export function CreateOrganization() {
if (!me?.root?.organizations) return;
const onSave = (draft: Loaded<typeof DraftOrganization>) => {
// validate if the draft is a valid organization
const validation = DraftOrganization.validate(draft);
const validation = validateDraftOrganization(draft);
setErrors(validation.errors);
if (validation.errors.length > 0) {
return;

View File

@@ -10,24 +10,24 @@ export const Organization = co.map({
projects: co.list(Project),
});
export const DraftOrganization = co
.map({
name: z.optional(z.string()),
projects: co.list(Project),
})
.withHelpers((Self) => ({
validate(org: Loaded<typeof Self>) {
const errors: string[] = [];
export const DraftOrganization = co.map({
name: z.optional(z.string()),
projects: co.list(Project),
});
if (!org.name) {
errors.push("Please enter a name.");
}
export function validateDraftOrganization(
org: Loaded<typeof DraftOrganization>,
) {
const errors: string[] = [];
return {
errors,
};
},
}));
if (!org.name) {
errors.push("Please enter a name.");
}
return {
errors,
};
}
export const JazzAccountRoot = co.map({
organizations: co.list(Organization),

View File

@@ -1,7 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
const nextConfig: NextConfig = {};
export default nextConfig;

View File

@@ -1,5 +1,5 @@
import { jazzServerAccount } from "@/jazzServerAccount";
import { Game, Player, PlayerState, createGameState } from "@/schema";
import { Game, createGameState } from "@/schema";
import { serverApi } from "@/serverApi";
import { Account, Group, JazzRequestError } from "jazz-tools";

View File

@@ -32,7 +32,7 @@ export type Game = co.loaded<typeof Game>;
export const WaitingRoom = co.map({
creator: co.account(),
game: z.optional(Game),
game: co.optional(Game),
});
export type WaitingRoom = co.loaded<typeof WaitingRoom>;

View File

@@ -41,7 +41,9 @@ export function Footer({
</Link>
</div>
<p className="col-span-full sm:col-span-6 md:col-span-4 text-sm sm:text-base">
Playful software for serious problems.
Computers are magic.
<br />
Time to make them less complex.
</p>
</div>
<div className="grid gap-y-8 grid-cols-12">

View File

@@ -1,6 +1,5 @@
import { ChatDemoSection } from "@/components/home/ChatDemoSection";
import { CollaborationFeaturesSection } from "@/components/home/CollaborationFeaturesSection";
import { ComingSoonSection } from "@/components/home/ComingSoonSection";
import { EarlyAdopterSection } from "@/components/home/EarlyAdopterSection";
import { EncryptionSection } from "@/components/home/EncryptionSection";
import { FeaturesSection } from "@/components/home/FeaturesSection";
@@ -15,9 +14,9 @@ import { Testimonial } from "@garden-co/design-system/src/components/molecules/T
export default function Home() {
return (
<>
<HeroSection />
<HeroSection />
<div className="container flex flex-col gap-12 lg:gap-20">
<GetStartedSnippetSelect />
<SupportedEnvironmentsSection />
<HowJazzWorksSection />
@@ -54,8 +53,6 @@ export default function Home() {
<FeaturesSection />
<ComingSoonSection />
<EarlyAdopterSection />
</div>
</>

View File

@@ -1,54 +0,0 @@
import CoPlainTextDescription from "@/app/(others)/(home)/coValueDescriptions/coPlainTextDescription.mdx";
import CursorsAndCaretsDescription from "@/app/(others)/(home)/toolkit/cursorsAndCarets.mdx";
import TwoWaySyncDescription from "@/app/(others)/(home)/toolkit/twoWaySync.mdx";
import VideoPresenceCallsDescription from "@/app/(others)/(home)/toolkit/videoPresenceCalls.mdx";
import { CodeRef } from "@garden-co/design-system/src/components/atoms/CodeRef";
import { P } from "@garden-co/design-system/src/components/atoms/Paragraph";
import { FeatureCard } from "@garden-co/design-system/src/components/molecules/FeatureCard";
import { GappedGrid } from "@garden-co/design-system/src/components/molecules/GappedGrid";
import { Prose } from "@garden-co/design-system/src/components/molecules/Prose";
import { SectionHeader } from "@garden-co/design-system/src/components/molecules/SectionHeader";
export function ComingSoonSection() {
return (
<div>
<SectionHeader title="More features coming soon" />
<GappedGrid cols={4}>
<FeatureCard className="p-4" label={<h3>Cursors & carets</h3>}>
<P>Ready-made spatial presence.</P>
<Prose size="sm">
<CursorsAndCaretsDescription />
</Prose>
</FeatureCard>
<FeatureCard className="p-4" label={<h3>Two-way sync to your DB</h3>}>
<P>Add Jazz to an existing app.</P>
<Prose size="sm">
<TwoWaySyncDescription />
</Prose>
</FeatureCard>
<FeatureCard className="p-4" label={<h3>Video presence & calls</h3>}>
<P>Stream and record audio & video.</P>
<Prose size="sm">
<VideoPresenceCallsDescription />
</Prose>
</FeatureCard>
<FeatureCard
className="p-4"
label={
<h3>
<CodeRef>CoPlainText</CodeRef> & <CodeRef>CoRichText</CodeRef>
</h3>
}
>
<Prose size="sm">
<CoPlainTextDescription />
</Prose>
</FeatureCard>
</GappedGrid>
</div>
);
}

View File

@@ -16,81 +16,70 @@ const features: Array<{
title: string;
icon: IconName;
}> = [
{
title: "Instant updates",
icon: "instant",
},
{
title: "Real-time sync",
icon: "devices",
},
{
title: "Multiplayer",
icon: "spatialPresence",
},
{
title: "File uploads",
icon: "upload",
},
{
title: "Social features",
icon: "social",
},
{
title: "Permissions",
icon: "permissions",
},
{
title: "E2E encryption",
icon: "encryption",
},
{
title: "Authentication",
icon: "auth",
},
];
{
title: "Instant updates",
icon: "instant",
},
{
title: "Real-time sync",
icon: "devices",
},
{
title: "Multiplayer",
icon: "spatialPresence",
},
{
title: "File uploads",
icon: "upload",
},
{
title: "Social features",
icon: "social",
},
{
title: "Permissions",
icon: "permissions",
},
{
title: "E2E encryption",
icon: "encryption",
},
{
title: "Authentication",
icon: "auth",
},
];
export function HeroSection() {
return (
<div className="container grid items-center gap-x-8 gap-y-12 my-12 md:my-16 lg:my-24 lg:gap-x-10 lg:grid-cols-12">
<div className="container grid items-center gap-x-8 gap-y-12 mt-12 md:mt-16 lg:mt-24 mb-12 lg:gap-x-10 lg:grid-cols-12">
<div className="flex flex-col justify-center gap-5 lg:col-span-11 lg:gap-8">
<Kicker>Toolkit for backendless apps</Kicker>
<Kicker>Reactive, distributed, secure</Kicker>
<H1>
<span className="inline-block text-highlight">
{marketingCopy.headline}
</span>
</H1>
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200">
<Prose size="lg" className="text-pretty max-w-2xl dark:text-stone-200 prose-p:leading-normal">
<p>
Jazz gives you data without needing a database plus auth,
permissions, files and multiplayer without needing a backend.
Jazz is a new kind of database that's distributed across your frontend, containers, serverless functions and its own storage cloud.
</p>
<p>It syncs structured data, files and LLM streams instantly.<br/>It looks like local reactive JSON state.</p>
<p>And you get auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.</p>
<p>
Do everything right from the frontend and ship better apps, faster.
This lets you get rid of 90% of the traditional backend, and most of your frontend state juggling.
You&apos;ll ship better apps, faster.
</p>
<p>
Open source. Self-host or use{" "}
<p className="text-base">
Self-host or use{" "}
<Link className="text-reset" href="/cloud">
Jazz Cloud
</Link>{" "}
for zero-config magic.
for a zero-deploy globally-scaled DB.
<br/>Open source (MIT)
</p>
</Prose>
<div className="grid grid-cols-2 gap-2 max-w-3xl sm:grid-cols-4 sm:gap-4">
{features.map(({ title, icon }) => (
<div
key={title}
className="flex text-xs sm:text-sm gap-2 items-center"
>
<span className="p-1.5 rounded-lg bg-primary-transparent">
<Icon size="xs" name={icon} intent="primary" />
</span>
<p>{title}</p>
</div>
))}
</div>
</div>
</div>
);

View File

@@ -83,7 +83,7 @@ export function HowJazzWorksSection() {
<div className="grid gap-3">
<Kicker>How it works</Kicker>
<H2>Build entire apps using only client-side code</H2>
<H2>Build entire apps with collaborative state</H2>
</div>
<GappedGrid>
<Step

View File

@@ -53,7 +53,7 @@ export function LocalFirstFeaturesSection() {
return (
<div>
<SectionHeader
title="The best of all worlds"
title="Local-first state with global sync"
slogan={
<>
<p>

View File

@@ -9,7 +9,7 @@ export default function ProblemStatementSection() {
<div className="grid gap-4 lg:gap-8">
<SectionHeader
className="sm:text-center sm:mx-auto"
title={"Powered by the first “flat stack”"}
title={"A database that does what's actually needed"}
slogan="A perspective shift worth 10,000 hours"
/>
@@ -41,8 +41,7 @@ export default function ProblemStatementSection() {
<Prose>
<p>
For each new app you tackle a{" "}
<strong>mess of moving parts and infra worries.</strong> Or, you
haven't even tried because "you're not full-stack".
<strong>mess of moving parts and infra worries.</strong> Your backend is responsible for shuffling data around in a myriad of ways.
</p>
<p>
Want to build a <strong>modern app</strong> with multiplayer or
@@ -68,7 +67,7 @@ export default function ProblemStatementSection() {
<strong>With users &amp; permissions built-in.</strong>
</p>
<p>
With completely <strong>app-independent infra,</strong> you get to
With a <strong>DB and infra made for modern apps</strong> you get to
focus on <strong>building the app your users want.</strong> You'll
notice that <strong>90% of the work is now the UI.</strong>
</p>

View File

@@ -1,5 +1,6 @@
import { BunLogo } from "@/components/icons/BunLogo";
import { CloudflareWorkerLogo } from "@/components/icons/CloudflareWorkerLogo";
import { VercelLogo } from "@/components/icons/VercelLogo";
import { ExpoLogo } from "@/components/icons/ExpoLogo";
import { JavascriptLogo } from "@/components/icons/JavascriptLogo";
import { NodejsLogo } from "@/components/icons/NodejsLogo";
@@ -44,14 +45,18 @@ const serverWorkers = [
icon: NodejsLogo,
href: "/docs/react/server-workers",
},
{
name: "Cloudflare Workers",
icon: CloudflareWorkerLogo,
},
{
name: "Bun",
icon: BunLogo,
},
{
name: "Vercel",
icon: VercelLogo,
},
{
name: "CF Workers",
icon: CloudflareWorkerLogo,
}
];
export function SupportedEnvironmentsSection() {

View File

@@ -0,0 +1,16 @@
import React from "react";
import type { SVGProps } from "react";
export function VercelLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="1.5em"
height="1.5em"
viewBox="0 0 76 65"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
</svg>
);
}

View File

@@ -54,7 +54,7 @@ function AuthStateIndicator() {
const isGuest = agent._type !== "Account"
// Anonymous authentication: has an account but not fully authenticated
const isAnonymous = agent._type === "Account" && !isAuthenticated;
const isAnonymous = agent._type === "Account" && !isAuthenticated;
return (
<div>
{isGuest && <span>Guest Mode</span>}

View File

@@ -54,10 +54,10 @@ import { co, z, CoMap } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
```
</CodeGroup>
@@ -73,17 +73,17 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
// ---cut---
// OrderForm.tsx
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -118,16 +118,16 @@ import * as React from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -177,10 +177,10 @@ import { useState, useEffect } from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -218,7 +218,7 @@ export function OrderForm({
// CreateOrder.tsx
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
@@ -228,7 +228,7 @@ export function CreateOrder() {
e.preventDefault();
if (!draft || !draft.name) return;
const order = draft as co.loaded<typeof BubbleTeaOrder>; // TODO: this should narrow correctly
const order = draft as BubbleTeaOrder; // TODO: this should narrow correctly
console.log("Order created:", order);
};
@@ -244,26 +244,30 @@ export function CreateOrder() {
In a `BubbleTeaOrder`, the `name` field is required, so it would be a good idea to validate this before turning the draft into a real order.
Update the schema to include a `validate` helper.
Update the schema to include a `validateDraftOrder` helper.
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
// ---cut---
// schema.ts
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
}).withHelpers((Self) => ({ // [!code ++:11]
validate(draft: co.loaded<typeof Self>) {
const errors: string[] = [];
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
if (!draft.name) {
errors.push("Please enter a name.");
}
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
return { errors };
},
}));
export function validateDraftOrder(draft: DraftBubbleTeaOrder) { // [!code ++:9]
const errors: string[] = [];
if (!draft.name) {
errors.push("Please enter a name.");
}
return { errors };
};
```
</CodeGroup>
@@ -279,20 +283,20 @@ import { useState, useEffect } from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
}).withHelpers((Self) => ({
validate(draft: co.loaded<typeof Self>) {
const errors: string[] = [];
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
if (!draft.name) {
errors.push("Please enter a name.");
}
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
return { errors };
},
}));
if (!draft.name) {
errors.push("Please enter a name.");
}
return { errors };
};
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -307,7 +311,7 @@ export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -330,7 +334,7 @@ export function OrderForm({
// CreateOrder.tsx
export function CreateOrder() {
const { me } = useAccount();
const [draft, setDraft] = useState<co.loaded<typeof DraftBubbleTeaOrder>>();
const [draft, setDraft] = useState<DraftBubbleTeaOrder>();
useEffect(() => {
setDraft(DraftBubbleTeaOrder.create({}));
@@ -340,13 +344,13 @@ export function CreateOrder() {
e.preventDefault();
if (!draft) return;
const validation = DraftBubbleTeaOrder.validate(draft); // [!code ++:5]
const validation = validateDraftOrder(draft); // [!code ++:5]
if (validation.errors.length > 0) {
console.log(validation.errors);
return;
}
const order = draft as co.loaded<typeof BubbleTeaOrder>;
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
};
@@ -372,10 +376,10 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({ // [!code ++:15]
draft: DraftBubbleTeaOrder,
@@ -403,10 +407,10 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
});
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -452,20 +456,20 @@ import { co, z } from "jazz-tools";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
}).withHelpers((Self) => ({
validate(draft: co.loaded<typeof Self>) {
const errors: string[] = [];
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
if (!draft.name) {
errors.push("Please enter a name.");
}
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
return { errors };
},
}));
if (!draft.name) {
errors.push("Please enter a name.");
}
return { errors };
};
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -485,14 +489,14 @@ export const JazzAccount = co.account({
// @filename: CreateOrder.tsx
import * as React from "react";
import { useCoState, useAccount } from "jazz-tools/react";
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount } from "schema";
import { BubbleTeaOrder, DraftBubbleTeaOrder, JazzAccount, validateDraftOrder } from "schema";
import { co } from "jazz-tools";
export function OrderForm({
order,
onSave,
}: {
order: co.loaded<typeof BubbleTeaOrder> | co.loaded<typeof DraftBubbleTeaOrder>;
order: BubbleTeaOrder | DraftBubbleTeaOrder;
onSave?: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
@@ -527,13 +531,13 @@ export function CreateOrder() {
const draft = me.root.draft; // [!code ++:2]
if (!draft) return;
const validation = DraftBubbleTeaOrder.validate(draft);
const validation = validateDraftOrder(draft);
if (validation.errors.length > 0) {
console.log(validation.errors);
return;
}
const order = draft as co.loaded<typeof BubbleTeaOrder>;
const order = draft as BubbleTeaOrder;
console.log("Order created:", order);
// create a new empty draft
@@ -577,23 +581,27 @@ Simply add a `hasChanges` helper to your schema.
import { co, z } from "jazz-tools";
// ---cut---
// schema.ts
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
}).withHelpers((Self) => ({
validate(draft: co.loaded<typeof Self>) {
const errors: string[] = [];
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
if (!draft.name) {
errors.push("Plese enter a name.");
}
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
return { errors };
},
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
return draft ? Object.keys(draft._edits).length : false;
},
}));
if (!draft.name) {
errors.push("Please enter a name.");
}
return { errors };
};
export function hasChanges(draft?: DraftBubbleTeaOrder) { // [!code ++:3]
return draft ? Object.keys(draft._edits).length : false;
};
```
</CodeGroup>
@@ -608,24 +616,24 @@ import * as React from "react";
export const BubbleTeaOrder = co.map({
name: z.string(),
});
export type BubbleTeaOrder = co.loaded<typeof BubbleTeaOrder>;
export const DraftBubbleTeaOrder = co.map({
name: z.optional(z.string()),
}).withHelpers((Self) => ({
validate(draft: co.loaded<typeof Self>) {
const errors: string[] = [];
export const DraftBubbleTeaOrder = BubbleTeaOrder.partial();
export type DraftBubbleTeaOrder = co.loaded<typeof DraftBubbleTeaOrder>;
if (!draft.name) {
errors.push("Plese enter a name.");
}
export function validateDraftOrder(draft: DraftBubbleTeaOrder) {
const errors: string[] = [];
return { errors };
},
if (!draft.name) {
errors.push("Please enter a name.");
}
hasChanges(draft?: co.loaded<typeof Self>) { // [!code ++:3]
return draft ? Object.keys(draft._edits).length : false;
},
}));
return { errors };
};
export function hasChanges(draft?: DraftBubbleTeaOrder) {
return draft ? Object.keys(draft._edits).length : false;
};
export const AccountRoot = co.map({
draft: DraftBubbleTeaOrder,
@@ -649,7 +657,7 @@ export function DraftIndicator() {
resolve: { root: { draft: true } },
});
if (DraftBubbleTeaOrder.hasChanges(me?.root.draft)) {
if (hasChanges(me?.root.draft)) {
return (
<p>You have a draft</p>
);

View File

@@ -31,7 +31,7 @@ export const Organization = co.map({
name: z.string(),
// shared data between users of each organization
projects: co.list(Project),
projects: co.list(Project),
});
export const ListOfOrganizations = co.list(Organization);
@@ -115,7 +115,7 @@ import * as React from "react";
import { useAcceptInvite, useAccount } from "jazz-tools/react";
import { co, z } from "jazz-tools";
const Project = z.object({
const Project = co.map({
name: z.string(),
});

View File

@@ -109,6 +109,11 @@ export const docNavigationItems = [
// collapse: true,
prefix: "/docs/upgrade",
items: [
{
name: "0.16.0 - Cleaner separation between Zod and CoValue schemas",
href: "/docs/upgrade/0-16-0",
done: 100,
},
{
name: "0.15.0 - Everything inside `jazz-tools`",
href: "/docs/upgrade/0-15-0",

View File

@@ -205,6 +205,101 @@ console.log(containingGroup.getParentGroups()); // [addedGroup]
```
</CodeGroup>
## Group hierarchy on CoValue creation
When creating CoValues that contain other CoValues using plain JSON objects, Jazz not only creates
the necessary CoValues automatically but it will also manage their group ownership.
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
// ---cut---
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
const board = Board.create({
title: "My board",
columns: [
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
],
});
```
</CodeGroup>
For each created column and task CoValue, Jazz also creates a new group as its owner and
adds the referencing CoValue's owner as a member of that group. This means permissions for nested CoValues
are inherited from the CoValue that references them, but can also be modified independently for each CoValue
if needed.
<CodeGroup>
```ts twoslash
import { co, z, Group, Account } from "jazz-tools";
const alice = {} as unknown as Account;
const bob = {} as unknown as Account;
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
// ---cut---
const writeAccess = Group.create();
writeAccess.addMember(bob, "writer");
// Give Bob write access to the board, columns and tasks
const board = Board.create({
title: "My board",
columns: [
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
],
}, writeAccess);
// Give Alice read access to one specific task
const task = board.columns[0][0];
const taskGroup = task._owner.castAs(Group);
taskGroup.addMember(alice, "reader");
```
</CodeGroup>
If you prefer to manage permissions differently, you can always create CoValues explicitly:
<CodeGroup>
```ts twoslash
import { co, Group, z, Account } from "jazz-tools";
const bob = {} as unknown as Account;
const Task = co.plainText();
const Column = co.list(Task);
const Board = co.map({
title: z.string(),
columns: co.list(Column),
});
// ---cut---
const writeAccess = Group.create();
writeAccess.addMember(bob, "writer");
const readAccess = Group.create();
readAccess.addMember(bob, "reader");
// Give Bob read access to the board and write access to the columns and tasks
const board = Board.create({
title: "My board",
columns: co.list(Column).create([
["Task 1.1", "Task 1.2"],
["Task 2.1", "Task 2.2"],
], writeAccess),
}, readAccess);
```
</CodeGroup>
## Example: Team Hierarchy
Here's a practical example of using group inheritance for team permissions:

View File

@@ -7,9 +7,11 @@ export const metadata = {
# Learn some <span className="sr-only">Jazz</span> <JazzLogo className="h-[41px] -ml-0.5 -mt-[3px] inline" />
**Jazz is a toolkit for building backendless apps**. You get data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Jazz lets you do everything right from the frontend and you'll ship better apps, faster.
**Jazz is a new kind of database** that's **distributed** across your frontend, containers, serverless functions and its own storage cloud.
Instead of wrestling with databases, APIs, and server infrastructure, you work with **CoValues** ("collaborative values") — your new cloud-synced building blocks that feel like local state but automatically sync across all devices and users in real-time.
It syncs structured data, files and LLM streams instantly, and looks like local reactive JSON state.
It also provides auth, orgs & teams, real-time multiplayer, edit histories, permissions, E2E encryption and offline-support out of the box.
---
@@ -19,7 +21,7 @@ You can use [`create-jazz-app`](/docs/tools/create-jazz-app) to create a new Jaz
<CodeGroup>
```sh
npx create-jazz-app@latest --api-key you@example.com
npx create-jazz-app@latest --api-key you@example.com
```
</CodeGroup>
@@ -30,21 +32,10 @@ Or you can follow this [React step-by-step guide](/docs/react/guide) where we wa
</ContentByFramework> */}
## Why Jazz is different
Most apps rebuild the same thing: shared state that syncs between users and devices. Jazz starts from that shared state, giving you:
- **No backend required** — Focus on building features, not infrastructure
- **Real-time sync** — Changes appear everywhere immediately
- **Multiplayer by default** — Collaboration just works
- **Local-first** — Your app works offline and feels instant
Think Figma, Notion, or Linear — but you don't need years to build a custom stack.
## How it works
1. **Define your data** with CoValues schemas
2. **Connect to sync infrastructure** (Jazz Cloud or self-hosted)
2. **Connect to storage infrastructure** (Jazz Cloud or self-hosted)
3. **Create and edit CoValues** like normal objects
4. **Get automatic sync and persistence** across all devices and users

View File

@@ -28,18 +28,19 @@ See the [schema docs](/docs/schemas/covalues) for more information.
<CodeGroup>
```ts
// src/lib/schema.ts
import { Account, Profile, coField } from "jazz-tools";
import { co, z } from "jazz-tools"
export class MyProfile extends Profile {
name = coField.string;
counter = coField.number; // This will be publically visible
}
export const MyProfile = co.profile({
name: z.string(),
counter: z.number()
});
export class MyAccount extends Account {
profile = coField.ref(MyProfile);
export const root = co.map({});
// ...
}
export const UserAccount = co.account({
root,
profile: MyProfile
});
```
</CodeGroup>
@@ -48,17 +49,17 @@ export class MyAccount extends Account {
<CodeGroup>
```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { JazzSvelteProvider } from 'jazz-tools/svelte';
import { JazzSvelteProvider } from "jazz-tools/svelte";
let { children } = $props();
// Example configuration for authentication and peer connection
let sync = { peer: "wss://cloud.jazz.tools/?key=you@example.com" };
let AccountSchema = MyAccount;
</script>
<JazzSvelteProvider {sync} {AccountSchema}>
<App />
<JazzSvelteProvider {sync} AccountSchema={MyAccount}>
{@render children?.()}
</JazzSvelteProvider>
```
</CodeGroup>
@@ -69,12 +70,11 @@ export class MyAccount extends Account {
```svelte
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { useCoState, useAccount } from 'jazz-tools/svelte';
import { MyProfile } from './schema';
import { CoState, AccountCoState } from "jazz-tools/svelte";
import { MyProfile, UserAccount } from "$lib/schema";
const { me } = useAccount();
const profile = $derived(useCoState(MyProfile, me._refs.profile.id));
const me = new AccountCoState(UserAccount);
const profile = new CoState(MyProfile, me.current?._refs.profile?.id);
function increment() {
if (!profile.current) return;
@@ -82,7 +82,7 @@ export class MyAccount extends Account {
}
</script>
<button on:click={increment}>
<button onclick={increment}>
Count: {profile.current?.counter}
</button>
```

View File

@@ -89,7 +89,7 @@ export const MyAppRoot = co.map({
export const MyAppProfile = co.profile({ // [!code ++:4]
name: z.string(), // compatible with default Profile schema
avatar: z.optional(co.image()),
avatar: co.optional(co.image()),
});
export const MyAppAccount = co.account({
@@ -241,7 +241,7 @@ const MyAppProfile = co.profile({
// ---cut---
const MyAppRoot = co.map({
myChats: co.list(Chat),
myBookmarks: z.optional(co.list(Bookmark)), // [!code ++:1]
myBookmarks: co.optional(co.list(Bookmark)), // [!code ++:1]
});

View File

@@ -3,6 +3,7 @@ export const metadata = {
};
import { CodeGroup, ComingSoon } from "@/components/forMdx";
import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
# Defining schemas: CoValues
@@ -80,6 +81,40 @@ const project = TodoProject.create(
</CodeGroup>
When creating CoValues that contain other CoValues, you can pass in a plain JSON object.
Jazz will automatically create the CoValues for you.
<CodeGroup>
```ts twoslash
// @filename: schema.ts
import { co, z, CoMap, CoList } from "jazz-tools";
export const ListOfTasks = co.list(z.string());
export const TodoProject = co.map({
title: z.string(),
tasks: ListOfTasks,
});
// @filename: app.ts
// ---cut---
// app.ts
import { Group } from "jazz-tools";
import { TodoProject, ListOfTasks } from "./schema";
const group = Group.create().makePublic();
const project = TodoProject.create({
title: "New Project",
tasks: [], // Permissions are inherited, so the tasks list will also be public
}, group);
```
</CodeGroup>
<Alert variant="info" className="flex gap-2 items-center my-4">
To learn more about how permissions work when creating nested CoValues with plain JSON objects,
refer to [Group hierarchy on CoValue creation](/docs/groups/inheritance#group-hierarchy-on-covalue-creation).
</Alert>
## Types of CoValues
### `CoMap` (declaration)
@@ -320,6 +355,10 @@ const Company = co.map({
</CodeGroup>
#### Optional References
You can make schema fields optional using either `z.optional()` or `co.optional()`, depending on the type of value:
- Use `z.optional()` for primitive Zod values like `z.string()`, `z.number()`, or `z.boolean()`
- Use `co.optional()` for CoValues like `co.map()`, `co.list()`, or `co.record()`
You can make references optional with `co.optional()`:
@@ -331,7 +370,8 @@ const Pet = co.map({
});
// ---cut---
const Person = co.map({
pet: co.optional(Pet),
age: z.optional(z.number()), // primitive
pet: co.optional(Pet), // CoValue
});
```
</CodeGroup>
@@ -353,16 +393,16 @@ const Person = co.map({
```
</CodeGroup>
You can use the same technique for mutually recursive references, but you'll need to help TypeScript along:
You can use the same technique for mutually recursive references:
<CodeGroup>
```ts twoslash
// ---cut---
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const Person = co.map({
name: z.string(),
get friends(): CoListSchema<typeof Person> {
get friends() {
return ListOfPeople;
}
});
@@ -372,22 +412,6 @@ const ListOfPeople = co.list(Person);
</CodeGroup>
Note: similarly, if you use modifiers like `co.optional()` you'll need to help TypeScript along:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
// ---cut---
const Person = co.map({
name: z.string(),
get bestFriend(): z.ZodOptional<typeof Person> {
return co.optional(Person);
}
});
```
</CodeGroup>
### Helper methods
If you find yourself repeating the same logic to access computed CoValues properties,

View File

@@ -7,7 +7,7 @@ import { Alert } from "@garden-co/design-system/src/components/atoms/Alert";
# Inbox API with Server Workers
The Inbox API provides a message-based communication system for Server Workers in Jazz.
The Inbox API provides a message-based communication system for Server Workers in Jazz.
It works on top of the Jazz APIs and uses sync to transfer messages between the client and the server.
@@ -154,8 +154,8 @@ function EventComponent({ event }: { event: Event }) {
```
</CodeGroup>
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
The `sendInboxMessage` API returns a Promise that waits for the message to be handled by a Worker.
A message is considered to be handled when the Promise returned by `inbox.subscribe` resolves.
The value returned will be the id of the CoValue returned in the `inbox.subscribe` resolved promise.
@@ -163,4 +163,4 @@ The value returned will be the id of the CoValue returned in the `inbox.subscrib
Multi-region deployments are not supported when using the Inbox API.
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests.mdx) instead.
If you need to split the workload across multiple regions, you can use the [HTTP API](./http-requests) instead.

View File

@@ -0,0 +1,158 @@
import { CodeGroup } from '@/components/forMdx'
# Jazz 0.16.0 - Cleaner separation between Zod and CoValue schemas
This release introduces a cleaner separation between Zod and CoValue schemas, improves type inference with circular references, and simplifies how you access internal schemas.
While most applications won't require extensive refactors, some breaking changes will require action.
## Motivation
Before 0.16.0, CoValue schemas were a thin wrapper around Zod schemas. This made it easy to use Zod methods on CoValue schemas,
but it also prevented the type checker from detecting issues when combining Zod and CoValue schemas.
For example, the following code would previously compile without errors, but would have severe limitations:
<CodeGroup>
```tsx
import { co, z } from "jazz-tools";
const Dog = co.map({
breed: z.string(),
});
const Person = co.map({
pets: z.array(Dog),
});
// You can create a CoMap with a z.array field that contains another CoMap
const map = Person.create({
pets: [Dog.create({ breed: "Labrador" })],
});
// But then you cannot eagerly load the nested CoMap, because
// there's a plain JS object in between. So this would fail:
Person.load(map.id, { resolve: { pets: { $each: true } } });
```
</CodeGroup>
Schema composition rules are now stricter: Zod schemas can only be composed with other Zod schemas.
CoValue schemas can be composed with either Zod or other CoValue schemas. These rules are enforced at the type level, to make it easier
to spot errors in schema definitions and avoid possible footguns when mixing Zod and CoValue schemas.
Having a stricter separation between Zod and CoValue schemas also allowed us to improve type inference with circular references.
Previously, the type checker would not be able to infer types for even simple circular references, but now it can!
<CodeGroup>
```tsx
import { co, z } from "jazz-tools";
const Person = co.map({
name: z.string(),
get friends(): CoListSchema<typeof Person> { // [!code --]
get friends() { // [!code ++]
return co.list(Person);
},
});
```
</CodeGroup>
There are some scenarios where recursive type inference can still fail due to TypeScript limitations, but these should be rare.
## Breaking changes
### The Account root id is now discoverable
In prior Jazz releases, the Account root id was stored encrypted and accessible only by the account owner.
This made it impossible to load the account root this way:
<CodeGroup>
```tsx
const bob = MyAppAccount.load(bobId, { resolve: { root: true }, loadAs: me });
```
</CodeGroup>
So we changed Account root id to be discoverable by everyone.
**This doesn't affect the visibility of the account root**, which still follows the permissions defined in its group.
For existing accounts, the change is applied the next time the user loads their account.
No action is required on your side, but we preferred to mark this as a breaking change because it
minimally affects access to the account root. (e.g., if in your app the root is public, now users can access other users' root by knowing their account ID)
### `z.optional()` and `z.discriminatedUnion()` no longer work with CoValue schemas
You'll now need to use the `co.optional()` and `co.discriminatedUnion()` equivalents.
This change may require you to update any explicitly typed cyclic references.
<CodeGroup>
```tsx
import { co, z } from "jazz-tools";
const Person = co.map({
name: z.string(),
get bestFriend(): z.ZodOptional<typeof Person> { // [!code --]
return z.optional(Person); // [!code --]
get bestFriend(): co.Optional<typeof Person> { // [!code ++]
return co.optional(Person); // [!code ++]
}
});
```
</CodeGroup>
### CoValue schema types are now under the `co.` namespace
All CoValue schema types are now accessed via the `co.` namespace. If you're using explicit types (especially in recursive schemas), you'll need to update them accordingly.
<CodeGroup>
```tsx
import { co, z } from "jazz-tools";
const Person = co.map({
name: z.string(),
get friends(): CoListSchema<typeof Person> { // [!code --]
get friends(): co.List<typeof Person> { // [!code ++]
return co.list(Person);
}
});
```
</CodeGroup>
### Unsupported Zod methods have been removed from CoMap schemas
CoMap schemas no longer incorrectly inherit Zod methods like `.extend()` and `.partial()`. These methods previously appeared to work but could behave unpredictably. They have now been disabled.
We're keeping `.optional()` and plan to introduce more Zod-like methods in future releases.
### Internal schema access is now simpler
You no longer need to use Zod's `.def` to access schema internals. Instead, you can directly use methods like `CoMapSchema.shape`, `CoListSchema.element`, and `CoOptionalSchema.innerType`.
<CodeGroup>
```tsx
import { co, z } from "jazz-tools";
const Message = co.map({
content: co.richText(),
});
const Thread = co.map({
messages: co.list(Message),
});
const thread = Thread.create({
messages: Thread.def.shape.messages.create([ // [!code --]
messages: Thread.shape.messages.create([ // [!code ++]
Message.create({
content: co.richText().create("Hi!"),
}),
Message.create({
content: co.richText().create("What's up?"),
}),
]),
});
```
</CodeGroup>
### Removed the deprecated `withHelpers` method from CoValue schemas
The deprecated `withHelpers()` method has been removed from CoValue schemas. You can define helper functions manually to encapsulate CoValue-related logic.
[Learn how to define helper methods](https://jazz.tools/docs/vanilla/schemas/covalues#helper-methods).

View File

@@ -343,14 +343,14 @@ CoLists can be used to create one-to-many relationships:
<CodeGroup>
```ts twoslash
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const Task = co.map({
title: z.string(),
status: z.literal(["todo", "in-progress", "complete"]),
get project(): z.ZodOptional<typeof Project> {
return z.optional(Project);
get project(): co.Optional<typeof Project> {
return co.optional(Project);
}
});
@@ -359,7 +359,7 @@ const ListOfTasks = co.list(Task);
const Project = co.map({
name: z.string(),
get tasks(): CoListSchema<typeof Task> {
get tasks(): co.List<typeof Task> {
return ListOfTasks;
}
});

View File

@@ -213,14 +213,14 @@ const Member = co.map({
name: z.string(),
});
// ---cut---
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
coordinator: co.optional(Member),
get subProjects(): z.ZodOptional<CoListSchema<typeof Project>> {
get subProjects(): co.Optional<co.List<typeof Project>> {
return co.optional(co.list(Project));
}
});
@@ -228,6 +228,54 @@ export type Project = co.loaded<typeof Project>;
```
</CodeGroup>
### Partial
For convenience Jazz provies a dedicated API for making all the properties of a CoMap optional:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
});
const ProjectDraft = Project.partial();
// The fields are all optional now
const project = ProjectDraft.create({});
```
</CodeGroup>
### Pick
You can also pick specific fields from a CoMap:
<CodeGroup>
```ts twoslash
import { co, z } from "jazz-tools";
const Project = co.map({
name: z.string(),
startDate: z.date(),
status: z.literal(["planning", "active", "completed"]),
});
const ProjectStep1 = Project.pick({
name: true,
startDate: true,
});
// We don't provide the status field
const project = ProjectStep1.create({
name: "My project",
startDate: new Date("2025-04-01"),
});
```
</CodeGroup>
### Working with Record CoMaps
For record-type CoMaps, you can access values using bracket notation:

View File

@@ -50,51 +50,3 @@ export type User = co.loaded<typeof User>;
This direct linking approach offers a single source of truth. When you update a referenced CoValue, all other CoValues that point to it are automatically updated, ensuring data consistency across your application.
By connecting CoValues through these direct references, you can build robust and collaborative applications where data is consistent, efficient to manage, and relationships are clearly defined. The ability to link different CoValue types to the same underlying data is fundamental to building complex applications with Jazz.
## Recursive references with DiscriminatedUnion
In advanced schemas, you may want a CoValue that recursively references itself. For example, a `ReferenceItem` that contains a list of other items like `NoteItem` or `AttachmentItem`. This is common in tree-like structures such as threaded comments or nested project outlines.
You can model this with a Zod `z.discriminatedUnion`, but TypeScripts type inference doesn't handle recursive unions well without a workaround.
Heres how to structure your schema to avoid circular reference errors.
### Use this pattern for recursive discriminated unions
<CodeGroup>
```ts twoslash
import { CoListSchema, co, z } from "jazz-tools";
// Recursive item modeling pattern using discriminated unions
// First, define the non-recursive types
export const NoteItem = co.map({
type: z.literal("note"),
internal: z.boolean(),
content: co.plainText(),
});
export const AttachmentItem = co.map({
type: z.literal("attachment"),
internal: z.boolean(),
content: co.fileStream(),
});
export const ReferenceItem = co.map({
type: z.literal("reference"),
internal: z.boolean(),
content: z.string(),
// Workaround: declare the field type using CoListSchema and ZodDiscriminatedUnion so TS can safely recurse
get children(): CoListSchema<z.ZodDiscriminatedUnion<[typeof NoteItem, typeof AttachmentItem, typeof ReferenceItem]>> {
return ProjectContextItemList;
},
});
// Create the recursive union
export const ProjectContextItem = z.discriminatedUnion("type", [NoteItem, AttachmentItem, ReferenceItem]);
// Final list of recursive types
export const ProjectContextItemList = co.list(ProjectContextItem);
```
</CodeGroup>
Even though this seems like a shortcut, TypeScript and Zod can't resolve the circular reference this way. Always define the discriminated union before introducing recursive links.

View File

@@ -24,7 +24,7 @@ import { Group, co, z } from "jazz-tools";
const MyProfile = co.profile({
name: z.string(),
image: z.optional(co.image()),
image: co.optional(co.image()),
});
const MyAccount = co.account({

View File

@@ -25,7 +25,7 @@ import { Group, co, z } from "jazz-tools";
const MyProfile = co.profile({
name: z.string(),
image: z.optional(co.image()),
image: co.optional(co.image()),
});
const MyAccount = co.account({

View File

@@ -249,7 +249,7 @@ Resolve queries let you declare exactly which references to load and how deep to
<CodeGroup>
```ts twoslash
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const projectId = "co_123";
// ---cut-before---
@@ -259,8 +259,8 @@ const TeamMember = co.map({
const Task = co.map({
title: z.string(),
assignee: z.optional(TeamMember),
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
assignee: co.optional(TeamMember),
get subtasks(): co.List<typeof Task> { return co.list(Task) },
});
const Project = co.map({
@@ -349,7 +349,7 @@ When a user tries to load a reference they don't have access to:
<CodeGroup>
```ts twoslash
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const TeamMember = co.map({
name: z.string(),
@@ -357,8 +357,8 @@ const TeamMember = co.map({
const Task = co.map({
title: z.string(),
assignee: z.optional(TeamMember),
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
assignee: co.optional(TeamMember),
get subtasks(): co.List<typeof Task> { return co.list(Task) },
});
const Project = co.map({
@@ -388,7 +388,7 @@ When a list contains references to items the user can't access:
<CodeGroup>
```ts twoslash
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const TeamMember = co.map({
name: z.string(),
@@ -396,8 +396,8 @@ const TeamMember = co.map({
const Task = co.map({
title: z.string(),
assignee: z.optional(TeamMember),
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
assignee: co.optional(TeamMember),
get subtasks(): co.List<typeof Task> { return co.list(Task) },
});
const Project = co.map({
@@ -424,7 +424,7 @@ When trying to load an object with an inaccessible reference without directly re
<CodeGroup>
```ts twoslash
import { co, z, CoListSchema } from "jazz-tools";
import { co, z } from "jazz-tools";
const TeamMember = co.map({
name: z.string(),
@@ -432,8 +432,8 @@ const TeamMember = co.map({
const Task = co.map({
title: z.string(),
assignee: z.optional(TeamMember),
get subtasks(): CoListSchema<typeof Task> { return co.list(Task) },
assignee: co.optional(TeamMember),
get subtasks(): co.List<typeof Task> { return co.list(Task) },
});
const Project = co.map({
@@ -468,7 +468,7 @@ This way the inaccessible items are replaced with `null` in the returned list.
<CodeGroup>
```ts twoslash
import { co, z, CoListSchema, Group } from "jazz-tools";
import { co, z, Group } from "jazz-tools";
import { createJazzTestAccount } from "jazz-tools/testing";
const me = await createJazzTestAccount();
@@ -653,7 +653,7 @@ The `co.loaded` type is especially useful when passing data between components,
<ContentByFramework framework="react">
<CodeGroup>
```tsx twoslash
import { CoListSchema, co, z } from "jazz-tools";
import { co, z } from "jazz-tools";
import React from "react";
const TeamMember = co.map({
@@ -662,8 +662,8 @@ const TeamMember = co.map({
const Task = co.map({
title: z.string(),
assignee: z.optional(TeamMember),
get subtasks(): CoListSchema<typeof Task> {
assignee: co.optional(TeamMember),
get subtasks(): co.List<typeof Task> {
return co.list(Task);
},
});
@@ -727,7 +727,7 @@ function processProject(project: FullyLoadedProject) {
<ContentByFramework framework="vanilla">
<CodeGroup>
```ts twoslash
import { CoListSchema, co, z } from "jazz-tools";
import { co, z } from "jazz-tools";
const TeamMember = co.map({
name: z.string(),
@@ -735,8 +735,8 @@ const TeamMember = co.map({
const Task = co.map({
title: z.string(),
assignee: z.optional(TeamMember),
get subtasks(): CoListSchema<typeof Task> {
assignee: co.optional(TeamMember),
get subtasks(): co.List<typeof Task> {
return co.list(Task);
},
});
@@ -799,7 +799,7 @@ Sometimes you need to make sure data is loaded before proceeding with an operati
<CodeGroup>
```ts twoslash
import { CoListSchema, co, z } from "jazz-tools";
import { co, z } from "jazz-tools";
const TeamMember = co.map({
name: z.string(),
@@ -809,7 +809,7 @@ const Task = co.map({
title: z.string(),
status: z.literal(["todo", "in-progress", "completed"]),
assignee: z.string().optional(),
get subtasks(): CoListSchema<typeof Task> {
get subtasks(): co.List<typeof Task> {
return co.list(Task);
},
});

View File

@@ -1,5 +1,5 @@
export const marketingCopy = {
headline: "Whip up an app",
headline: "Smooth database.",
description:
"Jazz gives you data without needing a database — plus auth, permissions, files and multiplayer without needing a backend. Do everything right from the frontend and ship better apps, faster.",
"Jazz is a database that's distributed across your frontend, containers and functions. It syncs structured data, files and LLM streams instantly and looks like local reactive JSON state.",
};

View File

@@ -5,6 +5,8 @@ import { readFile, readdir } from "fs/promises";
import { DOC_SECTIONS } from "./utils/config.mjs";
import { writeDocsFile } from "./utils/index.mjs";
const exclude = [/\/upgrade\//];
async function readMdxContent(url) {
try {
// Special case for the introduction
@@ -31,12 +33,17 @@ async function readMdxContent(url) {
// If it's a directory, try to read all framework variants
const fullPath = path.join(baseDir, relativePath);
if (exclude.some((pattern) => pattern.test(fullPath))) {
return null;
}
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// Read all MDX files in the directory
const files = await fs.readdir(fullPath);
const mdxFiles = files.filter((f) => f.endsWith(".mdx"));
const mdxFiles = files.filter((f) => f.endsWith(".mdx")).filter((f) => !exclude.some((pattern) => pattern.test(f)));
if (mdxFiles.length === 0) return null;

View File

@@ -31,7 +31,7 @@
"build:packages": "turbo build --filter='./packages/*'",
"lint": "turbo lint && cd homepage/homepage && pnpm run lint",
"test": "vitest",
"test:ci": "vitest run --watch=false --coverage.enabled=true",
"test:ci": "vitest run --watch=false",
"test:coverage": "vitest --ui --coverage.enabled=true",
"format-and-lint": "biome check .",
"format-and-lint:fix": "biome check . --write",

View File

@@ -1,5 +1,46 @@
# cojson-storage-indexeddb
## 0.16.5
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [267f689]
- cojson@0.16.5
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes
- cojson@0.16.2
## 0.16.1
### Patch Changes
- cojson@0.16.1
## 0.16.0
### Patch Changes
- Updated dependencies [c09dcdf]
- cojson@0.16.0
## 0.15.16
### Patch Changes

View File

@@ -1,6 +1,6 @@
{
"name": "cojson-storage-indexeddb",
"version": "0.15.16",
"version": "0.16.5",
"main": "dist/index.js",
"type": "module",
"types": "dist/index.d.ts",

View File

@@ -179,8 +179,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 5",
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> CONTENT Group header: false new: After: 3 New: 2",
"client -> CONTENT Map header: true new: After: 0 New: 1",
]
`);
@@ -561,9 +562,10 @@ test("should sync and load accounts from storage", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Account header: true new: After: 0 New: 4",
"client -> CONTENT Account header: true new: After: 0 New: 3",
"client -> CONTENT ProfileGroup header: true new: After: 0 New: 5",
"client -> CONTENT Profile header: true new: After: 0 New: 1",
"client -> CONTENT Account header: false new: After: 3 New: 1",
]
`);

View File

@@ -36,12 +36,11 @@ export function trackMessages() {
};
StorageApiAsync.prototype.store = async function (data, correctionCallback) {
for (const msg of data) {
messages.push({
from: "client",
msg,
});
}
messages.push({
from: "client",
msg: data,
});
return originalStore.call(this, data, (msg) => {
messages.push({
from: "storage",
@@ -51,7 +50,18 @@ export function trackMessages() {
...msg,
},
});
correctionCallback(msg);
const correctionMessages = correctionCallback(msg);
if (correctionMessages) {
for (const msg of correctionMessages) {
messages.push({
from: "client",
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -1,5 +1,46 @@
# cojson-storage-sqlite
## 0.16.5
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [267f689]
- cojson@0.16.5
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes
- cojson@0.16.2
## 0.16.1
### Patch Changes
- cojson@0.16.1
## 0.16.0
### Patch Changes
- Updated dependencies [c09dcdf]
- cojson@0.16.0
## 0.15.16
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-storage-sqlite",
"type": "module",
"version": "0.15.16",
"version": "0.16.5",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -211,8 +211,9 @@ test("should load dependencies correctly (group inheritance)", async () => {
),
).toMatchInlineSnapshot(`
[
"client -> CONTENT Group header: true new: After: 0 New: 5",
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT ParentGroup header: true new: After: 0 New: 4",
"client -> CONTENT Group header: false new: After: 3 New: 2",
"client -> CONTENT Map header: true new: After: 0 New: 1",
]
`);
@@ -374,6 +375,8 @@ test("should recover from data loss", async () => {
[
"client -> CONTENT Group header: true new: After: 0 New: 3",
"client -> CONTENT Map header: true new: After: 0 New: 1",
"client -> CONTENT Map header: false new: After: 3 New: 1",
"storage -> KNOWN CORRECTION Map sessions: header/4",
"client -> CONTENT Map header: false new: After: 1 New: 3",
]
`);
@@ -455,10 +458,7 @@ test("should recover missing dependencies from storage", async () => {
data,
correctionCallback,
) {
if (
data[0]?.id &&
[group.core.id, account.core.id as string].includes(data[0].id)
) {
if ([group.core.id, account.core.id as string].includes(data.id)) {
return false;
}

View File

@@ -36,12 +36,11 @@ export function trackMessages() {
};
StorageApiSync.prototype.store = function (data, correctionCallback) {
for (const msg of data) {
messages.push({
from: "client",
msg,
});
}
messages.push({
from: "client",
msg: data,
});
return originalStore.call(this, data, (msg) => {
messages.push({
from: "storage",
@@ -51,7 +50,19 @@ export function trackMessages() {
...msg,
},
});
correctionCallback(msg);
const correctionMessages = correctionCallback(msg);
if (correctionMessages) {
for (const msg of correctionMessages) {
messages.push({
from: "client",
msg,
});
}
}
return correctionMessages;
});
};

View File

@@ -1,5 +1,46 @@
# cojson-transport-nodejs-ws
## 0.16.5
### Patch Changes
- Updated dependencies [3cd1586]
- Updated dependencies [267f689]
- cojson@0.16.5
## 0.16.4
### Patch Changes
- Updated dependencies [f9d538f]
- Updated dependencies [802b5a3]
- cojson@0.16.4
## 0.16.3
### Patch Changes
- cojson@0.16.3
## 0.16.2
### Patch Changes
- cojson@0.16.2
## 0.16.1
### Patch Changes
- cojson@0.16.1
## 0.16.0
### Patch Changes
- Updated dependencies [c09dcdf]
- cojson@0.16.0
## 0.15.16
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "cojson-transport-ws",
"type": "module",
"version": "0.15.16",
"version": "0.16.5",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "MIT",

View File

@@ -1,5 +1,39 @@
# cojson
## 0.16.5
### Patch Changes
- 3cd1586: Makes the key rotation not fail when child groups are unavailable or their readkey is not accessible.
Also changes the Group.removeMember method to not return a Promise, because:
- All the locally available child groups are rotated immediately
- All the remote child groups are rotated in background, but since they are not locally available the user won't need the new key immediately
- 267f689: Groups: fix the readkey not being revealed to everyone when doing a key rotation
## 0.16.4
### Patch Changes
- f9d538f: Fix the error raised when extending a group without having child groups loaded
- 802b5a3: Refactor local updates sync to ensure that the changes are synced respecting the insertion order
## 0.16.3
## 0.16.2
## 0.16.1
## 0.16.0
### Minor Changes
- c09dcdf: Change the root attribute to be public on Account. The root content will still follow the visiblity rules specified in their group.
Existing accounts will be gradually migrated as they are loaded.
## 0.15.16
## 0.15.15

View File

@@ -25,7 +25,7 @@
},
"type": "module",
"license": "MIT",
"version": "0.15.16",
"version": "0.16.5",
"devDependencies": {
"@opentelemetry/sdk-metrics": "^2.0.0",
"libsql": "^0.5.13",

View File

@@ -1,7 +1,4 @@
import {
AvailableCoValueCore,
CoValueCore,
} from "./coValueCore/coValueCore.js";
import { AvailableCoValueCore } from "./coValueCore/coValueCore.js";
import { RawProfile as Profile, RawAccount } from "./coValues/account.js";
import { RawCoList } from "./coValues/coList.js";
import { RawCoMap } from "./coValues/coMap.js";

View File

@@ -0,0 +1,73 @@
import {
CoValueHeader,
Transaction,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import { MAX_RECOMMENDED_TX_SIZE } from "./config.js";
import { Signature } from "./crypto/crypto.js";
import { RawCoID, SessionID } from "./ids.js";
import { getPriorityFromHeader } from "./priority.js";
import { NewContentMessage, emptyKnownState } from "./sync.js";
export function createContentMessage(
id: RawCoID,
header: CoValueHeader,
includeHeader = true,
): NewContentMessage {
return {
action: "content",
id,
header: includeHeader ? header : undefined,
priority: getPriorityFromHeader(header),
new: {},
};
}
export function addTransactionToContentMessage(
content: NewContentMessage,
transaction: Transaction,
sessionID: SessionID,
signature: Signature,
txIdx: number,
) {
const sessionContent = content.new[sessionID];
if (sessionContent) {
sessionContent.newTransactions.push(transaction);
sessionContent.lastSignature = signature;
} else {
content.new[sessionID] = {
after: txIdx,
newTransactions: [transaction],
lastSignature: signature,
};
}
}
export function getTransactionSize(transaction: Transaction) {
return transaction.privacy === "private"
? transaction.encryptedChanges.length
: transaction.changes.length;
}
export function exceedsRecommendedSize(
baseSize: number,
transactionSize?: number,
) {
if (transactionSize === undefined) {
return baseSize > MAX_RECOMMENDED_TX_SIZE;
}
return baseSize + transactionSize > MAX_RECOMMENDED_TX_SIZE;
}
export function knownStateFromContent(content: NewContentMessage) {
const knownState = emptyKnownState(content.id);
for (const [sessionID, session] of Object.entries(content.new)) {
knownState.sessions[sessionID as SessionID] =
session.after + session.newTransactions.length;
}
return knownState;
}

View File

@@ -1,10 +1,10 @@
import { UpDownCounter, ValueType, metrics } from "@opentelemetry/api";
import { Result, err } from "neverthrow";
import { PeerState } from "../PeerState.js";
import { RawCoValue } from "../coValue.js";
import { ControlledAccountOrAgent, RawAccountID } from "../coValues/account.js";
import { RawGroup } from "../coValues/group.js";
import { CO_VALUE_LOADING_CONFIG, MAX_RECOMMENDED_TX_SIZE } from "../config.js";
import type { PeerState } from "../PeerState.js";
import type { RawCoValue } from "../coValue.js";
import type { ControlledAccountOrAgent } from "../coValues/account.js";
import type { RawGroup } from "../coValues/group.js";
import { CO_VALUE_LOADING_CONFIG } from "../config.js";
import { coreToCoValue } from "../coreToCoValue.js";
import {
CryptoProvider,
@@ -16,25 +16,15 @@ import {
SignerID,
StreamingHash,
} from "../crypto/crypto.js";
import {
RawCoID,
SessionID,
TransactionID,
getParentGroupId,
isParentGroupReference,
} from "../ids.js";
import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { parseJSON, stableStringify } from "../jsonStringify.js";
import { JsonValue } from "../jsonValue.js";
import { LocalNode, ResolveAccountAgentError } from "../localNode.js";
import { logger } from "../logger.js";
import {
determineValidTransactions,
isKeyForKeyField,
} from "../permissions.js";
import { determineValidTransactions } from "../permissions.js";
import { CoValueKnownState, PeerID, emptyKnownState } from "../sync.js";
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
import { isAccountID } from "../typeUtils/isAccountID.js";
import { getDependedOnCoValuesFromRawData } from "./utils.js";
import { CoValueHeader, Transaction, VerifiedState } from "./verifiedState.js";
@@ -50,10 +40,9 @@ export type DecryptedTransaction = {
txID: TransactionID;
changes: JsonValue[];
madeAt: number;
trusting?: boolean;
};
const readKeyCache = new WeakMap<CoValueCore, { [id: KeyID]: KeySecret }>();
export type AvailableCoValueCore = CoValueCore & { verified: VerifiedState };
export class CoValueCore {
@@ -379,7 +368,7 @@ export class CoValueCore {
}
knownStateWithStreaming(): CoValueKnownState {
if (this.isAvailable()) {
if (this.verified) {
return this.verified.knownStateWithStreaming();
} else {
return emptyKnownState(this.id);
@@ -387,7 +376,7 @@ export class CoValueCore {
}
knownState(): CoValueKnownState {
if (this.isAvailable()) {
if (this.verified) {
return this.verified.knownState();
} else {
return emptyKnownState(this.id);
@@ -604,8 +593,17 @@ export class CoValueCore {
)._unsafeUnwrap({ withStackTrace: true });
if (success) {
const session = this.verified.sessions.get(sessionID);
const txIdx = session ? session.transactions.length - 1 : 0;
this.node.syncManager.recordTransactionsSize([transaction], "local");
void this.node.syncManager.requestCoValueSync(this);
this.node.syncManager.syncLocalTransaction(
this.verified,
transaction,
sessionID,
signature,
txIdx,
);
}
return success;
@@ -657,6 +655,7 @@ export class CoValueCore {
txID,
madeAt: tx.madeAt,
changes: parseJSON(tx.changes),
trusting: true,
});
} catch (e) {
logger.error("Failed to parse trusting transaction on " + this.id, {
@@ -757,20 +756,7 @@ export class CoValueCore {
}
if (this.verified.header.ruleset.type === "group") {
const content = expectGroup(this.getCurrentContent());
const currentKeyId = content.getCurrentReadKeyId();
if (!currentKeyId) {
throw new Error("No readKey set");
}
const secret = this.getReadKey(currentKeyId);
return {
secret: secret,
id: currentKeyId,
};
return expectGroup(this.getCurrentContent()).getCurrentReadKey();
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.verified.header.ruleset.group)
@@ -782,154 +768,36 @@ export class CoValueCore {
}
}
readKeyCache = new Map<KeyID, KeySecret>();
getReadKey(keyID: KeyID): KeySecret | undefined {
let key = readKeyCache.get(this)?.[keyID];
if (!key) {
key = this.getUncachedReadKey(keyID);
if (key) {
let cache = readKeyCache.get(this);
if (!cache) {
cache = {};
readKeyCache.set(this, cache);
}
cache[keyID] = key;
}
}
return key;
}
// We want to check the cache here, to skip re-computing the group content
const cachedSecret = this.readKeyCache.get(keyID);
if (cachedSecret) {
return cachedSecret;
}
getUncachedReadKey(keyID: KeyID): KeySecret | undefined {
if (!this.verified) {
throw new Error(
"CoValueCore: getUncachedReadKey called on coValue without verified state",
);
}
// Getting the readKey from accounts
if (this.verified.header.ruleset.type === "group") {
const content = expectGroup(
this.getCurrentContent({ ignorePrivateTransactions: true }), // to prevent recursion
);
const keyForEveryone = content.get(`${keyID}_for_everyone`);
if (keyForEveryone) {
return keyForEveryone;
}
// Try to find key revelation for us
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
this.node.currentSessionID,
// load the account without private transactions, because we are here
// to be able to decrypt those
this.getCurrentContent({ ignorePrivateTransactions: true }),
);
// being careful here to avoid recursion
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
? this.id === currentAgentOrAccountID
? this.crypto.getAgentID(this.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
: currentAgentOrAccountID // current account ID
: currentAgentOrAccountID; // current agent ID
const lastReadyKeyEdit = content.lastEditAt(
`${keyID}_for_${lookupAccountOrAgentID}`,
);
if (lastReadyKeyEdit?.value) {
const revealer = lastReadyKeyEdit.by;
const revealerAgent = this.node
.resolveAccountAgent(revealer, "Expected to know revealer")
._unsafeUnwrap({ withStackTrace: true });
const secret = this.crypto.unseal(
lastReadyKeyEdit.value,
this.crypto.getAgentSealerSecret(this.node.agentSecret), // being careful here to avoid recursion
this.crypto.getAgentSealerID(revealerAgent),
{
in: this.id,
tx: lastReadyKeyEdit.tx,
},
);
if (secret) {
return secret as KeySecret;
}
}
// Try to find indirect revelation through previousKeys
for (const co of content.keys()) {
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
const encryptingKeyID = co.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
const encryptedPreviousKey = content.get(co)!;
const secret = this.crypto.decryptKeySecret(
{
encryptedID: keyID,
encryptingID: encryptingKeyID,
encrypted: encryptedPreviousKey,
},
encryptingKeySecret,
);
if (secret) {
return secret as KeySecret;
} else {
logger.warn(
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
);
}
}
}
// try to find revelation to parent group read keys
for (const co of content.keys()) {
if (isParentGroupReference(co)) {
const parentGroupID = getParentGroupId(co);
const parentGroup = this.node.expectCoValueLoaded(
parentGroupID,
"Expected parent group to be loaded",
);
const parentKeys = this.findValidParentKeys(
keyID,
content,
parentGroup,
);
for (const parentKey of parentKeys) {
const revelationForParentKey = content.get(
`${keyID}_for_${parentKey.id}`,
);
if (revelationForParentKey) {
const secret = parentGroup.crypto.decryptKeySecret(
{
encryptedID: keyID,
encryptingID: parentKey.id,
encrypted: revelationForParentKey,
},
parentKey.secret,
);
if (secret) {
return secret as KeySecret;
} else {
logger.warn(
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
);
}
}
}
}
}
return undefined;
return content.getReadKey(keyID);
} else if (this.verified.header.ruleset.type === "ownedByGroup") {
return this.node
.expectCoValueLoaded(this.verified.header.ruleset.group)
.getReadKey(keyID);
return expectGroup(
this.node
.expectCoValueLoaded(this.verified.header.ruleset.group)
.getCurrentContent(),
).getReadKey(keyID);
} else {
throw new Error(
"Only groups or values owned by groups have read secrets",
@@ -937,28 +805,6 @@ export class CoValueCore {
}
}
findValidParentKeys(keyID: KeyID, group: RawGroup, parentGroup: CoValueCore) {
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
for (const co of group.keys()) {
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
const encryptingKeyID = co.split("_for_")[1] as KeyID;
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
validParentKeys.push({
id: encryptingKeyID,
secret: encryptingKeySecret,
});
}
}
return validParentKeys;
}
getGroup(): RawGroup {
if (!this.verified) {
throw new Error(

View File

@@ -1,6 +1,10 @@
import { Result, err, ok } from "neverthrow";
import { AnyRawCoValue } from "../coValue.js";
import { MAX_RECOMMENDED_TX_SIZE } from "../config.js";
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
CryptoProvider,
Encrypted,
@@ -14,7 +18,6 @@ import { RawCoID, SessionID, TransactionID } from "../ids.js";
import { Stringified } from "../jsonStringify.js";
import { JsonObject, JsonValue } from "../jsonValue.js";
import { PermissionsDef as RulesetDef } from "../permissions.js";
import { getPriorityFromHeader } from "../priority.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
import { InvalidHashError, InvalidSignatureError } from "./coValueCore.js";
import { TryAddTransactionsError } from "./coValueCore.js";
@@ -151,6 +154,17 @@ export class VerifiedState {
return ok(true as const);
}
getLastSignatureCheckpoint(sessionID: SessionID): number {
const sessionLog = this.sessions.get(sessionID);
if (!sessionLog?.signatureAfter) return -1;
return Object.keys(sessionLog.signatureAfter).reduce(
(max, idx) => Math.max(max, parseInt(idx)),
-1,
);
}
private doAddTransactions(
sessionID: SessionID,
newTransactions: Transaction[],
@@ -165,24 +179,14 @@ export class VerifiedState {
}
const signatureAfter = sessionLog?.signatureAfter ?? {};
const lastInbetweenSignatureIdx = Object.keys(signatureAfter).reduce(
(max, idx) => (parseInt(idx) > max ? parseInt(idx) : max),
-1,
);
const lastInbetweenSignatureIdx =
this.getLastSignatureCheckpoint(sessionID);
const sizeOfTxsSinceLastInbetweenSignature = transactions
.slice(lastInbetweenSignatureIdx + 1)
.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
.reduce((sum, tx) => sum + getTransactionSize(tx), 0);
if (sizeOfTxsSinceLastInbetweenSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(sizeOfTxsSinceLastInbetweenSignature)) {
signatureAfter[transactions.length - 1] = newSignature;
}
@@ -242,13 +246,11 @@ export class VerifiedState {
return this._cachedNewContentSinceEmpty;
}
let currentPiece: NewContentMessage = {
action: "content",
id: this.id,
header: knownState?.header ? undefined : this.header,
priority: getPriorityFromHeader(this.header),
new: {},
};
let currentPiece: NewContentMessage = createContentMessage(
this.id,
this.header,
!knownState?.header,
);
const pieces = [currentPiece];
@@ -299,25 +301,16 @@ export class VerifiedState {
const oldPieceSize = pieceSize;
for (let txIdx = firstNewTxIdx; txIdx < afterLastNewTxIdx; txIdx++) {
const tx = log.transactions[txIdx]!;
pieceSize +=
tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length;
pieceSize += getTransactionSize(tx);
}
if (pieceSize >= MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(pieceSize)) {
if (!currentPiece.expectContentUntil && pieces.length === 1) {
currentPiece.expectContentUntil =
this.knownStateWithStreaming().sessions;
}
currentPiece = {
action: "content",
id: this.id,
header: undefined,
new: {},
priority: getPriorityFromHeader(this.header),
};
currentPiece = createContentMessage(this.id, this.header, false);
pieces.push(currentPiece);
pieceSize = pieceSize - oldPieceSize;
}

View File

@@ -16,6 +16,7 @@ type MapOp<K extends string, V extends JsonValue | undefined> = {
madeAt: number;
changeIdx: number;
change: MapOpPayload<K, V>;
trusting?: boolean;
};
// TODO: add after TransactionID[] for conflicts/ordering
@@ -112,7 +113,7 @@ export class RawCoMapView<
NonNullable<(typeof ops)[keyof typeof ops]>
>();
for (const { txID, changes, madeAt } of newValidTransactions) {
for (const { txID, changes, madeAt, trusting } of newValidTransactions) {
if (madeAt > this.latestTxMadeAt) {
this.latestTxMadeAt = madeAt;
}
@@ -127,6 +128,7 @@ export class RawCoMapView<
madeAt,
changeIdx,
change,
trusting,
};
const entries = ops[change.key];

View File

@@ -1,8 +1,11 @@
import { base58 } from "@scure/base";
import { CoID } from "../coValue.js";
import { AvailableCoValueCore } from "../coValueCore/coValueCore.js";
import { CoValueUniqueness } from "../coValueCore/verifiedState.js";
import {
import type { CoID } from "../coValue.js";
import type {
AvailableCoValueCore,
CoValueCore,
} from "../coValueCore/coValueCore.js";
import type { CoValueUniqueness } from "../coValueCore/verifiedState.js";
import type {
CryptoProvider,
Encrypted,
KeyID,
@@ -21,8 +24,10 @@ import {
} from "../ids.js";
import { JsonObject } from "../jsonValue.js";
import { logger } from "../logger.js";
import { AccountRole, Role } from "../permissions.js";
import { AccountRole, Role, isKeyForKeyField } from "../permissions.js";
import { accountOrAgentIDfromSessionID } from "../typeUtils/accountOrAgentIDfromSessionID.js";
import { expectGroup } from "../typeUtils/expectGroup.js";
import { isAccountID } from "../typeUtils/isAccountID.js";
import {
ControlledAccountOrAgent,
RawAccount,
@@ -60,6 +65,59 @@ export type GroupShape = {
[child: ChildGroupReference]: "revoked" | "extend";
};
// We had a bug on key rotation, where the new read key was not revealed to everyone
// TODO: remove this when we hit the 0.18.0 release (either the groups are healed or they are not used often, it's a minor issue anyway)
function healMissingKeyForEveryone(group: RawGroup) {
const readKeyId = group.get("readKey");
if (
!readKeyId ||
!canRead(group, EVERYONE) ||
group.get(`${readKeyId}_for_${EVERYONE}`)
) {
return;
}
const hasAccessToReadKey = canRead(
group,
group.core.node.getCurrentAgent().id,
);
// If the current account has access to the read key, we can fix the group
if (hasAccessToReadKey) {
const secret = group.getReadKey(readKeyId);
if (secret) {
group.set(`${readKeyId}_for_${EVERYONE}`, secret, "trusting");
}
return;
}
// Fallback to the latest readable key for everyone
const keys = group
.keys()
.filter((key) => key.startsWith("key_") && key.endsWith("_for_everyone"));
let latestKey = keys[0];
for (const key of keys) {
if (!latestKey) {
latestKey = key;
continue;
}
const keyEntry = group.getRaw(key);
const latestKeyEntry = group.getRaw(latestKey);
if (keyEntry && latestKeyEntry && keyEntry.madeAt > latestKeyEntry.madeAt) {
latestKey = key;
}
}
if (latestKey) {
group._lastReadableKeyId = latestKey.replace("_for_everyone", "") as KeyID;
}
}
/** A `Group` is a scope for permissions of its members (`"reader" | "writer" | "admin"`), applying to objects owned by that group.
*
* A `Group` object exposes methods for permission management and allows you to create new CoValues owned by that group.
@@ -86,6 +144,8 @@ export class RawGroup<
> extends RawCoMap<GroupShape, Meta> {
protected readonly crypto: CryptoProvider;
_lastReadableKeyId?: KeyID;
constructor(
core: AvailableCoValueCore,
options?: {
@@ -94,6 +154,8 @@ export class RawGroup<
) {
super(core, options);
this.crypto = core.node.crypto;
healMissingKeyForEveryone(this);
}
/**
@@ -191,43 +253,7 @@ export class RawGroup<
return groups;
}
loadAllChildGroups() {
const requests: Promise<unknown>[] = [];
const peers = this.core.node.syncManager.getServerPeers();
for (const key of this.keys()) {
if (!isChildGroupReference(key)) {
continue;
}
const id = getChildGroupId(key);
const child = this.core.node.getCoValue(id);
if (
child.loadingState === "unknown" ||
child.loadingState === "unavailable"
) {
child.load(peers);
}
requests.push(
child.waitForAvailableOrUnavailable().then((coValue) => {
if (!coValue.isAvailable()) {
throw new Error(`Child group ${child.id} is unavailable`);
}
// Recursively load child groups
return expectGroup(coValue.getCurrentContent()).loadAllChildGroups();
}),
);
}
return Promise.all(requests);
}
getChildGroups() {
const groups: RawGroup[] = [];
forEachChildGroup(callback: (child: RawGroup) => void) {
for (const key of this.keys()) {
if (isChildGroupReference(key)) {
// Check if the child group reference is revoked
@@ -235,15 +261,22 @@ export class RawGroup<
continue;
}
const child = this.core.node.expectCoValueLoaded(
getChildGroupId(key),
"Expected child group to be loaded",
);
groups.push(expectGroup(child.getCurrentContent()));
const id = getChildGroupId(key);
const child = this.core.node.getCoValue(id);
if (child.isAvailable()) {
callback(expectGroup(child.getCurrentContent()));
} else {
this.core.node.load(id).then((child) => {
if (child !== "unavailable") {
callback(expectGroup(child));
} else {
logger.warn(`Unable to load child group ${id}, skipping`);
}
});
}
}
}
return groups;
}
/**
@@ -279,7 +312,7 @@ export class RawGroup<
"Can't make everyone something other than reader, writer or writeOnly",
);
}
const currentReadKey = this.core.getCurrentReadKey();
const currentReadKey = this.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
@@ -306,7 +339,7 @@ export class RawGroup<
if (role === "writeOnly") {
if (previousRole === "reader" || previousRole === "writer") {
this.rotateReadKey();
this.rotateReadKey("everyone");
}
this.delete(`${currentReadKey.id}_for_${EVERYONE}`);
@@ -349,7 +382,7 @@ export class RawGroup<
this.internalCreateWriteOnlyKeyForMember(memberKey, agent);
} else {
const currentReadKey = this.core.getCurrentReadKey();
const currentReadKey = this.getCurrentReadKey();
if (!currentReadKey.secret) {
throw new Error("Can't add member without read key secret");
@@ -467,6 +500,10 @@ export class RawGroup<
}
getCurrentReadKeyId() {
if (this._lastReadableKeyId) {
return this._lastReadableKeyId;
}
const myRole = this.myRole();
if (myRole === "writeOnly") {
@@ -518,23 +555,173 @@ export class RawGroup<
return memberKeys;
}
getReadKey(keyID: KeyID): KeySecret | undefined {
const cache = this.core.readKeyCache;
let key = cache.get(keyID);
if (!key) {
key = this.getUncachedReadKey(keyID);
if (key) {
cache.set(keyID, key);
}
}
return key;
}
getUncachedReadKey(keyID: KeyID) {
const core = this.core;
const keyForEveryone = this.get(`${keyID}_for_everyone`);
if (keyForEveryone) {
return keyForEveryone;
}
// Try to find key revelation for us
const currentAgentOrAccountID = accountOrAgentIDfromSessionID(
core.node.currentSessionID,
);
// being careful here to avoid recursion
const lookupAccountOrAgentID = isAccountID(currentAgentOrAccountID)
? core.id === currentAgentOrAccountID
? core.node.crypto.getAgentID(core.node.agentSecret) // in accounts, the read key is revealed for the primitive agent
: currentAgentOrAccountID // current account ID
: currentAgentOrAccountID; // current agent ID
const lastReadyKeyEdit = this.lastEditAt(
`${keyID}_for_${lookupAccountOrAgentID}`,
);
if (lastReadyKeyEdit?.value) {
const revealer = lastReadyKeyEdit.by;
const revealerAgent = core.node
.resolveAccountAgent(revealer, "Expected to know revealer")
._unsafeUnwrap({ withStackTrace: true });
const secret = this.crypto.unseal(
lastReadyKeyEdit.value,
this.crypto.getAgentSealerSecret(core.node.agentSecret), // being careful here to avoid recursion
this.crypto.getAgentSealerID(revealerAgent),
{
in: this.id,
tx: lastReadyKeyEdit.tx,
},
);
if (secret) {
return secret as KeySecret;
}
}
// Try to find indirect revelation through previousKeys
for (const co of this.keys()) {
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
const encryptingKeyID = co.split("_for_")[1] as KeyID;
const encryptingKeySecret = this.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
const encryptedPreviousKey = this.get(co)!;
const secret = this.crypto.decryptKeySecret(
{
encryptedID: keyID,
encryptingID: encryptingKeyID,
encrypted: encryptedPreviousKey,
},
encryptingKeySecret,
);
if (secret) {
return secret as KeySecret;
} else {
logger.warn(
`Encrypting ${encryptingKeyID} key didn't decrypt ${keyID}`,
);
}
}
}
// try to find revelation to parent group read keys
for (const co of this.keys()) {
if (isParentGroupReference(co)) {
const parentGroupID = getParentGroupId(co);
const parentGroup = core.node.expectCoValueLoaded(
parentGroupID,
"Expected parent group to be loaded",
);
const parentKeys = this.findValidParentKeys(keyID, parentGroup);
for (const parentKey of parentKeys) {
const revelationForParentKey = this.get(
`${keyID}_for_${parentKey.id}`,
);
if (revelationForParentKey) {
const secret = parentGroup.node.crypto.decryptKeySecret(
{
encryptedID: keyID,
encryptingID: parentKey.id,
encrypted: revelationForParentKey,
},
parentKey.secret,
);
if (secret) {
return secret as KeySecret;
} else {
logger.warn(
`Encrypting parent ${parentKey.id} key didn't decrypt ${keyID}`,
);
}
}
}
}
}
return undefined;
}
findValidParentKeys(keyID: KeyID, parentGroup: CoValueCore) {
const validParentKeys: { id: KeyID; secret: KeySecret }[] = [];
for (const co of this.keys()) {
if (isKeyForKeyField(co) && co.startsWith(keyID)) {
const encryptingKeyID = co.split("_for_")[1] as KeyID;
const encryptingKeySecret = parentGroup.getReadKey(encryptingKeyID);
if (!encryptingKeySecret) {
continue;
}
validParentKeys.push({
id: encryptingKeyID,
secret: encryptingKeySecret,
});
}
}
return validParentKeys;
}
/** @internal */
rotateReadKey(removedMemberKey?: RawAccountID | AgentID | "everyone") {
if (removedMemberKey !== EVERYONE && canRead(this, EVERYONE)) {
// When everyone has access to the group, rotating the key is useless
// because it would be stored unencrypted and available to everyone
return;
}
const memberKeys = this.getMemberKeys().filter(
(key) => key !== removedMemberKey,
);
const currentlyPermittedReaders = memberKeys.filter((key) => {
const role = this.get(key);
return (
role === "admin" ||
role === "writer" ||
role === "reader" ||
role === "adminInvite" ||
role === "writerInvite" ||
role === "readerInvite"
);
});
const currentlyPermittedReaders = memberKeys.filter((key) =>
canRead(this, key),
);
const writeOnlyMembers = memberKeys.filter((key) => {
const role = this.get(key);
@@ -543,12 +730,12 @@ export class RawGroup<
// Get these early, so we fail fast if they are unavailable
const parentGroups = this.getParentGroups();
const childGroups = this.getChildGroups();
const maybeCurrentReadKey = this.core.getCurrentReadKey();
const maybeCurrentReadKey = this.getCurrentReadKey();
if (!maybeCurrentReadKey.secret) {
throw new Error("Can't rotate read key secret we don't have access to");
throw new NoReadKeyAccessError(
"Can't rotate read key secret we don't have access to",
);
}
const currentReadKey = {
@@ -631,7 +818,7 @@ export class RawGroup<
*/
for (const parent of parentGroups) {
const { id: parentReadKeyID, secret: parentReadKeySecret } =
parent.core.getCurrentReadKey();
parent.getCurrentReadKey();
if (!parentReadKeySecret) {
// We can't reveal the new child key to the parent group where we don't have access to the parent read key
@@ -655,33 +842,67 @@ export class RawGroup<
);
}
for (const child of childGroups) {
this.forEachChildGroup((child) => {
// Since child references are mantained only for the key rotation,
// circular references are skipped here because it's more performant
// than always checking for circular references in childs inside the permission checks
if (child.isSelfExtension(this)) {
continue;
return;
}
child.rotateReadKey(removedMemberKey);
}
try {
child.rotateReadKey(removedMemberKey);
} catch (error) {
if (error instanceof NoReadKeyAccessError) {
logger.warn(
`Can't rotate read key on child ${child.id} because we don't have access to the read key`,
);
} else {
throw error;
}
}
});
}
/** Detect circular references in group inheritance */
isSelfExtension(parent: RawGroup) {
if (parent.id === this.id) {
return true;
}
const checkedGroups = new Set<string>();
const queue = [parent];
const childGroups = this.getChildGroups();
while (true) {
const current = queue.pop();
for (const child of childGroups) {
if (child.isSelfExtension(parent)) {
if (!current) {
return false;
}
if (current.id === this.id) {
return true;
}
checkedGroups.add(current.id);
const parentGroups = current.getParentGroups();
for (const parent of parentGroups) {
if (!checkedGroups.has(parent.id)) {
queue.push(parent);
}
}
}
}
getCurrentReadKey() {
const keyId = this.getCurrentReadKeyId();
if (!keyId) {
throw new Error("No readKey set");
}
return false;
return {
secret: this.getReadKey(keyId),
id: keyId,
};
}
extend(
@@ -700,8 +921,8 @@ export class RawGroup<
const value = role === "inherit" ? "extend" : role;
this.set(`parent_${parent.id}`, value, "trusting");
parent.set(`child_${this.id}`, "extend", "trusting");
this.set(`parent_${parent.id}`, value, "trusting");
if (
parent.myRole() !== "admin" &&
@@ -716,14 +937,15 @@ export class RawGroup<
);
}
const { id: parentReadKeyID, secret: parentReadKeySecret } =
parent.core.getCurrentReadKey();
let { id: parentReadKeyID, secret: parentReadKeySecret } =
parent.getCurrentReadKey();
if (!parentReadKeySecret) {
throw new Error("Can't extend group without parent read key secret");
}
const { id: childReadKeyID, secret: childReadKeySecret } =
this.core.getCurrentReadKey();
this.getCurrentReadKey();
if (!childReadKeySecret) {
throw new Error("Can't extend group without child read key secret");
}
@@ -744,7 +966,7 @@ export class RawGroup<
);
}
async revokeExtend(parent: RawGroup) {
revokeExtend(parent: RawGroup) {
if (this.myRole() !== "admin") {
throw new Error(
"To unextend a group, the current account must be an admin in the child group",
@@ -775,8 +997,6 @@ export class RawGroup<
// Set the child key on the parent group to `revoked`
parent.set(`child_${this.id}`, "revoked", "trusting");
await this.loadAllChildGroups();
// Rotate the keys on the child group
this.rotateReadKey();
}
@@ -788,19 +1008,7 @@ export class RawGroup<
*
* @category 2. Role changing
*/
async removeMember(
account: RawAccount | ControlledAccountOrAgent | Everyone,
) {
// Ensure all child groups are loaded before removing a member
await this.loadAllChildGroups();
this.removeMemberInternal(account);
}
/** @internal */
removeMemberInternal(
account: RawAccount | ControlledAccountOrAgent | AgentID | Everyone,
) {
removeMember(account: RawAccount | ControlledAccountOrAgent | Everyone) {
const memberKey = typeof account === "string" ? account : account.id;
if (this.myRole() === "admin") {
@@ -1011,3 +1219,25 @@ export function secretSeedFromInviteSecret(inviteSecret: InviteSecret) {
return base58.decode(inviteSecret.slice("inviteSecret_z".length));
}
const canRead = (
group: RawGroup,
key: RawAccountID | AgentID | "everyone",
): boolean => {
const role = group.get(key);
return (
role === "admin" ||
role === "writer" ||
role === "reader" ||
role === "adminInvite" ||
role === "writerInvite" ||
role === "readerInvite"
);
};
class NoReadKeyAccessError extends Error {
constructor(message: string) {
super(message);
this.name = "NoReadKeyAccessError";
}
}

View File

@@ -63,7 +63,7 @@ import { DisconnectedError, SyncManager, emptyKnownState } from "./sync.js";
type Value = JsonValue | AnyRawCoValue;
export { PriorityBasedMessageQueue } from "./PriorityBasedMessageQueue.js";
export { PriorityBasedMessageQueue } from "./queue/PriorityBasedMessageQueue.js";
import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
import {
CO_VALUE_LOADING_CONFIG,

View File

@@ -1,8 +1,8 @@
import { base58 } from "@scure/base";
import { CoID } from "./coValue.js";
import { RawAccountID } from "./coValues/account.js";
import type { CoID } from "./coValue.js";
import type { RawAccountID } from "./coValues/account.js";
import type { RawGroup } from "./coValues/group.js";
import { shortHashLength } from "./crypto/crypto.js";
import { RawGroup } from "./exports.js";
export type RawCoID = `co_z${string}`;
export type ParentGroupReference = `parent_${CoID<RawGroup>}`;

View File

@@ -1,14 +1,14 @@
import { Result, err, ok } from "neverthrow";
import { CoID } from "./coValue.js";
import { RawCoValue } from "./coValue.js";
import type { CoID } from "./coValue.js";
import type { RawCoValue } from "./coValue.js";
import {
AvailableCoValueCore,
type AvailableCoValueCore,
CoValueCore,
idforHeader,
} from "./coValueCore/coValueCore.js";
import {
CoValueHeader,
CoValueUniqueness,
type CoValueHeader,
type CoValueUniqueness,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import {
@@ -26,8 +26,8 @@ import {
expectAccount,
} from "./coValues/account.js";
import {
InviteSecret,
RawGroup,
type InviteSecret,
type RawGroup,
secretSeedFromInviteSecret,
} from "./coValues/group.js";
import { CO_VALUE_LOADING_CONFIG } from "./config.js";
@@ -313,6 +313,15 @@ export class LocalNode {
throw new Error("Account has no profile");
}
const rootID = account.get("root");
if (rootID) {
const rawEntry = account.getRaw("root");
if (!rawEntry?.trusting) {
account.set("root", rootID, "trusting");
}
}
// Preload the profile
await node.load(profileID);
@@ -342,7 +351,7 @@ export class LocalNode {
new VerifiedState(id, this.crypto, header, new Map()),
);
void this.syncManager.requestCoValueSync(coValue);
this.syncManager.syncHeader(coValue.verified);
return coValue;
}
@@ -729,9 +738,14 @@ export class LocalNode {
}
}
gracefulShutdown() {
this.storage?.close();
/**
* Closes all the peer connections, drains all the queues and closes the storage.
*
* @returns Promise of the current pending store operation, if any.
*/
gracefulShutdown(): Promise<unknown> | undefined {
this.syncManager.gracefulShutdown();
return this.storage?.close();
}
}

View File

@@ -2,7 +2,7 @@ import { CoID } from "./coValue.js";
import { CoValueCore } from "./coValueCore/coValueCore.js";
import { Transaction } from "./coValueCore/verifiedState.js";
import { RawAccount, RawAccountID, RawProfile } from "./coValues/account.js";
import { MapOpPayload } from "./coValues/coMap.js";
import { MapOpPayload, RawCoMap } from "./coValues/coMap.js";
import {
EVERYONE,
Everyone,
@@ -270,6 +270,7 @@ function determineValidTransactionsForGroup(
| MapOpPayload<RawAccountID | AgentID | Everyone, Role>
| MapOpPayload<"readKey", JsonValue>
| MapOpPayload<"profile", CoID<RawProfile>>
| MapOpPayload<"root", CoID<RawCoMap>>
| MapOpPayload<`parent_${CoID<RawGroup>}`, CoID<RawGroup>>
| MapOpPayload<`child_${CoID<RawGroup>}`, CoID<RawGroup>>;
@@ -297,6 +298,14 @@ function determineValidTransactionsForGroup(
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (change.key === "root") {
if (memberState[transactor] !== "admin") {
logPermissionError("Only admins can set root");
continue;
}
validTransactions.push({ txID: { sessionID, txIndex }, tx });
continue;
} else if (

View File

@@ -1,9 +1,9 @@
import { Counter, ValueType, metrics } from "@opentelemetry/api";
import type { PeerState } from "./PeerState.js";
import { LinkedList } from "./PriorityBasedMessageQueue.js";
import { SYNC_SCHEDULER_CONFIG } from "./config.js";
import { logger } from "./logger.js";
import type { SyncMessage } from "./sync.js";
import type { PeerState } from "../PeerState.js";
import { SYNC_SCHEDULER_CONFIG } from "../config.js";
import { logger } from "../logger.js";
import type { SyncMessage } from "../sync.js";
import { LinkedList } from "./LinkedList.js";
/**
* A queue that schedules messages across different peers using a round-robin approach.

View File

@@ -1,6 +1,5 @@
import { Counter, ValueType, metrics } from "@opentelemetry/api";
import { CO_VALUE_PRIORITY, type CoValuePriority } from "./priority.js";
import type { SyncMessage } from "./sync.js";
import type { SyncMessage } from "../sync.js";
/**
* Since we have a fixed range of priority values (0-7) we can create a fixed array of queues.
@@ -10,18 +9,16 @@ type Tuple<T, N extends number, A extends unknown[] = []> = A extends {
}
? A
: Tuple<T, N, [...A, T]>;
type QueueTuple = Tuple<LinkedList<SyncMessage>, 3>;
export type QueueTuple = Tuple<LinkedList<SyncMessage>, 3>;
type LinkedListNode<T> = {
value: T;
next: LinkedListNode<T> | undefined;
};
/**
* Using a linked list to make the shift operation O(1) instead of O(n)
* as our queues can grow very large when the system is under pressure.
*/
export class LinkedList<T> {
constructor(private meter?: QueueMeter) {}
@@ -70,7 +67,6 @@ export class LinkedList<T> {
return this.head === undefined;
}
}
class QueueMeter {
private pullCounter: Counter;
private pushCounter: Counter;
@@ -111,52 +107,9 @@ class QueueMeter {
this.pushCounter.add(1, this.attrs);
}
}
function meteredList<T>(
export function meteredList<T>(
type: "incoming" | "outgoing",
attrs?: Record<string, string | number>,
) {
return new LinkedList<T>(new QueueMeter("jazz.messagequeue." + type, attrs));
}
const PRIORITY_TO_QUEUE_INDEX = {
[CO_VALUE_PRIORITY.HIGH]: 0,
[CO_VALUE_PRIORITY.MEDIUM]: 1,
[CO_VALUE_PRIORITY.LOW]: 2,
} as const;
export class PriorityBasedMessageQueue {
private queues: QueueTuple;
constructor(
private defaultPriority: CoValuePriority,
type: "incoming" | "outgoing",
/**
* Optional attributes to be added to the generated metrics.
* By default the metrics will have the priority as an attribute.
*/
attrs?: Record<string, string | number>,
) {
this.queues = [
meteredList(type, { priority: CO_VALUE_PRIORITY.HIGH, ...attrs }),
meteredList(type, { priority: CO_VALUE_PRIORITY.MEDIUM, ...attrs }),
meteredList(type, { priority: CO_VALUE_PRIORITY.LOW, ...attrs }),
];
}
private getQueue(priority: CoValuePriority) {
return this.queues[PRIORITY_TO_QUEUE_INDEX[priority]];
}
public push(msg: SyncMessage) {
const priority = "priority" in msg ? msg.priority : this.defaultPriority;
this.getQueue(priority).push(msg);
}
public pull() {
const priority = this.queues.findIndex((queue) => queue.length > 0);
return this.queues[priority]?.shift();
}
}

View File

@@ -0,0 +1,96 @@
import {
addTransactionToContentMessage,
createContentMessage,
} from "../coValueContentMessage.js";
import { Transaction, VerifiedState } from "../coValueCore/verifiedState.js";
import { Signature } from "../crypto/crypto.js";
import { SessionID } from "../ids.js";
import { NewContentMessage } from "../sync.js";
import { LinkedList } from "./LinkedList.js";
/**
* This queue is used to batch the sync of local transactions while preserving the order of updates between CoValues.
*
* We need to preserve the order of updates between CoValues to keep the state always consistent in case of shutdown in the middle of a sync.
*
* Examples:
* 1. When we extend a Group we need to always ensure that the parent group is persisted before persisting the extension transaction.
* 2. If we do multiple updates on the same CoMap, the updates will be batched because it's safe to do so.
*/
export class LocalTransactionsSyncQueue {
private readonly queue = new LinkedList<NewContentMessage>();
constructor(private readonly sync: (content: NewContentMessage) => void) {}
syncHeader = (coValue: VerifiedState) => {
const lastPendingSync = this.queue.tail?.value;
if (lastPendingSync?.id === coValue.id) {
return;
}
this.enqueue(createContentMessage(coValue.id, coValue.header));
};
syncTransaction = (
coValue: VerifiedState,
transaction: Transaction,
sessionID: SessionID,
signature: Signature,
txIdx: number,
) => {
const lastPendingSync = this.queue.tail?.value;
const lastSignatureIdx = coValue.getLastSignatureCheckpoint(sessionID);
const isSignatureCheckpoint =
lastSignatureIdx > -1 && lastSignatureIdx === txIdx - 1;
if (lastPendingSync?.id === coValue.id && !isSignatureCheckpoint) {
addTransactionToContentMessage(
lastPendingSync,
transaction,
sessionID,
signature,
txIdx,
);
return;
}
const content = createContentMessage(coValue.id, coValue.header, false);
addTransactionToContentMessage(
content,
transaction,
sessionID,
signature,
txIdx,
);
this.enqueue(content);
};
enqueue(content: NewContentMessage) {
this.queue.push(content);
this.processPendingSyncs();
}
private processingSyncs = false;
processPendingSyncs() {
if (this.processingSyncs) return;
this.processingSyncs = true;
queueMicrotask(() => {
while (this.queue.head) {
const content = this.queue.head.value;
this.sync(content);
this.queue.shift();
}
this.processingSyncs = false;
});
}
}

View File

@@ -0,0 +1,45 @@
import { CO_VALUE_PRIORITY, type CoValuePriority } from "../priority.js";
import type { SyncMessage } from "../sync.js";
import { QueueTuple, meteredList } from "./LinkedList.js";
const PRIORITY_TO_QUEUE_INDEX = {
[CO_VALUE_PRIORITY.HIGH]: 0,
[CO_VALUE_PRIORITY.MEDIUM]: 1,
[CO_VALUE_PRIORITY.LOW]: 2,
} as const;
export class PriorityBasedMessageQueue {
private queues: QueueTuple;
constructor(
private defaultPriority: CoValuePriority,
type: "incoming" | "outgoing",
/**
* Optional attributes to be added to the generated metrics.
* By default the metrics will have the priority as an attribute.
*/
attrs?: Record<string, string | number>,
) {
this.queues = [
meteredList(type, { priority: CO_VALUE_PRIORITY.HIGH, ...attrs }),
meteredList(type, { priority: CO_VALUE_PRIORITY.MEDIUM, ...attrs }),
meteredList(type, { priority: CO_VALUE_PRIORITY.LOW, ...attrs }),
];
}
private getQueue(priority: CoValuePriority) {
return this.queues[PRIORITY_TO_QUEUE_INDEX[priority]];
}
public push(msg: SyncMessage) {
const priority = "priority" in msg ? msg.priority : this.defaultPriority;
this.getQueue(priority).push(msg);
}
public pull() {
const priority = this.queues.findIndex((queue) => queue.length > 0);
return this.queues[priority]?.shift();
}
}

View File

@@ -1,19 +1,22 @@
import { LinkedList } from "../PriorityBasedMessageQueue.js";
import { CorrectionCallback } from "../exports.js";
import { logger } from "../logger.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
import { NewContentMessage } from "../sync.js";
import { LinkedList } from "./LinkedList.js";
type StoreQueueEntry = {
data: NewContentMessage[];
correctionCallback: (data: CoValueKnownState) => void;
data: NewContentMessage;
correctionCallback: CorrectionCallback;
};
export class StoreQueue {
private queue = new LinkedList<StoreQueueEntry>();
closed = false;
public push(data: NewContentMessage, correctionCallback: CorrectionCallback) {
if (this.closed) {
return;
}
public push(
data: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
) {
this.queue.push({ data, correctionCallback });
}
@@ -22,12 +25,13 @@ export class StoreQueue {
}
processing = false;
lastCallback: Promise<unknown> | undefined;
async processQueue(
callback: (
data: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
) => Promise<void>,
data: NewContentMessage,
correctionCallback: CorrectionCallback,
) => Promise<unknown>,
) {
if (this.processing) {
return;
@@ -41,16 +45,22 @@ export class StoreQueue {
const { data, correctionCallback } = entry;
try {
await callback(data, correctionCallback);
this.lastCallback = callback(data, correctionCallback);
await this.lastCallback;
} catch (err) {
logger.error("Error processing message in store queue", { err });
}
}
this.lastCallback = undefined;
this.processing = false;
}
drain() {
close() {
this.closed = true;
while (this.pull()) {}
return this.lastCallback;
}
}

View File

@@ -1,21 +1,29 @@
import { LinkedList } from "../PriorityBasedMessageQueue.js";
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
type CoValueCore,
MAX_RECOMMENDED_TX_SIZE,
type RawCoID,
type SessionID,
type StorageAPI,
logger,
} from "../exports.js";
import { getPriorityFromHeader } from "../priority.js";
import { StoreQueue } from "../queue/StoreQueue.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { StoreQueue } from "./StoreQueue.js";
import { StorageKnownState } from "./knownState.js";
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
import {
collectNewTxs,
getDependedOnCoValues,
getNewTransactionsSize,
} from "./syncUtils.js";
import type {
CorrectionCallback,
DBClientInterfaceAsync,
SignatureAfterRow,
StoredCoValueRow,
@@ -83,6 +91,7 @@ export class StorageApiAsync implements StorageAPI {
);
const knownState = this.knwonStates.getKnownState(coValueRow.id);
knownState.header = true;
for (const sessionRow of allCoValueSessions) {
knownState.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
@@ -90,13 +99,7 @@ export class StorageApiAsync implements StorageAPI {
this.loadedCoValues.add(coValueRow.id);
let contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} as NewContentMessage;
let contentMessage = createContentMessage(coValueRow.id, coValueRow.header);
if (contentStreaming) {
contentMessage.expectContentUntil = knownState["sessions"];
@@ -137,13 +140,10 @@ export class StorageApiAsync implements StorageAPI {
contentMessage,
callback,
);
contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} satisfies NewContentMessage;
contentMessage = createContentMessage(
coValueRow.id,
coValueRow.header,
);
}
}
}
@@ -195,33 +195,64 @@ export class StorageApiAsync implements StorageAPI {
storeQueue = new StoreQueue();
async store(
msgs: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
) {
async store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
/**
* The store operations must be done one by one, because we can't start a new transaction when there
* is already a transaction open.
*/
this.storeQueue.push(msgs, correctionCallback);
this.storeQueue.push(msg, correctionCallback);
this.storeQueue.processQueue(async (data, correctionCallback) => {
for (const msg of data) {
const success = await this.storeSingle(msg, correctionCallback);
if (!success) {
// Stop processing the messages for this entry, because the data is out of sync with storage
// and the other transactions will be rejected anyway.
break;
}
}
return this.storeSingle(data, correctionCallback);
});
}
/**
* This function is called when the storage lacks the information required to store the incoming content.
*
* It triggers a `correctionCallback` to ask the syncManager to provide the missing information.
*
* The correction is applied immediately, to ensure that, when applicable, the dependent content in the queue won't require additional corrections.
*/
private async handleCorrection(
knownState: CoValueKnownState,
correctionCallback: CorrectionCallback,
) {
const correction = correctionCallback(knownState);
if (!correction) {
logger.error("Correction callback returned undefined", {
knownState,
correction: correction ?? null,
});
return false;
}
for (const msg of correction) {
const success = await this.storeSingle(msg, (knownState) => {
logger.error("Double correction requested", {
msg,
knownState,
});
return undefined;
});
if (!success) {
return false;
}
}
return true;
}
private async storeSingle(
msg: NewContentMessage,
correctionCallback: (data: CoValueKnownState) => void,
correctionCallback: CorrectionCallback,
): Promise<boolean> {
if (this.storeQueue.closed) {
return false;
}
const id = msg.id;
const coValueRow = await this.dbClient.getCoValue(id);
@@ -232,8 +263,7 @@ export class StorageApiAsync implements StorageAPI {
const knownState = emptyKnownState(id as RawCoID);
this.knwonStates.setKnownState(id, knownState);
correctionCallback(knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
const storedCoValueRowID: number = coValueRow
@@ -277,8 +307,7 @@ export class StorageApiAsync implements StorageAPI {
this.knwonStates.handleUpdate(id, knownState);
if (invalidAssumptions) {
correctionCallback(knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
return true;
@@ -291,38 +320,31 @@ export class StorageApiAsync implements StorageAPI {
storedCoValueRowID: number,
) {
const newTransactions = msg.new[sessionID]?.newTransactions || [];
const lastIdx = sessionRow?.lastIdx || 0;
const actuallyNewOffset =
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
const actuallyNewOffset = lastIdx - (msg.new[sessionID]?.after || 0);
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
if (actuallyNewTransactions.length === 0) {
return sessionRow?.lastIdx || 0;
return lastIdx;
}
let newBytesSinceLastSignature =
(sessionRow?.bytesSinceLastSignature || 0) +
actuallyNewTransactions.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
let bytesSinceLastSignature = sessionRow?.bytesSinceLastSignature || 0;
const newTransactionsSize = getNewTransactionsSize(actuallyNewTransactions);
const newLastIdx =
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
const newLastIdx = lastIdx + actuallyNewTransactions.length;
let shouldWriteSignature = false;
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(bytesSinceLastSignature, newTransactionsSize)) {
shouldWriteSignature = true;
newBytesSinceLastSignature = 0;
bytesSinceLastSignature = 0;
} else {
bytesSinceLastSignature += newTransactionsSize;
}
const nextIdx = sessionRow?.lastIdx || 0;
const nextIdx = lastIdx;
if (!msg.new[sessionID]) throw new Error("Session ID not found");
@@ -331,7 +353,7 @@ export class StorageApiAsync implements StorageAPI {
sessionID,
lastIdx: newLastIdx,
lastSignature: msg.new[sessionID].lastSignature,
bytesSinceLastSignature: newBytesSinceLastSignature,
bytesSinceLastSignature,
};
const sessionRowID: number = await this.dbClient.addSessionUpdate({
@@ -361,7 +383,6 @@ export class StorageApiAsync implements StorageAPI {
}
close() {
// Drain the store queue
this.storeQueue.drain();
return this.storeQueue.close();
}
}

View File

@@ -1,20 +1,29 @@
import { UpDownCounter, metrics } from "@opentelemetry/api";
import {
createContentMessage,
exceedsRecommendedSize,
getTransactionSize,
} from "../coValueContentMessage.js";
import {
CoValueCore,
MAX_RECOMMENDED_TX_SIZE,
RawCoID,
type SessionID,
type StorageAPI,
logger,
} from "../exports.js";
import { getPriorityFromHeader } from "../priority.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { StorageKnownState } from "./knownState.js";
import { collectNewTxs, getDependedOnCoValues } from "./syncUtils.js";
import {
collectNewTxs,
getDependedOnCoValues,
getNewTransactionsSize,
} from "./syncUtils.js";
import type {
CorrectionCallback,
DBClientInterfaceSync,
SignatureAfterRow,
StoredCoValueRow,
@@ -84,6 +93,7 @@ export class StorageApiSync implements StorageAPI {
}
const knownState = this.knwonStates.getKnownState(coValueRow.id);
knownState.header = true;
for (const sessionRow of allCoValueSessions) {
knownState.sessions[sessionRow.sessionID] = sessionRow.lastIdx;
@@ -91,13 +101,7 @@ export class StorageApiSync implements StorageAPI {
this.loadedCoValues.add(coValueRow.id);
let contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} as NewContentMessage;
let contentMessage = createContentMessage(coValueRow.id, coValueRow.header);
if (contentStreaming) {
this.streamingCounter.add(1);
@@ -137,13 +141,10 @@ export class StorageApiSync implements StorageAPI {
contentMessage,
callback,
);
contentMessage = {
action: "content",
id: coValueRow.id,
header: coValueRow.header,
new: {},
priority: getPriorityFromHeader(coValueRow.header),
} satisfies NewContentMessage;
contentMessage = createContentMessage(
coValueRow.id,
coValueRow.header,
);
// Introduce a delay to not block the main thread
// for the entire content processing
@@ -189,22 +190,49 @@ export class StorageApiSync implements StorageAPI {
pushCallback(contentMessage);
}
store(
msgs: NewContentMessage[],
correctionCallback: (data: CoValueKnownState) => void,
store(msg: NewContentMessage, correctionCallback: CorrectionCallback) {
return this.storeSingle(msg, correctionCallback);
}
/**
* This function is called when the storage lacks the information required to store the incoming content.
*
* It triggers a `correctionCallback` to ask the syncManager to provide the missing information.
*/
private handleCorrection(
knownState: CoValueKnownState,
correctionCallback: CorrectionCallback,
) {
for (const msg of msgs) {
const success = this.storeSingle(msg, correctionCallback);
const correction = correctionCallback(knownState);
if (!correction) {
logger.error("Correction callback returned undefined", {
knownState,
correction: correction ?? null,
});
return false;
}
for (const msg of correction) {
const success = this.storeSingle(msg, (knownState) => {
logger.error("Double correction requested", {
msg,
knownState,
});
return undefined;
});
if (!success) {
return false;
}
}
return true;
}
private storeSingle(
msg: NewContentMessage,
correctionCallback: (data: CoValueKnownState) => void,
correctionCallback: CorrectionCallback,
): boolean {
const id = msg.id;
const coValueRow = this.dbClient.getCoValue(id);
@@ -214,11 +242,9 @@ export class StorageApiSync implements StorageAPI {
if (invalidAssumptionOnHeaderPresence) {
const knownState = emptyKnownState(id as RawCoID);
correctionCallback(knownState);
this.knwonStates.setKnownState(id, knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
const storedCoValueRowID: number = coValueRow
@@ -258,8 +284,7 @@ export class StorageApiSync implements StorageAPI {
this.knwonStates.handleUpdate(id, knownState);
if (invalidAssumptions) {
correctionCallback(knownState);
return false;
return this.handleCorrection(knownState, correctionCallback);
}
return true;
@@ -272,35 +297,29 @@ export class StorageApiSync implements StorageAPI {
storedCoValueRowID: number,
) {
const newTransactions = msg.new[sessionID]?.newTransactions || [];
const lastIdx = sessionRow?.lastIdx || 0;
const actuallyNewOffset =
(sessionRow?.lastIdx || 0) - (msg.new[sessionID]?.after || 0);
const actuallyNewOffset = lastIdx - (msg.new[sessionID]?.after || 0);
const actuallyNewTransactions = newTransactions.slice(actuallyNewOffset);
if (actuallyNewTransactions.length === 0) {
return sessionRow?.lastIdx || 0;
return lastIdx;
}
let newBytesSinceLastSignature =
(sessionRow?.bytesSinceLastSignature || 0) +
actuallyNewTransactions.reduce(
(sum, tx) =>
sum +
(tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length),
0,
);
let bytesSinceLastSignature = sessionRow?.bytesSinceLastSignature || 0;
const newTransactionsSize = getNewTransactionsSize(actuallyNewTransactions);
const newLastIdx =
(sessionRow?.lastIdx || 0) + actuallyNewTransactions.length;
let shouldWriteSignature = false;
if (newBytesSinceLastSignature > MAX_RECOMMENDED_TX_SIZE) {
if (exceedsRecommendedSize(bytesSinceLastSignature, newTransactionsSize)) {
shouldWriteSignature = true;
newBytesSinceLastSignature = 0;
bytesSinceLastSignature = 0;
} else {
bytesSinceLastSignature += newTransactionsSize;
}
const nextIdx = sessionRow?.lastIdx || 0;
@@ -312,7 +331,7 @@ export class StorageApiSync implements StorageAPI {
sessionID,
lastIdx: newLastIdx,
lastSignature: msg.new[sessionID].lastSignature,
bytesSinceLastSignature: newBytesSinceLastSignature,
bytesSinceLastSignature,
};
const sessionRowID: number = this.dbClient.addSessionUpdate({
@@ -339,5 +358,7 @@ export class StorageApiSync implements StorageAPI {
return this.knwonStates.waitForSync(id, coValue);
}
close() {}
close() {
return undefined;
}
}

View File

@@ -1,5 +1,9 @@
import { getTransactionSize } from "../coValueContentMessage.js";
import { getDependedOnCoValuesFromRawData } from "../coValueCore/utils.js";
import type { CoValueHeader } from "../coValueCore/verifiedState.js";
import type {
CoValueHeader,
Transaction,
} from "../coValueCore/verifiedState.js";
import type { Signature } from "../crypto/crypto.js";
import type { SessionID } from "../exports.js";
import type { NewContentMessage } from "../sync.js";
@@ -48,3 +52,7 @@ export function getDependedOnCoValues(
return getDependedOnCoValuesFromRawData(id, header, sessionIDs, transactions);
}
export function getNewTransactionsSize(newTxs: Transaction[]) {
return newTxs.reduce((sum, tx) => sum + getTransactionSize(tx), 0);
}

View File

@@ -6,6 +6,10 @@ import { Signature } from "../crypto/crypto.js";
import type { CoValueCore, RawCoID, SessionID } from "../exports.js";
import { CoValueKnownState, NewContentMessage } from "../sync.js";
export type CorrectionCallback = (
correction: CoValueKnownState,
) => NewContentMessage[] | undefined;
/**
* The StorageAPI is the interface that the StorageSync and StorageAsync classes implement.
*
@@ -18,16 +22,13 @@ export interface StorageAPI {
callback: (data: NewContentMessage) => void,
done?: (found: boolean) => void,
): void;
store(
data: NewContentMessage[] | undefined,
handleCorrection: (correction: CoValueKnownState) => void,
): void;
store(data: NewContentMessage, handleCorrection: CorrectionCallback): void;
getKnownState(id: string): CoValueKnownState;
waitForSync(id: string, coValue: CoValueCore): Promise<void>;
close(): void;
close(): Promise<unknown> | undefined;
}
export type CoValueRow = {

View File

@@ -1,15 +1,24 @@
import { Histogram, ValueType, metrics } from "@opentelemetry/api";
import { IncomingMessagesQueue } from "./IncomingMessagesQueue.js";
import { PeerState } from "./PeerState.js";
import { SyncStateManager } from "./SyncStateManager.js";
import {
getTransactionSize,
knownStateFromContent,
} from "./coValueContentMessage.js";
import { CoValueCore } from "./coValueCore/coValueCore.js";
import { getDependedOnCoValuesFromRawData } from "./coValueCore/utils.js";
import { CoValueHeader, Transaction } from "./coValueCore/verifiedState.js";
import {
CoValueHeader,
Transaction,
VerifiedState,
} from "./coValueCore/verifiedState.js";
import { Signature } from "./crypto/crypto.js";
import { RawCoID, SessionID } from "./ids.js";
import { RawCoID, SessionID, isRawCoID } from "./ids.js";
import { LocalNode } from "./localNode.js";
import { logger } from "./logger.js";
import { CoValuePriority } from "./priority.js";
import { IncomingMessagesQueue } from "./queue/IncomingMessagesQueue.js";
import { LocalTransactionsSyncQueue } from "./queue/LocalTransactionsSyncQueue.js";
import { accountOrAgentIDfromSessionID } from "./typeUtils/accountOrAgentIDfromSessionID.js";
import { isAccountID } from "./typeUtils/isAccountID.js";
@@ -57,10 +66,12 @@ export type NewContentMessage = {
};
export type SessionNewContent = {
// The index where to start appending the new transactions. The index counting starts from 1.
after: number;
newTransactions: Transaction[];
lastSignature: Signature;
};
export type DoneMessage = {
action: "done";
id: RawCoID;
@@ -162,13 +173,9 @@ export class SyncManager {
}
handleSyncMessage(msg: SyncMessage, peer: PeerState) {
if (msg.id === undefined || msg.id === null) {
logger.warn("Received sync message with undefined id", {
msg,
});
return;
} else if (!msg.id.startsWith("co_z")) {
logger.warn("Received sync message with invalid id", {
if (!isRawCoID(msg.id)) {
const errorType = msg.id ? "invalid" : "undefined";
logger.warn(`Received sync message with ${errorType} id`, {
msg,
});
return;
@@ -431,12 +438,9 @@ export class SyncManager {
recordTransactionsSize(newTransactions: Transaction[], source: string) {
for (const tx of newTransactions) {
const txLength =
tx.privacy === "private"
? tx.encryptedChanges.length
: tx.changes.length;
const size = getTransactionSize(tx);
this.transactionsSizeHistogram.record(txLength, {
this.transactionsSizeHistogram.record(size, {
source,
});
}
@@ -674,7 +678,7 @@ export class SyncManager {
const syncedPeers = [];
if (from !== "storage") {
this.storeCoValue(coValue, [msg]);
this.storeContent(msg);
}
for (const peer of this.peersInPriorityOrder()) {
@@ -736,60 +740,18 @@ export class SyncManager {
};
}
requestedSyncs = new Set<RawCoID>();
requestCoValueSync(coValue: CoValueCore) {
if (this.requestedSyncs.has(coValue.id)) {
return;
}
private syncQueue = new LocalTransactionsSyncQueue((content) =>
this.syncContent(content),
);
syncHeader = this.syncQueue.syncHeader;
syncLocalTransaction = this.syncQueue.syncTransaction;
for (const trackingSet of this.dirtyCoValuesTrackingSets) {
trackingSet.add(coValue.id);
}
syncContent(content: NewContentMessage) {
const coValue = this.local.getCoValue(content.id);
queueMicrotask(() => {
if (this.requestedSyncs.has(coValue.id)) {
this.syncCoValue(coValue);
}
});
this.storeContent(content);
this.requestedSyncs.add(coValue.id);
}
storeCoValue(coValue: CoValueCore, data: NewContentMessage[] | undefined) {
const storage = this.local.storage;
if (!storage || !data) return;
// Try to store the content as-is for performance
// In case that some transactions are missing, a correction will be requested, but it's an edge case
storage.store(data, (correction) => {
if (!coValue.hasVerifiedContent()) return;
const newContentPieces = coValue.verified.newContentSince(correction);
if (!newContentPieces) return;
storage.store(newContentPieces, (response) => {
logger.error(
"Correction requested by storage after sending a correction content",
{
response,
knownState: coValue.knownState(),
},
);
});
});
}
syncCoValue(coValue: CoValueCore) {
this.requestedSyncs.delete(coValue.id);
if (this.local.storage && coValue.hasVerifiedContent()) {
const knownState = this.local.storage.getKnownState(coValue.id);
const newContentPieces = coValue.verified.newContentSince(knownState);
this.storeCoValue(coValue, newContentPieces);
}
const contentKnownState = knownStateFromContent(content);
for (const peer of this.peersInPriorityOrder()) {
if (peer.closed) continue;
@@ -803,7 +765,11 @@ export class SyncManager {
continue;
}
this.sendNewContentIncludingDependencies(coValue.id, peer);
// We assume that the peer already knows anything before this content
// Any eventual reconciliation will be handled through the known state messages exchange
this.trySendToPeer(peer, content);
peer.combineOptimisticWith(coValue.id, contentKnownState);
peer.trackToldKnownState(coValue.id);
}
for (const peer of this.getPeers()) {
@@ -811,6 +777,20 @@ export class SyncManager {
}
}
private storeContent(content: NewContentMessage) {
const storage = this.local.storage;
if (!storage) return;
// Try to store the content as-is for performance
// In case that some transactions are missing, a correction will be requested, but it's an edge case
storage.store(content, (correction) => {
return this.local
.getCoValue(content.id)
.verified?.newContentSince(correction);
});
}
waitForSyncWithPeer(peerId: PeerID, id: RawCoID, timeout: number) {
const { syncState } = this;
const currentSyncState = syncState.getCurrentSyncState(peerId, id);

View File

@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { IncomingMessagesQueue } from "../IncomingMessagesQueue.js";
import { PeerState } from "../PeerState.js";
import { IncomingMessagesQueue } from "../queue/IncomingMessagesQueue.js";
import { ConnectedPeerChannel } from "../streamUtils.js";
import { Peer, SyncMessage } from "../sync.js";
import {

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "vitest";
import { LinkedList } from "../PriorityBasedMessageQueue";
import { LinkedList } from "../queue/LinkedList.js";
describe("LinkedList", () => {
let list: LinkedList<number>;

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from "vitest";
import { PriorityBasedMessageQueue } from "../PriorityBasedMessageQueue.js";
import { CO_VALUE_PRIORITY } from "../priority.js";
import { PriorityBasedMessageQueue } from "../queue/PriorityBasedMessageQueue.js";
import type { SyncMessage } from "../sync.js";
import {
createTestMetricReader,

View File

@@ -0,0 +1,829 @@
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { CoID, LocalNode, RawCoID, RawCoMap, logger } from "../exports.js";
import { CoValueCore } from "../exports.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { createAsyncStorage } from "./testStorage.js";
import {
SyncMessagesLog,
loadCoValueOrFail,
randomAgentAndSessionID,
waitFor,
} from "./testUtils.js";
const crypto = await WasmCrypto.create();
/**
* Helper function that gets new content since a known state, throwing if:
* - The coValue is not verified
* - There is no new content
*/
function getNewContentSince(
coValue: CoValueCore,
knownState: CoValueKnownState,
): NewContentMessage {
if (!coValue.verified) {
throw new Error(`CoValue ${coValue.id} is not verified`);
}
const contentMessage = coValue.verified.newContentSince(knownState)?.[0];
if (!contentMessage) {
throw new Error(`No new content available for coValue ${coValue.id}`);
}
return contentMessage;
}
async function createFixturesNode(customDbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
// Create a unique database file for each test
const dbPath = customDbPath ?? join(tmpdir(), `test-${randomUUID()}.db`);
const storage = await createAsyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
onTestFinished(() => {
try {
unlinkSync(dbPath);
} catch {}
});
onTestFinished(async () => {
await node.gracefulShutdown();
});
node.setStorage(storage);
return {
fixturesNode: node,
dbPath,
};
}
async function createTestNode(dbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
const storage = await createAsyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
onTestFinished(async () => {
node.gracefulShutdown();
await storage.close();
});
return {
node,
storage,
};
}
afterEach(() => {
SyncMessagesLog.clear();
});
describe("StorageApiAsync", () => {
describe("getKnownState", () => {
test("should return known state for existing coValue ID", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const id = fixturesNode.createGroup().id;
const knownState = storage.getKnownState(id);
expect(knownState).toEqual(emptyKnownState(id));
expect(storage.getKnownState(id)).toBe(knownState); // Should return same instance
});
test("should return different known states for different coValue IDs", async () => {
const { storage } = await createTestNode();
const id1 = "test-id-1";
const id2 = "test-id-2";
const knownState1 = storage.getKnownState(id1);
const knownState2 = storage.getKnownState(id2);
expect(knownState1).not.toBe(knownState2);
});
});
describe("load", () => {
test("should handle non-existent coValue gracefully", async () => {
const { storage } = await createTestNode();
const id = "non-existent-id";
const callback = vi.fn();
const done = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(id);
expect(initialKnownState).toEqual(emptyKnownState(id as `co_z${string}`));
await storage.load(id, callback, done);
expect(done).toHaveBeenCalledWith(false);
expect(callback).not.toHaveBeenCalled();
// Verify that storage known state is NOT updated when load fails
const afterLoadKnownState = storage.getKnownState(id);
expect(afterLoadKnownState).toEqual(initialKnownState);
});
test("should load coValue with header only successfully", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.any(Object),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should load coValue with sessions and transactions successfully", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.objectContaining({
[fixturesNode.currentSessionID]: expect.any(Object),
}),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("store", () => {
test("should store new coValue with header successfully", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should store coValue with transactions successfully", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle invalid assumption on header presence with correction", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(emptyKnownState(group.id));
return group.core.verified.newContentSince(known);
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
expect(correctionCallback).toHaveBeenCalledTimes(1);
// Verify that storage known state is updated after store with correction
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle invalid assumption on new content with correction", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const initialContent = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const initialKnownState = group.core.knownState();
group.addMember("everyone", "reader");
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(initialKnownState);
return group.core.verified.newContentSince(known);
});
// Get initial storage known state
const initialStorageKnownState = storage.getKnownState(group.id);
expect(initialStorageKnownState).toEqual(emptyKnownState(group.id));
await storage.store(initialContent, correctionCallback);
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(group.id, group.core);
expect(correctionCallback).toHaveBeenCalledTimes(1);
// Verify that storage known state is updated after store with correction
const finalKnownState = storage.getKnownState(group.id);
expect(finalKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("writer");
});
test("should log an error when the correction callback returns undefined", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
return undefined;
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
await storage.store(contentMessage, correctionCallback);
await waitFor(() => {
expect(correctionCallback).toHaveBeenCalledTimes(1);
});
// Verify that storage known state is NOT updated when store fails
const afterStoreKnownState = storage.getKnownState(group.id);
expect(afterStoreKnownState).toEqual(initialKnownState);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
errorSpy.mockClear();
});
test("should log an error when the correction callback returns an invalid content message", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn(() => {
return [contentMessage];
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
await storage.store(contentMessage, correctionCallback);
await waitFor(() => {
expect(correctionCallback).toHaveBeenCalledTimes(1);
});
// Verify that storage known state is NOT updated when store fails
const afterStoreKnownState = storage.getKnownState(group.id);
expect(afterStoreKnownState).toEqual(initialKnownState);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
expect(errorSpy).toHaveBeenCalledWith("Double correction requested", {
knownState: expect.any(Object),
msg: expect.any(Object),
});
errorSpy.mockClear();
});
test("should handle invalid assumption when pushing multiple transactions with correction", async () => {
const { node, storage } = await createTestNode();
const core = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
core.makeTransaction([{ count: 1 }], "trusting");
await core.waitForSync();
// Add storage later
node.setStorage(storage);
core.makeTransaction([{ count: 2 }], "trusting");
core.makeTransaction([{ count: 3 }], "trusting");
await new Promise((resolve) => setTimeout(resolve, 10));
core.makeTransaction([{ count: 4 }], "trusting");
core.makeTransaction([{ count: 5 }], "trusting");
await core.waitForSync();
expect(storage.getKnownState(core.id)).toEqual(core.knownState());
expect(
SyncMessagesLog.getMessages({
Core: core,
}),
).toMatchInlineSnapshot(`
[
"test -> test-storage | CONTENT Core header: false new: After: 1 New: 2",
"test-storage -> test | KNOWN CORRECTION Core sessions: empty",
"test -> test-storage | CONTENT Core header: true new: After: 0 New: 3",
"test -> test-storage | CONTENT Core header: false new: After: 3 New: 2",
]
`);
});
test("should handle invalid assumption when pushing multiple transactions on different coValues with correction", async () => {
const { node, storage } = await createTestNode();
const core = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
const core2 = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
core.makeTransaction([{ count: 1 }], "trusting");
core2.makeTransaction([{ count: 1 }], "trusting");
await core.waitForSync();
// Add storage later
node.setStorage(storage);
core.makeTransaction([{ count: 2 }], "trusting");
core2.makeTransaction([{ count: 2 }], "trusting");
core.makeTransaction([{ count: 3 }], "trusting");
core2.makeTransaction([{ count: 3 }], "trusting");
await new Promise((resolve) => setTimeout(resolve, 10));
core.makeTransaction([{ count: 4 }], "trusting");
core2.makeTransaction([{ count: 4 }], "trusting");
core.makeTransaction([{ count: 5 }], "trusting");
core2.makeTransaction([{ count: 5 }], "trusting");
await core.waitForSync();
expect(storage.getKnownState(core.id)).toEqual(core.knownState());
expect(
SyncMessagesLog.getMessages({
Core: core,
Core2: core2,
}),
).toMatchInlineSnapshot(`
[
"test -> test-storage | CONTENT Core header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 2 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 2 New: 1",
"test-storage -> test | KNOWN CORRECTION Core sessions: empty",
"test -> test-storage | CONTENT Core header: true new: After: 0 New: 3",
"test-storage -> test | KNOWN CORRECTION Core2 sessions: empty",
"test -> test-storage | CONTENT Core2 header: true new: After: 0 New: 3",
"test -> test-storage | CONTENT Core header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 4 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 4 New: 1",
]
`);
});
test("should handle close while pushing multiple transactions on different coValues with an invalid assumption", async () => {
const { node, storage } = await createTestNode();
const core = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
const core2 = node.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
core.makeTransaction([{ count: 1 }], "trusting");
core2.makeTransaction([{ count: 1 }], "trusting");
await core.waitForSync();
// Add storage later
node.setStorage(storage);
core.makeTransaction([{ count: 2 }], "trusting");
core2.makeTransaction([{ count: 2 }], "trusting");
core.makeTransaction([{ count: 3 }], "trusting");
core2.makeTransaction([{ count: 3 }], "trusting");
await new Promise<void>(queueMicrotask);
await storage.close();
const knownState = JSON.parse(
JSON.stringify(storage.getKnownState(core.id)),
);
core.makeTransaction([{ count: 4 }], "trusting");
core2.makeTransaction([{ count: 4 }], "trusting");
core.makeTransaction([{ count: 5 }], "trusting");
core2.makeTransaction([{ count: 5 }], "trusting");
await new Promise<void>((resolve) => setTimeout(resolve, 10));
expect(
SyncMessagesLog.getMessages({
Core: core,
Core2: core2,
}),
).toMatchInlineSnapshot(`
[
"test -> test-storage | CONTENT Core header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 1 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 2 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 2 New: 1",
"test-storage -> test | KNOWN CORRECTION Core sessions: empty",
"test -> test-storage | CONTENT Core header: true new: After: 0 New: 3",
"test -> test-storage | CONTENT Core header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 3 New: 1",
"test -> test-storage | CONTENT Core header: false new: After: 4 New: 1",
"test -> test-storage | CONTENT Core2 header: false new: After: 4 New: 1",
]
`);
expect(storage.getKnownState(core.id)).toEqual(knownState);
});
test("should handle multiple sessions correctly", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { fixturesNode: fixtureNode2 } = await createFixturesNode(dbPath);
const { node, storage } = await createTestNode();
const coValue = fixturesNode.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
coValue.makeTransaction(
[
{
count: 1,
},
],
"trusting",
);
await coValue.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(
fixtureNode2,
coValue.id as CoID<RawCoMap>,
);
coValue.makeTransaction(
[
{
count: 2,
},
],
"trusting",
);
const knownState = mapOnNode2.core.knownState();
const contentMessage = getNewContentSince(
mapOnNode2.core,
emptyKnownState(mapOnNode2.id),
);
const correctionCallback = vi.fn();
await storage.store(contentMessage, correctionCallback);
await storage.waitForSync(mapOnNode2.id, mapOnNode2.core);
node.setStorage(storage);
const finalMap = await loadCoValueOrFail(node, mapOnNode2.id);
expect(finalMap.core.knownState()).toEqual(knownState);
});
});
describe("dependencies", () => {
test("should push dependencies before the coValue", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group to create dependencies
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// Load the map, which should also load the group dependency first
await storage.load(map.id, callback, done);
expect(callback).toHaveBeenCalledTimes(2); // Group first, then map
expect(callback).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
id: group.id,
}),
);
expect(callback).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known states are updated after load
const updatedGroupKnownState = storage.getKnownState(group.id);
const updatedMapKnownState = storage.getKnownState(map.id);
expect(updatedGroupKnownState).toEqual(group.core.verified.knownState());
expect(updatedMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
test("should handle dependencies that are already loaded correctly", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// First load the group
await storage.load(group.id, callback, done);
callback.mockClear();
done.mockClear();
// Verify group known state is updated after first load
const afterGroupLoad = storage.getKnownState(group.id);
expect(afterGroupLoad).toEqual(group.core.verified.knownState());
// Then load the map - the group dependency should already be loaded
await storage.load(map.id, callback, done);
// Should only call callback once for the map since group is already loaded
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify map known state is updated after second load
const finalMapKnownState = storage.getKnownState(map.id);
expect(finalMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
});
describe("waitForSync", () => {
test("should resolve when the coValue is already synced", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and add a member
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Store the group in storage
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
await storage.store(contentMessage, correctionCallback);
node.setStorage(storage);
// Load the group on the new node
const groupOnNode = await loadCoValueOrFail(node, group.id);
// Wait for sync should resolve immediately since the coValue is already synced
await expect(
storage.waitForSync(group.id, groupOnNode.core),
).resolves.toBeUndefined();
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("close", () => {
test("should close without throwing an error", async () => {
const { storage } = await createTestNode();
expect(() => storage.close()).not.toThrow();
});
});
});

View File

@@ -0,0 +1,628 @@
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, onTestFinished, test, vi } from "vitest";
import { WasmCrypto } from "../crypto/WasmCrypto.js";
import { CoID, LocalNode, RawCoID, RawCoMap, logger } from "../exports.js";
import { CoValueCore } from "../exports.js";
import {
CoValueKnownState,
NewContentMessage,
emptyKnownState,
} from "../sync.js";
import { createSyncStorage } from "./testStorage.js";
import { loadCoValueOrFail, randomAgentAndSessionID } from "./testUtils.js";
const crypto = await WasmCrypto.create();
/**
* Helper function that gets new content since a known state, throwing if:
* - The coValue is not verified
* - There is no new content
*/
function getNewContentSince(
coValue: CoValueCore,
knownState: CoValueKnownState,
): NewContentMessage {
if (!coValue.verified) {
throw new Error(`CoValue ${coValue.id} is not verified`);
}
const contentMessage = coValue.verified.newContentSince(knownState)?.[0];
if (!contentMessage) {
throw new Error(`No new content available for coValue ${coValue.id}`);
}
return contentMessage;
}
async function createFixturesNode(customDbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
// Create a unique database file for each test
const dbPath = customDbPath ?? join(tmpdir(), `test-${randomUUID()}.db`);
const storage = createSyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
onTestFinished(() => {
try {
unlinkSync(dbPath);
} catch {}
});
node.setStorage(storage);
return {
fixturesNode: node,
dbPath,
};
}
async function createTestNode(dbPath?: string) {
const [admin, session] = randomAgentAndSessionID();
const node = new LocalNode(admin.agentSecret, session, crypto);
const storage = createSyncStorage({
filename: dbPath,
nodeName: "test",
storageName: "test-storage",
});
return {
node,
storage,
};
}
describe("StorageApiSync", () => {
describe("getKnownState", () => {
test("should return empty known state for new coValue ID and cache the result", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const id = fixturesNode.createGroup().id;
const knownState = storage.getKnownState(id);
expect(knownState).toEqual(emptyKnownState(id));
expect(storage.getKnownState(id)).toBe(knownState); // Should return same instance
});
test("should return separate known state instances for different coValue IDs", async () => {
const { storage } = await createTestNode();
const id1 = "test-id-1";
const id2 = "test-id-2";
const knownState1 = storage.getKnownState(id1);
const knownState2 = storage.getKnownState(id2);
expect(knownState1).not.toBe(knownState2);
});
});
describe("load", () => {
test("should fail gracefully when loading non-existent coValue and preserve known state", async () => {
const { storage } = await createTestNode();
const id = "non-existent-id";
const callback = vi.fn();
const done = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(id);
expect(initialKnownState).toEqual(emptyKnownState(id as `co_z${string}`));
await storage.load(id, callback, done);
expect(done).toHaveBeenCalledWith(false);
expect(callback).not.toHaveBeenCalled();
// Verify that storage known state is NOT updated when load fails
const afterLoadKnownState = storage.getKnownState(id);
expect(afterLoadKnownState).toEqual(initialKnownState);
});
test("should successfully load coValue with header and update known state", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.any(Object),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should successfully load coValue with transactions and update known state", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
await storage.load(group.id, callback, done);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: group.id,
header: group.core.verified.header,
new: expect.objectContaining({
[fixturesNode.currentSessionID]: expect.any(Object),
}),
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known state is updated after load
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("store", () => {
test("should successfully store new coValue with header and update known state", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and get its content message
const group = fixturesNode.createGroup();
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
storage.store(contentMessage, correctionCallback);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.core.verified.header).toEqual(
group.core.verified.header,
);
});
test("should successfully store coValue with transactions and update known state", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
// Create a real group and add a member to create transactions
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
storage.store(contentMessage, correctionCallback);
// Verify that storage known state is updated after store
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle correction when header assumption is invalid", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.verified.knownState();
group.addMember("everyone", "reader");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(emptyKnownState(group.id));
return group.core.verified.newContentSince(known);
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
// Verify that storage known state is updated after store with correction
const updatedKnownState = storage.getKnownState(group.id);
expect(updatedKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("reader");
});
test("should handle correction when new content assumption is invalid", async () => {
const { fixturesNode } = await createFixturesNode();
const { node, storage } = await createTestNode();
const group = fixturesNode.createGroup();
const initialContent = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const initialKnownState = group.core.knownState();
group.addMember("everyone", "reader");
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
expect(known).toEqual(initialKnownState);
return group.core.verified.newContentSince(known);
});
// Get initial storage known state
const initialStorageKnownState = storage.getKnownState(group.id);
expect(initialStorageKnownState).toEqual(emptyKnownState(group.id));
storage.store(initialContent, correctionCallback);
// Verify storage known state after first store
const afterFirstStore = storage.getKnownState(group.id);
expect(afterFirstStore).toEqual(initialKnownState);
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
// Verify that storage known state is updated after store with correction
const finalKnownState = storage.getKnownState(group.id);
expect(finalKnownState).toEqual(group.core.verified.knownState());
node.setStorage(storage);
const groupOnNode = await loadCoValueOrFail(node, group.id);
expect(groupOnNode.get("everyone")).toEqual("writer");
});
test("should log error and fail when correction callback returns undefined", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn((known) => {
return undefined;
});
// Get initial known state
const initialKnownState = storage.getKnownState(group.id);
expect(initialKnownState).toEqual(emptyKnownState(group.id));
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(false);
// Verify that storage known state is NOT updated when store fails
const afterStoreKnownState = storage.getKnownState(group.id);
expect(afterStoreKnownState).toEqual(initialKnownState);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
errorSpy.mockClear();
});
test("should log error and fail when correction callback returns invalid content message", async () => {
const { fixturesNode } = await createFixturesNode();
const { storage } = await createTestNode();
const group = fixturesNode.createGroup();
const knownState = group.core.knownState();
group.addMember("everyone", "writer");
const contentMessage = getNewContentSince(group.core, knownState);
const correctionCallback = vi.fn(() => {
return [contentMessage];
});
const errorSpy = vi.spyOn(logger, "error").mockImplementation(() => {});
const result = storage.store(contentMessage, correctionCallback);
expect(correctionCallback).toHaveBeenCalledTimes(1);
expect(result).toBe(false);
expect(errorSpy).toHaveBeenCalledWith(
"Correction callback returned undefined",
{
knownState: expect.any(Object),
correction: null,
},
);
expect(errorSpy).toHaveBeenCalledWith("Double correction requested", {
knownState: expect.any(Object),
msg: expect.any(Object),
});
errorSpy.mockClear();
});
test("should successfully store coValue with multiple sessions", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { fixturesNode: fixtureNode2 } = await createFixturesNode(dbPath);
const { node, storage } = await createTestNode();
const coValue = fixturesNode.createCoValue({
type: "comap",
ruleset: { type: "unsafeAllowAll" },
meta: null,
...crypto.createdNowUnique(),
});
coValue.makeTransaction(
[
{
count: 1,
},
],
"trusting",
);
await coValue.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(
fixtureNode2,
coValue.id as CoID<RawCoMap>,
);
coValue.makeTransaction(
[
{
count: 2,
},
],
"trusting",
);
const knownState = mapOnNode2.core.knownState();
const contentMessage = getNewContentSince(
mapOnNode2.core,
emptyKnownState(mapOnNode2.id),
);
const correctionCallback = vi.fn();
storage.store(contentMessage, correctionCallback);
node.setStorage(storage);
const finalMap = await loadCoValueOrFail(node, mapOnNode2.id);
expect(finalMap.core.knownState()).toEqual(knownState);
});
});
describe("dependencies", () => {
test("should load dependencies before dependent coValues and update all known states", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group to create dependencies
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// Load the map, which should also load the group dependency first
await storage.load(map.id, callback, done);
expect(callback).toHaveBeenCalledTimes(2); // Group first, then map
expect(callback).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
id: group.id,
}),
);
expect(callback).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify that storage known states are updated after load
const updatedGroupKnownState = storage.getKnownState(group.id);
const updatedMapKnownState = storage.getKnownState(map.id);
expect(updatedGroupKnownState).toEqual(group.core.verified.knownState());
expect(updatedMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
test("should skip loading already loaded dependencies", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and a map owned by that group
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
const map = group.createMap({ test: "value" });
await group.core.waitForSync();
await map.core.waitForSync();
const callback = vi.fn((content) =>
node.syncManager.handleNewContent(content, "storage"),
);
const done = vi.fn();
// Get initial known states
const initialGroupKnownState = storage.getKnownState(group.id);
const initialMapKnownState = storage.getKnownState(map.id);
expect(initialGroupKnownState).toEqual(emptyKnownState(group.id));
expect(initialMapKnownState).toEqual(emptyKnownState(map.id));
// First load the group
await storage.load(group.id, callback, done);
callback.mockClear();
done.mockClear();
// Verify group known state is updated after first load
const afterGroupLoad = storage.getKnownState(group.id);
expect(afterGroupLoad).toEqual(group.core.verified.knownState());
// Then load the map - the group dependency should already be loaded
await storage.load(map.id, callback, done);
// Should only call callback once for the map since group is already loaded
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
id: map.id,
}),
);
expect(done).toHaveBeenCalledWith(true);
// Verify map known state is updated after second load
const finalMapKnownState = storage.getKnownState(map.id);
expect(finalMapKnownState).toEqual(map.core.verified.knownState());
node.setStorage(storage);
const mapOnNode = await loadCoValueOrFail(node, map.id);
expect(mapOnNode.get("test")).toEqual("value");
});
});
describe("waitForSync", () => {
test("should resolve immediately when coValue is already synced", async () => {
const { fixturesNode, dbPath } = await createFixturesNode();
const { node, storage } = await createTestNode(dbPath);
// Create a group and add a member
const group = fixturesNode.createGroup();
group.addMember("everyone", "reader");
await group.core.waitForSync();
// Store the group in storage
const contentMessage = getNewContentSince(
group.core,
emptyKnownState(group.id),
);
const correctionCallback = vi.fn();
storage.store(contentMessage, correctionCallback);
node.setStorage(storage);
// Load the group on the new node
const groupOnNode = await loadCoValueOrFail(node, group.id);
// Wait for sync should resolve immediately since the coValue is already synced
await expect(
storage.waitForSync(group.id, groupOnNode.core),
).resolves.toBeUndefined();
expect(groupOnNode.get("everyone")).toEqual("reader");
});
});
describe("close", () => {
test("should close storage without throwing errors", async () => {
const { storage } = await createTestNode();
expect(() => storage.close()).not.toThrow();
});
});
});

View File

@@ -1,16 +1,14 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { StoreQueue } from "../storage/StoreQueue.js";
import { StoreQueue } from "../queue/StoreQueue.js";
import type { CoValueKnownState, NewContentMessage } from "../sync.js";
function createMockNewContentMessage(id: string): NewContentMessage[] {
return [
{
action: "content",
id: id as any,
priority: 0,
new: {},
},
];
function createMockNewContentMessage(id: string): NewContentMessage {
return {
action: "content",
id: id as any,
priority: 0,
new: {},
};
}
function setup() {
@@ -154,14 +152,14 @@ describe("StoreQueue", () => {
storeQueue.push(data1, mockCorrectionCallback);
storeQueue.push(data2, mockCorrectionCallback);
storeQueue.drain();
storeQueue.close();
expect(storeQueue.pull()).toBeUndefined();
});
test("should handle empty queue", () => {
const { storeQueue } = setup();
expect(() => storeQueue.drain()).not.toThrow();
expect(() => storeQueue.close()).not.toThrow();
expect(storeQueue.pull()).toBeUndefined();
});
});
@@ -240,23 +238,11 @@ describe("StoreQueue", () => {
});
describe("edge cases", () => {
test("should handle undefined data", () => {
const { storeQueue, mockCorrectionCallback } = setup();
const data: NewContentMessage[] = [];
storeQueue.push(data, mockCorrectionCallback);
const entry = storeQueue.pull();
expect(entry).toEqual({
data,
correctionCallback: mockCorrectionCallback,
});
});
test("should handle null correction callback", () => {
const { storeQueue } = setup();
const data = createMockNewContentMessage("co1");
const nullCallback = () => {};
const nullCallback = () => undefined;
storeQueue.push(data, nullCallback);
const entry = storeQueue.pull();

View File

@@ -38,14 +38,6 @@ describe("SyncStateManager", () => {
const updateSpy: GlobalSyncStateListenerCallback = vi.fn();
const unsubscribe = subscriptionManager.subscribeToUpdates(updateSpy);
await client.node.syncManager.syncCoValue(map.core);
expect(updateSpy).toHaveBeenCalledWith(
peerState.id,
emptyKnownState(map.core.id),
{ uploaded: false },
);
await waitFor(() => {
return subscriptionManager.getCurrentSyncState(peerState.id, map.core.id)
.uploaded;
@@ -98,13 +90,6 @@ describe("SyncStateManager", () => {
unsubscribe2();
});
await client.node.syncManager.syncCoValue(map.core);
expect(updateToJazzCloudSpy).toHaveBeenCalledWith(
emptyKnownState(map.core.id),
{ uploaded: false },
);
await waitFor(() => {
return subscriptionManager.getCurrentSyncState(peerState.id, map.core.id)
.uploaded;
@@ -117,7 +102,7 @@ describe("SyncStateManager", () => {
{ uploaded: true },
);
expect(updateToStorageSpy).toHaveBeenLastCalledWith(
expect(updateToStorageSpy).toHaveBeenCalledWith(
emptyKnownState(group.core.id),
{ uploaded: false },
);
@@ -133,8 +118,6 @@ describe("SyncStateManager", () => {
const map = group.createMap();
map.set("key1", "value1", "trusting");
await client.node.syncManager.syncCoValue(map.core);
const subscriptionManager = client.node.syncManager.syncState;
expect(
@@ -174,8 +157,6 @@ describe("SyncStateManager", () => {
unsubscribe1();
unsubscribe2();
await client.node.syncManager.syncCoValue(map.core);
anyUpdateSpy.mockClear();
await waitFor(() => {
@@ -336,6 +317,26 @@ describe("SyncStateManager", () => {
await map.core.waitForSync();
expect(client.node.getCoValue(map.id).isAvailable()).toBe(true);
expect(client.node.getCoValue(map.id).hasVerifiedContent()).toBe(true);
// Since only the map is subscribed, the dependencies are pushed after the client requests them
await waitFor(() => {
expect(client.node.getCoValue(map.id).isAvailable()).toBe(true);
});
expect(
SyncMessagesLog.getMessages({
Map: map.core,
Group: group.core,
}),
).toMatchInlineSnapshot(`
[
"server -> client | CONTENT Map header: true new: After: 0 New: 1",
"client -> server | LOAD Group sessions: empty",
"client -> server | KNOWN Map sessions: header/1",
"server -> client | CONTENT Group header: true new: After: 0 New: 3",
"client -> server | KNOWN Group sessions: header/3",
]
`);
});
});

View File

@@ -74,6 +74,69 @@ test("Can create account with one node, and then load it on another", async () =
expect(map2.get("foo")).toEqual("bar");
});
test("Should migrate the root from private to trusting", async () => {
const { node, accountID, accountSecret } =
await LocalNode.withNewlyCreatedAccount({
creationProps: { name: "Hermes Puggington" },
crypto: Crypto,
});
const group = await node.createGroup();
expect(group).not.toBeNull();
const map = group.createMap();
map.set("foo", "bar", "private");
expect(map.get("foo")).toEqual("bar");
const peers1 = connectedPeers("node1", "node2", {
peer1role: "server",
peer2role: "client",
});
const account = await node.load(accountID);
if (account === "unavailable") throw new Error("Account unavailable");
account.set("root", map.id, "private");
node.syncManager.addPeer(peers1[1]);
const node2 = await LocalNode.withLoadedAccount({
accountID,
accountSecret,
sessionID: Crypto.newRandomSessionID(accountID),
peersToLoadFrom: [peers1[0]],
crypto: Crypto,
});
const account2 = await node2.load(accountID);
if (account2 === "unavailable") throw new Error("Account unavailable");
expect(account2.getRaw("root")?.trusting).toEqual(true);
node2.gracefulShutdown(); // Stop getting updates from node1
const peers2 = connectedPeers("node2", "node3", {
peer1role: "server",
peer2role: "client",
});
node.syncManager.addPeer(peers2[1]);
const node3 = await LocalNode.withLoadedAccount({
accountID,
accountSecret,
sessionID: Crypto.newRandomSessionID(accountID),
peersToLoadFrom: [peers2[0]],
crypto: Crypto,
});
const account3 = await node3.load(accountID);
if (account3 === "unavailable") throw new Error("Account unavailable");
expect(account3.getRaw("root")?.trusting).toEqual(true);
expect(account3.ops).toEqual(account2.ops); // No new transactions were made
});
test("throws an error if the user tried to create an invite from an account", async () => {
const { node, accountID } = await LocalNode.withNewlyCreatedAccount({
creationProps: { name: "Hermes Puggington" },

View File

@@ -1,10 +1,21 @@
import { describe, expect, test } from "vitest";
import { beforeEach, describe, expect, test } from "vitest";
import type { CoID, RawGroup } from "../exports";
import { NewContentMessage } from "../sync";
import {
SyncMessagesLog,
createThreeConnectedNodes,
createTwoConnectedNodes,
loadCoValueOrFail,
setupTestNode,
} from "./testUtils";
let jazzCloud: ReturnType<typeof setupTestNode>;
beforeEach(async () => {
SyncMessagesLog.clear();
jazzCloud = setupTestNode({ isSyncServer: true });
});
describe("extend", () => {
test("inherited writer roles should work correctly", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
@@ -87,6 +98,32 @@ describe("extend", () => {
expect(mapOnNode2.get("test")).toEqual("Written from node2");
});
test("inherited everyone roles should work correctly", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
const group = node1.node.createGroup();
group.addMember("everyone", "writer");
const childGroup = node1.node.createGroup();
childGroup.extend(group);
expect(childGroup.roleOf("everyone")).toEqual("writer");
const map = childGroup.createMap();
map.set("test", "Written from the admin");
await map.core.waitForSync();
const mapOnNode2 = await loadCoValueOrFail(node2.node, map.id);
// The writer role should be able to see the edits from the admin
expect(mapOnNode2.get("test")).toEqual("Written from the admin");
mapOnNode2.set("hello", "from node 2");
expect(mapOnNode2.get("hello")).toEqual("from node 2");
});
test("a user should be able to extend a group when his role on the parent group is writeOnly", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
@@ -143,6 +180,132 @@ describe("extend", () => {
expect(map.get("test")).toEqual("Hello!");
});
test("should not break when checking for cycles on a loaded group", async () => {
const clientSession1 = setupTestNode({
connected: true,
});
const clientSession2 = clientSession1.spawnNewSession();
const group = clientSession1.node.createGroup();
const childGroup = clientSession1.node.createGroup();
const group2 = clientSession1.node.createGroup();
const group3 = clientSession1.node.createGroup();
childGroup.extend(group);
group.extend(group2);
group2.extend(group3);
await group.core.waitForSync();
await childGroup.core.waitForSync();
await group2.core.waitForSync();
await group3.core.waitForSync();
const groupOnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group.id,
);
const group3OnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group3.id,
);
expect(group3OnClientSession2.isSelfExtension(groupOnClientSession2)).toBe(
true,
);
// Child groups are not loaded as dependencies, and we want to make sure having a missing child doesn't break the extension
expect(clientSession2.node.getCoValue(childGroup.id).isAvailable()).toEqual(
false,
);
group3OnClientSession2.extend(groupOnClientSession2);
expect(group3OnClientSession2.getParentGroups()).toEqual([]);
const map = group3OnClientSession2.createMap();
map.set("test", "Hello!");
expect(map.get("test")).toEqual("Hello!");
});
test("should extend groups when loaded from a different session", async () => {
const clientSession1 = setupTestNode({
connected: true,
});
const clientSession2 = clientSession1.spawnNewSession();
const group = clientSession1.node.createGroup();
const group2 = clientSession1.node.createGroup();
await group.core.waitForSync();
await group2.core.waitForSync();
const groupOnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group.id,
);
const group2OnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group2.id,
);
group2OnClientSession2.extend(groupOnClientSession2);
expect(group2OnClientSession2.getParentGroups()).toEqual([
groupOnClientSession2,
]);
const map = group2OnClientSession2.createMap();
map.set("test", "Hello!");
expect(map.get("test")).toEqual("Hello!");
});
test("should extend groups when there is a cycle in the parent groups", async () => {
const clientSession1 = setupTestNode({
connected: true,
});
const clientSession2 = clientSession1.spawnNewSession();
const group = clientSession1.node.createGroup();
const group2 = clientSession1.node.createGroup();
await group.core.waitForSync();
await group2.core.waitForSync();
const groupOnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group.id,
);
const group2OnClientSession2 = await loadCoValueOrFail(
clientSession2.node,
group2.id,
);
group.extend(group2);
group2OnClientSession2.extend(groupOnClientSession2);
expect(group.getParentGroups()).toEqual([group2]);
expect(group2OnClientSession2.getParentGroups()).toEqual([
groupOnClientSession2,
]);
await group.core.waitForSync();
await group2OnClientSession2.core.waitForSync();
const group3 = clientSession1.node.createGroup();
group3.extend(group2);
expect(group3.getParentGroups()).toEqual([group2]);
const map = group3.createMap();
map.set("test", "Hello!");
expect(map.get("test")).toEqual("Hello!");
});
test("a writerInvite role should not be inherited", async () => {
const { node1, node2 } = await createTwoConnectedNodes("server", "server");
@@ -180,6 +343,257 @@ describe("extend", () => {
expect(childGroup.roleOf(alice.id)).toBe("writer");
});
test("should be possible to extend a group after getting revoked from the parent group", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const parentGroup = node1.node.createGroup();
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
parentGroup.addMember(alice, "writer");
parentGroup.addMember(bob, "reader");
parentGroup.removeMember(bob);
const parentGroupOnNode2 = await loadCoValueOrFail(
node2.node,
parentGroup.id,
);
const childGroup = node2.node.createGroup();
childGroup.extend(parentGroupOnNode2);
expect(childGroup.roleOf(alice.id)).toBe("writer");
});
test("should be possible to extend when access is everyone reader and the account is revoked from the parent group", async () => {
const { node1, node2, node3 } = await createThreeConnectedNodes(
"server",
"server",
"server",
);
const parentGroup = node1.node.createGroup();
parentGroup.addMember("everyone", "reader");
const alice = await loadCoValueOrFail(node1.node, node3.accountID);
const bob = await loadCoValueOrFail(node1.node, node2.accountID);
parentGroup.addMember(alice, "writer");
parentGroup.addMember(bob, "reader");
parentGroup.removeMember(bob);
const parentGroupOnNode2 = await loadCoValueOrFail(
node2.node,
parentGroup.id,
);
const childGroup = node2.node.createGroup();
childGroup.extend(parentGroupOnNode2);
expect(childGroup.roleOf(alice.id)).toBe("writer");
});
test("should be able to extend when the last read key is healed", async () => {
const clientWithAccess = setupTestNode({
secret:
"sealerSecret_zBTPp7U58Fzq9o7EvJpu4KEziepi8QVf2Xaxuy5xmmXFx/signerSecret_z62DuviZdXCjz4EZWofvr9vaLYFXDeTaC9KWhoQiQjzKk",
connected: true,
});
const clientWithoutAccess = setupTestNode({
connected: true,
});
const brokenGroupContent = {
action: "content",
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
header: {
type: "comap",
ruleset: {
type: "group",
initialAdmin:
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
},
meta: null,
createdAt: "2025-08-06T10:14:39.617Z",
uniqueness: "z3LJjnuPiPJaf5Qb9A",
},
priority: 0,
new: {
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
{
after: 0,
newTransactions: [
{
privacy: "trusting",
madeAt: 1754475279619,
changes:
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
},
{
privacy: "trusting",
madeAt: 1754475279621,
changes:
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
},
{
privacy: "trusting",
madeAt: 1754475279621,
changes:
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
},
{
privacy: "trusting",
madeAt: 1754475279622,
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
},
{
privacy: "trusting",
madeAt: 1754475279623,
changes:
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
},
{
privacy: "trusting",
madeAt: 1754475279623,
changes:
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
},
{
privacy: "trusting",
madeAt: 1754475279624,
changes:
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
},
{
privacy: "trusting",
madeAt: 1754475279624,
changes:
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
},
],
lastSignature:
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
},
},
} as unknown as NewContentMessage;
clientWithAccess.node.syncManager.handleNewContent(
brokenGroupContent,
"import",
);
// Load the CoValue to recover the key_for_everyone
await loadCoValueOrFail(
clientWithAccess.node,
brokenGroupContent.id as CoID<RawGroup>,
);
const group = await loadCoValueOrFail(
clientWithoutAccess.node,
brokenGroupContent.id as CoID<RawGroup>,
);
const childGroup = clientWithoutAccess.node.createGroup();
childGroup.extend(group);
expect(childGroup.getParentGroups()).toEqual([group]);
});
test("should be able to extend when the last read key is missing", async () => {
const clientWithoutAccess = setupTestNode({
connected: true,
});
const brokenGroupContent = {
action: "content",
id: "co_zW7F36Nnop9A7Er4gUzBcUXnZCK",
header: {
type: "comap",
ruleset: {
type: "group",
initialAdmin:
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv",
},
meta: null,
createdAt: "2025-08-06T10:14:39.617Z",
uniqueness: "z3LJjnuPiPJaf5Qb9A",
},
priority: 0,
new: {
"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv_session_zYLsz2CiW9pW":
{
after: 0,
newTransactions: [
{
privacy: "trusting",
madeAt: 1754475279619,
changes:
'[{"key":"sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"admin"}]',
},
{
privacy: "trusting",
madeAt: 1754475279621,
changes:
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UCg4UkytXF-W8PaIvaDffO3pZ3d9hdXUuNkQQEikPTAuOD9us92Pqb5Vgu7lx1Fpb0X8V5BJ2yxz6_D5WOzK3qjWBSsc7J1xDJA=="}]',
},
{
privacy: "trusting",
madeAt: 1754475279621,
changes:
'[{"key":"readKey","op":"set","value":"key_z5CVahfMkEWPj1B3zH"}]',
},
{
privacy: "trusting",
madeAt: 1754475279622,
changes: '[{"key":"everyone","op":"set","value":"reader"}]',
},
{
privacy: "trusting",
madeAt: 1754475279623,
changes:
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_everyone","op":"set","value":"keySecret_z9U9gzkahQXCxDoSw7isiUnbobXwuLdcSkL9Ci6ZEEkaL"}]',
},
{
privacy: "trusting",
madeAt: 1754475279623,
changes:
'[{"key":"key_z4Fi7hZNBx7XoVAKkP_for_sealer_z12QDazYB3ygPZtBV7sMm7iYKMRnNZ6Aaj1dfLXR7LSBm/signer_z2AskZQbc82qxo7iA3oiXoNExHLsAEXC2pHbwJzRnATWv","op":"set","value":"sealed_UuCBBfZkTnRTrGraqWWlzm9JE-VFduhsfu7WaZjpCbJYOTXpPhSNOnzGeS8qVuIsG6dORbME22lc5ltLxPjRqofQdDCNGQehCeQ=="}]',
},
{
privacy: "trusting",
madeAt: 1754475279624,
changes:
'[{"key":"key_z5CVahfMkEWPj1B3zH_for_key_z4Fi7hZNBx7XoVAKkP","op":"set","value":"encrypted_USTrBuobwTCORwy5yHxy4sFZ7swfrafP6k5ZwcTf76f0MBu9Ie-JmsX3mNXad4mluI47gvGXzi8I_"}]',
},
{
privacy: "trusting",
madeAt: 1754475279624,
changes:
'[{"key":"readKey","op":"set","value":"key_z4Fi7hZNBx7XoVAKkP"}]',
},
],
lastSignature:
"signature_z3tsE7U1JaeNeUmZ4EY3Xq5uQ9jq9jDi6Rkhdt7T7b7z4NCnpMgB4bo8TwLXYVCrRdBm6PoyyPdK8fYFzHJUh5EzA",
},
},
} as unknown as NewContentMessage;
clientWithoutAccess.node.syncManager.handleNewContent(
brokenGroupContent,
"import",
);
const group = await loadCoValueOrFail(
clientWithoutAccess.node,
brokenGroupContent.id as CoID<RawGroup>,
);
const childGroup = clientWithoutAccess.node.createGroup();
childGroup.extend(group);
expect(childGroup.getParentGroups()).toEqual([group]);
});
});
describe("unextend", () => {

View File

@@ -1,12 +1,15 @@
import { beforeEach, describe, expect, test } from "vitest";
import { setCoValueLoadingRetryDelay } from "../config.js";
import {
SyncMessagesLog,
TEST_NODE_CONFIG,
blockMessageTypeOnOutgoingPeer,
loadCoValueOrFail,
setupTestAccount,
setupTestNode,
} from "./testUtils.js";
setCoValueLoadingRetryDelay(10);
let jazzCloud: ReturnType<typeof setupTestNode>;
beforeEach(async () => {
@@ -15,6 +18,65 @@ beforeEach(async () => {
});
describe("Group.removeMember", () => {
test("revoking a member access should not affect everyone access", async () => {
const admin = await setupTestAccount({
connected: true,
});
const alice = await setupTestAccount({
connected: true,
});
const group = admin.node.createGroup();
group.addMember("everyone", "writer");
const aliceOnAdminNode = await loadCoValueOrFail(
admin.node,
alice.accountID,
);
group.addMember(aliceOnAdminNode, "writer");
group.removeMember(aliceOnAdminNode);
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
expect(groupOnAliceNode.myRole()).toEqual("writer");
const map = groupOnAliceNode.createMap();
map.set("test", "test");
expect(map.get("test")).toEqual("test");
});
test("revoking a member access should not affect everyone access when everyone access is gained through a group extension", async () => {
const admin = await setupTestAccount({
connected: true,
});
const alice = await setupTestAccount({
connected: true,
});
const parentGroup = admin.node.createGroup();
const group = admin.node.createGroup();
parentGroup.addMember("everyone", "reader");
group.extend(parentGroup);
const aliceOnAdminNode = await loadCoValueOrFail(
admin.node,
alice.accountID,
);
group.addMember(aliceOnAdminNode, "writer");
group.removeMember(aliceOnAdminNode);
const map = group.createMap();
map.set("test", "test");
const groupOnAliceNode = await loadCoValueOrFail(alice.node, group.id);
expect(groupOnAliceNode.myRole()).toEqual("reader");
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
expect(mapOnAliceNode.get("test")).toEqual("test");
});
test("a reader member should be able to revoke themselves", async () => {
const admin = await setupTestAccount({
connected: true,
@@ -294,4 +356,185 @@ describe("Group.removeMember", () => {
undefined,
);
});
test("removing a member should rotate the readKey on available child groups", async () => {
const admin = await setupTestAccount({
connected: true,
});
const alice = await setupTestAccount({
connected: true,
});
const aliceOnAdminNode = await loadCoValueOrFail(
admin.node,
alice.accountID,
);
const group = admin.node.createGroup();
const childGroup = admin.node.createGroup();
group.addMember(aliceOnAdminNode, "reader");
childGroup.extend(group);
group.removeMember(aliceOnAdminNode);
const map = childGroup.createMap();
map.set("test", "Not readable by alice");
await map.core.waitForSync();
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
expect(mapOnAliceNode.get("test")).toBeUndefined();
});
test("removing a member should rotate the readKey on unloaded child groups", async () => {
const admin = await setupTestAccount({
connected: true,
});
const bob = await setupTestAccount({
connected: true,
});
const alice = await setupTestAccount({
connected: true,
});
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
const aliceOnAdminNode = await loadCoValueOrFail(
admin.node,
alice.accountID,
);
const group = admin.node.createGroup();
const childGroup = bob.node.createGroup();
group.addMember(bobOnAdminNode, "reader");
group.addMember(aliceOnAdminNode, "reader");
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
childGroup.extend(groupOnBobNode);
await childGroup.core.waitForSync();
group.removeMember(aliceOnAdminNode);
// Rotating the child group keys is async when the child group is not loaded
await admin.node.getCoValue(childGroup.id).waitForAvailableOrUnavailable();
await admin.node.syncManager.waitForAllCoValuesSync();
const map = childGroup.createMap();
map.set("test", "Not readable by alice");
await map.core.waitForSync();
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
expect(mapOnAliceNode.get("test")).toBeUndefined();
});
test("removing a member should work even if there are partially available child groups", async () => {
const admin = await setupTestAccount({
connected: true,
});
const bob = await setupTestAccount();
const { peer } = bob.connectToSyncServer();
const alice = await setupTestAccount({
connected: true,
});
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
const aliceOnAdminNode = await loadCoValueOrFail(
admin.node,
alice.accountID,
);
const group = admin.node.createGroup();
const childGroup = bob.node.createGroup();
group.addMember(bobOnAdminNode, "reader");
group.addMember(aliceOnAdminNode, "reader");
await group.core.waitForSync();
blockMessageTypeOnOutgoingPeer(peer, "content", {
id: childGroup.id,
});
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
childGroup.extend(groupOnBobNode);
await groupOnBobNode.core.waitForSync();
group.removeMember(aliceOnAdminNode);
await admin.node.syncManager.waitForAllCoValuesSync();
const map = group.createMap();
map.set("test", "Not readable by alice");
await map.core.waitForSync();
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
expect(mapOnAliceNode.get("test")).toBeUndefined();
});
test("removing a member should work even if there are unavailable child groups", async () => {
const admin = await setupTestAccount({
connected: true,
});
const { peerOnServer } = admin.connectToSyncServer();
const bob = await setupTestAccount({
connected: true,
});
const alice = await setupTestAccount({
connected: true,
});
const bobOnAdminNode = await loadCoValueOrFail(admin.node, bob.accountID);
const aliceOnAdminNode = await loadCoValueOrFail(
admin.node,
alice.accountID,
);
const group = admin.node.createGroup();
const childGroup = bob.node.createGroup();
blockMessageTypeOnOutgoingPeer(peerOnServer, "content", {
id: childGroup.id,
});
group.addMember(bobOnAdminNode, "reader");
group.addMember(aliceOnAdminNode, "reader");
await group.core.waitForSync();
const groupOnBobNode = await loadCoValueOrFail(bob.node, group.id);
childGroup.extend(groupOnBobNode);
await groupOnBobNode.core.waitForSync();
group.removeMember(aliceOnAdminNode);
await group.core.waitForSync();
const map = group.createMap();
map.set("test", "Not readable by alice");
await map.core.waitForSync();
const mapOnAliceNode = await loadCoValueOrFail(alice.node, map.id);
expect(mapOnAliceNode.get("test")).toBeUndefined();
});
});

Some files were not shown because too many files have changed in this diff Show More