├── .changeset ├── README.md └── config.json ├── .github └── workflows │ ├── changesets.yaml │ ├── docs.yaml │ └── tests.yaml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode └── verdant.code-workspace ├── Dockerfile.scaleTest ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── .vscode │ └── tasks.json ├── README.md ├── babel.config.js ├── docs │ ├── cli.md │ ├── comparisons.md │ ├── efficiency.md │ ├── integrations │ │ ├── _category_.json │ │ └── tiptap.md │ ├── internals │ │ ├── _category_.json │ │ ├── assumptions.md │ │ ├── diffing.md │ │ ├── entity-ids.md │ │ └── initial-sync.md │ ├── intro.md │ ├── local-storage │ │ ├── _category_.json │ │ ├── entities.md │ │ ├── export.md │ │ ├── files.md │ │ ├── generate-client.md │ │ ├── migrations.md │ │ ├── querying.md │ │ ├── schema.md │ │ └── undo.md │ ├── manifesto.md │ ├── react │ │ ├── _category_.json │ │ ├── examples.md │ │ ├── field.md │ │ ├── generation.md │ │ ├── queries.md │ │ ├── reactivity.md │ │ └── router.md │ ├── sync │ │ ├── _category_.json │ │ ├── access.md │ │ ├── authentication.md │ │ ├── client.md │ │ ├── files.md │ │ ├── presence.md │ │ ├── pruning.md │ │ ├── server.md │ │ ├── storage.md │ │ ├── tips-and-tricks.md │ │ └── transports.md │ └── tutorials │ │ ├── _category_.json │ │ └── your-first-app.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.module.css │ │ └── index.tsx ├── static │ ├── .nojekyll │ ├── CNAME │ ├── Silence-sm.m4v │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── fonts │ │ ├── Cormorant-Italic-VariableFont_wght.ttf │ │ ├── Cormorant-VariableFont_wght.ttf │ │ ├── Lora-Italic-VariableFont_wght.ttf │ │ └── Lora-VariableFont_wght.ttf │ ├── gif │ │ └── tldraw.gif │ ├── images │ │ ├── local-only.png │ │ ├── multiplayer-web-app.png │ │ ├── personal-web-app.png │ │ └── tutorial-mood │ │ │ ├── app-1.png │ │ │ └── fast-update.mp4 │ ├── opengraph.png │ └── site.webmanifest └── tsconfig.json ├── examples └── vanilla │ ├── README.md │ └── index.html ├── fly.toml ├── package.json ├── packages ├── cli │ ├── .gitignore │ ├── .turbo │ │ └── turbo-ci │ ├── CHANGELOG.md │ ├── README.md │ ├── jsconfig.json │ ├── package.json │ ├── src │ │ ├── bin │ │ │ ├── compileSchema.ts │ │ │ ├── generate.ts │ │ │ ├── index.ts │ │ │ └── preflight.ts │ │ ├── client.ts │ │ ├── codegen.ts │ │ ├── compare.ts │ │ ├── cyclics.ts │ │ ├── env.ts │ │ ├── fs │ │ │ ├── code.ts │ │ │ ├── compareFiles.ts │ │ │ ├── copy.ts │ │ │ ├── emptyDirectory.ts │ │ │ ├── exists.ts │ │ │ ├── isEmpty.ts │ │ │ ├── makedir.ts │ │ │ ├── posixRelative.ts │ │ │ ├── posixify.ts │ │ │ ├── rm.ts │ │ │ ├── tempDir.ts │ │ │ └── write.ts │ │ ├── migrations.ts │ │ ├── react.ts │ │ ├── schema.ts │ │ ├── typingBuilder.ts │ │ ├── typings.test.ts │ │ ├── typings.ts │ │ ├── upgrade.ts │ │ └── validate.ts │ ├── test │ │ ├── __snapshots__ │ │ │ └── generated.test.ts.snap │ │ ├── generated.test.ts │ │ ├── migrations │ │ │ ├── index.ts │ │ │ └── v1.ts │ │ ├── schema.ts │ │ └── setup │ │ │ └── indexedDB.ts │ ├── test2 │ │ ├── index.ts │ │ ├── people.ts │ │ ├── posts.ts │ │ ├── schema.ts │ │ └── todos.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── common │ ├── .turbo │ │ └── turbo-ci │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── EventSubscriber.ts │ │ ├── authz.ts │ │ ├── baseline.ts │ │ ├── batching.ts │ │ ├── diffing.test.ts │ │ ├── diffing.ts │ │ ├── error.ts │ │ ├── files.ts │ │ ├── index.ts │ │ ├── indexes.test.ts │ │ ├── indexes.ts │ │ ├── memo.test.ts │ │ ├── memo.ts │ │ ├── migration.test.ts │ │ ├── migration.ts │ │ ├── oids.test.ts │ │ ├── oids.ts │ │ ├── oidsLegacy.test.ts │ │ ├── oidsLegacy.ts │ │ ├── operation.test.ts │ │ ├── operation.ts │ │ ├── patch.ts │ │ ├── presence.ts │ │ ├── protocol.ts │ │ ├── refs.ts │ │ ├── replica.ts │ │ ├── schema │ │ │ ├── children.ts │ │ │ ├── defaults.test.ts │ │ │ ├── fieldHelpers.ts │ │ │ ├── fields.ts │ │ │ ├── index.ts │ │ │ ├── indexFilters.ts │ │ │ ├── types.ts │ │ │ ├── types │ │ │ │ ├── collection.ts │ │ │ │ ├── compounds.ts │ │ │ │ ├── fields.ts │ │ │ │ ├── filters.ts │ │ │ │ ├── shapes.ts │ │ │ │ └── synthetics.ts │ │ │ ├── validation.test.ts │ │ │ └── validation.ts │ │ ├── timestamp.test.ts │ │ ├── timestamp.ts │ │ ├── undo.test.ts │ │ ├── undo.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── create-app │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── templates │ │ ├── common │ │ │ ├── .gitignore-template │ │ │ ├── apps │ │ │ │ └── web │ │ │ │ │ └── src │ │ │ │ │ └── components │ │ │ │ │ ├── todos │ │ │ │ │ └── TodoList.tsx │ │ │ │ │ └── updatePrompt │ │ │ │ │ └── UpdatePrompt.tsx │ │ │ └── packages │ │ │ │ └── verdant │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ │ └── tsconfig.json │ │ ├── full │ │ │ ├── .dockerignore │ │ │ ├── .github │ │ │ │ └── workflows │ │ │ │ │ └── deploy.yaml │ │ │ ├── .gitignore │ │ │ ├── Dockerfile │ │ │ ├── README.md │ │ │ ├── apps │ │ │ │ ├── api │ │ │ │ │ ├── .env-template │ │ │ │ │ ├── package.json │ │ │ │ │ ├── src │ │ │ │ │ │ ├── api │ │ │ │ │ │ │ ├── auth │ │ │ │ │ │ │ │ ├── google │ │ │ │ │ │ │ │ │ ├── callback.ts │ │ │ │ │ │ │ │ │ └── login.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── loginSuccess.ts │ │ │ │ │ │ │ │ ├── logout.ts │ │ │ │ │ │ │ │ ├── session.ts │ │ │ │ │ │ │ │ └── verdant.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── plan │ │ │ │ │ │ │ │ ├── create.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── invite.ts │ │ │ │ │ │ │ │ └── status.ts │ │ │ │ │ │ │ └── stripe │ │ │ │ │ │ │ │ ├── createCheckout.ts │ │ │ │ │ │ │ │ ├── createPortal.ts │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ └── webhook.ts │ │ │ │ │ │ ├── auth │ │ │ │ │ │ │ ├── join.ts │ │ │ │ │ │ │ └── subscriptions.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ ├── cookies.ts │ │ │ │ │ │ ├── lib │ │ │ │ │ │ │ ├── googleOAuth.ts │ │ │ │ │ │ │ └── stripe.ts │ │ │ │ │ │ ├── server.ts │ │ │ │ │ │ ├── session.ts │ │ │ │ │ │ └── verdant.ts │ │ │ │ │ └── tsconfig.json │ │ │ │ └── web │ │ │ │ │ ├── .env-template │ │ │ │ │ ├── index.html │ │ │ │ │ ├── package.json │ │ │ │ │ ├── public │ │ │ │ │ ├── 192x192.png │ │ │ │ │ ├── 512x512.png │ │ │ │ │ └── favicon.ico │ │ │ │ │ ├── src │ │ │ │ │ ├── App.tsx │ │ │ │ │ ├── components │ │ │ │ │ │ ├── auth │ │ │ │ │ │ │ ├── LoginButton.tsx │ │ │ │ │ │ │ └── OAuthSigninButton.tsx │ │ │ │ │ │ └── subscription │ │ │ │ │ │ │ ├── CompleteSubscription.tsx │ │ │ │ │ │ │ └── ManageSubscriptionButton.tsx │ │ │ │ │ ├── config.ts │ │ │ │ │ ├── hooks │ │ │ │ │ │ ├── useCreateInviteLink.ts │ │ │ │ │ │ └── useSession.ts │ │ │ │ │ ├── main.tsx │ │ │ │ │ ├── pages │ │ │ │ │ │ ├── ClaimInvitePage.tsx │ │ │ │ │ │ ├── HomePage.tsx │ │ │ │ │ │ ├── JoinPage.tsx │ │ │ │ │ │ ├── Pages.tsx │ │ │ │ │ │ └── SettingsPage.tsx │ │ │ │ │ ├── service-worker.ts │ │ │ │ │ ├── store.ts │ │ │ │ │ └── vite-env.d.ts │ │ │ │ │ ├── tsconfig.json │ │ │ │ │ ├── vercel.json │ │ │ │ │ └── vite.config.ts │ │ │ ├── fly.toml │ │ │ ├── package.json │ │ │ ├── packages │ │ │ │ └── prisma │ │ │ │ │ ├── .env-template │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── package.json │ │ │ │ │ ├── prisma │ │ │ │ │ └── schema.prisma │ │ │ │ │ └── tsconfig.json │ │ │ ├── pnpm-lock.yaml │ │ │ ├── pnpm-workspace.yaml │ │ │ ├── tsconfig.json │ │ │ └── turbo.json │ │ └── local │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── apps │ │ │ └── web │ │ │ │ ├── .env-template │ │ │ │ ├── index.html │ │ │ │ ├── package.json │ │ │ │ ├── public │ │ │ │ ├── 192x192.png │ │ │ │ ├── 512x512.png │ │ │ │ └── favicon.ico │ │ │ │ ├── src │ │ │ │ ├── App.tsx │ │ │ │ ├── config.ts │ │ │ │ ├── main.tsx │ │ │ │ ├── pages │ │ │ │ │ ├── HomePage.tsx │ │ │ │ │ └── Pages.tsx │ │ │ │ ├── service-worker.ts │ │ │ │ ├── store.ts │ │ │ │ └── vite-env.d.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vercel.json │ │ │ │ └── vite.config.ts │ │ │ ├── package.json │ │ │ ├── pnpm-lock.yaml │ │ │ ├── pnpm-workspace.yaml │ │ │ ├── tsconfig.json │ │ │ └── turbo.json │ └── tsconfig.json ├── file-storage-s3 │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── persistence-capacitor-sqlite │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── persistence-sqlite │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── SqlitePersistence.ts │ │ ├── SqliteService.ts │ │ ├── documents │ │ │ └── SqlitePersistenceDocumentDb.ts │ │ ├── files │ │ │ └── SqlitePersistenceFileDb.ts │ │ ├── index.ts │ │ ├── interfaces.ts │ │ ├── kysely.ts │ │ ├── metadata │ │ │ ├── SqlitePersistenceMetadataDb.ts │ │ │ └── migrations │ │ │ │ ├── index.ts │ │ │ │ └── v0001-initial.ts │ │ ├── nodeFilesystem.ts │ │ └── sqlContext.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── react-router │ ├── CHANGELOG.md │ ├── README.md │ ├── demo │ │ ├── RouteTransition.tsx │ │ ├── ScrollTester.tsx │ │ ├── data │ │ │ ├── fakePosts.ts │ │ │ └── utils.ts │ │ ├── index.css │ │ ├── index.html │ │ ├── main.tsx │ │ ├── routes │ │ │ ├── Comment.tsx │ │ │ ├── Home.tsx │ │ │ ├── Post.tsx │ │ │ ├── Posts.tsx │ │ │ └── Test.tsx │ │ ├── tsconfig.json │ │ └── vite.config.ts │ ├── package.json │ ├── src │ │ ├── Link.tsx │ │ ├── Outlet.tsx │ │ ├── RestoreScroll.tsx │ │ ├── Route.tsx │ │ ├── Router.tsx │ │ ├── TransitionIndicator.tsx │ │ ├── context.tsx │ │ ├── events.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── resolution.test.ts │ │ ├── resolution.ts │ │ ├── routes.ts │ │ ├── scrollPositions.ts │ │ ├── types.ts │ │ ├── util.test.ts │ │ └── util.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── react │ ├── .gitignore │ ├── CHANGELOG.md │ ├── package.json │ ├── src │ │ ├── generation.test.ts │ │ ├── hooks.tsx │ │ ├── index.ts │ │ ├── watch.test.tsx │ │ └── watch.ts │ ├── tests │ │ └── setup │ │ │ └── indexedDB.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── server │ ├── CHANGELOG.md │ ├── README.md │ ├── bin │ │ └── server.mjs │ ├── diagram.tldr │ ├── package.json │ ├── perf │ │ └── perf.mjs │ ├── src │ │ ├── ClientConnection.ts │ │ ├── MessageSender.ts │ │ ├── Presence.ts │ │ ├── Profiles.ts │ │ ├── ReplicaKeepaliveTimers.ts │ │ ├── Server.ts │ │ ├── ServerLibrary.test.ts │ │ ├── ServerLibrary.ts │ │ ├── TokenProvider.ts │ │ ├── TokenVerifier.ts │ │ ├── __snapshots__ │ │ │ └── migrations.test.ts.snap │ │ ├── files │ │ │ └── FileStorage.ts │ │ ├── index.ts │ │ ├── storage │ │ │ ├── Storage.ts │ │ │ ├── index.ts │ │ │ ├── sql │ │ │ │ ├── SqlBaselines.ts │ │ │ │ ├── SqlFileMetadata.ts │ │ │ │ ├── SqlOperations.ts │ │ │ │ ├── SqlReplicas.ts │ │ │ │ ├── database.ts │ │ │ │ ├── migrations.ts │ │ │ │ ├── migrations │ │ │ │ │ ├── v1.ts │ │ │ │ │ └── v2.ts │ │ │ │ ├── sqlStorage.ts │ │ │ │ └── tables.ts │ │ │ └── sqlShard │ │ │ │ ├── Databases.ts │ │ │ │ ├── SqlBaselines.ts │ │ │ │ ├── SqlFileMetadata.ts │ │ │ │ ├── SqlOperations.ts │ │ │ │ ├── SqlReplicas.ts │ │ │ │ ├── _testData │ │ │ │ └── unifiedData.ts │ │ │ │ ├── database.ts │ │ │ │ ├── migrations.ts │ │ │ │ ├── migrations │ │ │ │ ├── v1.ts │ │ │ │ └── v2.ts │ │ │ │ ├── sqlShardStorage.ts │ │ │ │ ├── tables.ts │ │ │ │ ├── transfer.test.ts │ │ │ │ └── transfer.ts │ │ └── types.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── store │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── perf │ │ ├── .gitignore │ │ ├── index.html │ │ ├── perfTest.ts │ │ ├── results │ │ │ ├── 2023-11-12T20-21-14.272Z.dc425561435e22e33b2f655a3aae4bf2a56cfccc.run.json │ │ │ ├── 2023-11-12T21-30-02.913Z.ab178e293af25632918d6f8ca5ef808b97273633.run.json │ │ │ ├── 2024-03-16T00-05-58.434Z.ecc696c7525cb2b83a67d238a1e6bf75d2c7a33a.run.json │ │ │ └── 2024-03-16T00-06-35.534Z.ecc696c7525cb2b83a67d238a1e6bf75d2c7a33a.run.json │ │ └── run.mjs │ ├── src │ │ ├── BackoffScheduler.ts │ │ ├── FakeWeakRef.ts │ │ ├── UndoHistory.ts │ │ ├── __tests__ │ │ │ ├── batching.test.ts │ │ │ ├── entities.test.ts │ │ │ ├── fixtures │ │ │ │ └── testStorage.ts │ │ │ ├── mutations.test.ts │ │ │ ├── queries.test.ts │ │ │ ├── schema.test.ts │ │ │ ├── setup │ │ │ │ └── indexedDB.ts │ │ │ └── undo.test.ts │ │ ├── authorization.ts │ │ ├── backup.ts │ │ ├── client │ │ │ ├── Client.ts │ │ │ └── ClientDescriptor.ts │ │ ├── constants.ts │ │ ├── context │ │ │ ├── ShutdownHandler.ts │ │ │ ├── Time.ts │ │ │ └── context.ts │ │ ├── entities │ │ │ ├── DocumentManager.ts │ │ │ ├── Entity.test.ts │ │ │ ├── Entity.ts │ │ │ ├── EntityCache.ts │ │ │ ├── EntityMetadata.ts │ │ │ ├── EntityStore.ts │ │ │ ├── OperationBatcher.ts │ │ │ ├── entityFieldSubscriber.ts │ │ │ └── types.ts │ │ ├── errors.ts │ │ ├── files │ │ │ ├── EntityFile.ts │ │ │ ├── FileManager.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── internal.ts │ │ ├── logger.ts │ │ ├── persistence │ │ │ ├── MessageCreator.ts │ │ │ ├── PersistenceFiles.ts │ │ │ ├── PersistenceMetadata.ts │ │ │ ├── PersistenceQueries.ts │ │ │ ├── PersistenceRebaser.ts │ │ │ ├── idb │ │ │ │ ├── IdbService.ts │ │ │ │ ├── files │ │ │ │ │ └── IdbPersistenceFileDb.ts │ │ │ │ ├── idbPersistence.ts │ │ │ │ ├── metadata │ │ │ │ │ ├── IdbMetadataDb.ts │ │ │ │ │ └── openMetadataDatabase.ts │ │ │ │ ├── queries │ │ │ │ │ ├── IdbDocumentDb.ts │ │ │ │ │ ├── migration │ │ │ │ │ │ └── db.ts │ │ │ │ │ └── ranges.ts │ │ │ │ └── util.ts │ │ │ ├── interfaces.ts │ │ │ ├── migration │ │ │ │ ├── engine.ts │ │ │ │ ├── finalize.ts │ │ │ │ ├── migrate.ts │ │ │ │ ├── paths.test.ts │ │ │ │ ├── paths.ts │ │ │ │ └── types.ts │ │ │ └── persistence.ts │ │ ├── queries │ │ │ ├── BaseQuery.ts │ │ │ ├── CollectionQueries.ts │ │ │ ├── FindAllQuery.ts │ │ │ ├── FindInfiniteQuery.ts │ │ │ ├── FindOneQuery.ts │ │ │ ├── FindPageQuery.ts │ │ │ ├── GetQuery.ts │ │ │ ├── QueryCache.ts │ │ │ ├── keys.ts │ │ │ ├── ranges.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── sync │ │ │ ├── FileSync.ts │ │ │ ├── Heartbeat.ts │ │ │ ├── PresenceManager.ts │ │ │ ├── PushPullSync.ts │ │ │ ├── ServerSyncEndpointProvider.ts │ │ │ ├── Sync.ts │ │ │ ├── WebSocketSync.ts │ │ │ ├── background.ts │ │ │ ├── cliSync.ts │ │ │ └── serviceWorker.ts │ │ ├── types.ts │ │ ├── utils │ │ │ ├── Disposable.ts │ │ │ ├── Resolvable.ts │ │ │ ├── id.ts │ │ │ ├── versions.ts │ │ │ └── wip.ts │ │ └── vanilla.ts │ ├── tsconfig.json │ └── vitest.config.ts └── tiptap │ ├── .gitignore │ ├── CHANGELOG.md │ ├── demo │ ├── index.css │ ├── index.html │ ├── main.tsx │ ├── server │ │ └── index.mjs │ ├── store │ │ ├── .generated │ │ │ ├── client.d.ts │ │ │ ├── client.js │ │ │ ├── index.d.ts │ │ │ ├── index.js │ │ │ ├── index.js.map │ │ │ ├── index.ts │ │ │ ├── meta.json │ │ │ ├── react.d.ts │ │ │ ├── react.js │ │ │ ├── schema.d.ts │ │ │ ├── schema.js │ │ │ └── schemaVersions │ │ │ │ ├── index.d.ts │ │ │ │ ├── index.js │ │ │ │ ├── v1.d.ts │ │ │ │ └── v1.js │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── index.d.ts │ │ │ ├── index.js │ │ │ ├── index.js.map │ │ │ ├── index.ts │ │ │ ├── v1.d.ts │ │ │ ├── v1.js │ │ │ ├── v1.js.map │ │ │ └── v1.ts │ │ └── schema.ts │ ├── tsconfig.json │ └── vite.config.ts │ ├── package.json │ ├── src │ ├── __browserTests__ │ │ ├── fixtures │ │ │ ├── cat.jpg │ │ │ ├── cat.m4a │ │ │ └── cat.mp4 │ │ └── react.test.tsx │ ├── extensions │ │ ├── NodeId.ts │ │ ├── Verdant.ts │ │ ├── VerdantMedia.ts │ │ ├── VerdantMediaRenderer.ts │ │ └── attributes.ts │ ├── fields.ts │ ├── index.ts │ ├── react.ts │ └── server │ │ └── index.ts │ ├── tsconfig.json │ ├── tsconfig.server.json │ └── vitest.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── scaleTest ├── client │ ├── index.ts │ └── src │ │ ├── index.html │ │ ├── index.ts │ │ └── store │ │ ├── client │ │ ├── index.d.ts │ │ ├── index.js │ │ ├── package.json │ │ ├── react.d.ts │ │ ├── react.js │ │ ├── schema.js │ │ └── schemaVersions │ │ │ └── v1.ts │ │ ├── index.ts │ │ ├── migrations │ │ ├── index.ts │ │ └── v1.ts │ │ └── schema.ts ├── package.json ├── server │ └── index.ts ├── test-db.sqlite └── tsconfig.json ├── scratchpad ├── .gitignore ├── README.md ├── index.html ├── package.json ├── tsconfig.json └── vite.config.ts ├── test ├── .client-sqlite │ └── .gitkeep ├── .databases │ └── .gitkeep ├── .gitignore ├── benchmark.ts ├── client │ ├── client.d.ts │ ├── client.js │ ├── index.ts │ ├── meta.json │ ├── react.d.ts │ ├── react.js │ ├── schema.d.ts │ ├── schema.js │ └── schemaVersions │ │ ├── index.d.ts │ │ ├── index.js │ │ ├── v1.d.ts │ │ └── v1.js ├── lib │ ├── createTestContext.ts │ ├── createTestFile.ts │ ├── log.ts │ ├── persistence.ts │ ├── testClient.ts │ ├── testServer.ts │ └── waits.ts ├── migrations │ ├── index.ts │ └── v1.ts ├── package.json ├── schema.ts ├── scripts │ └── cleanupDbs.mjs ├── setup │ └── indexedDB.ts ├── tests │ ├── authz.test.ts │ ├── authzMigration.test.ts │ ├── backup.test.ts │ ├── cloning.test.ts │ ├── deletion.test.ts │ ├── export.test.ts │ ├── fileSync.test.ts │ ├── files.test.ts │ ├── fuzz.test.ts │ ├── hono.test.ts │ ├── initialData.test.ts │ ├── migration.test.ts │ ├── needMore.test.ts │ ├── presence.test.ts │ ├── pruning.test.ts │ ├── push.test.ts │ ├── realWorld.test.ts │ ├── rebasing.test.ts │ ├── reset.test.ts │ ├── serverChanges.test.ts │ ├── serverSnapshot.test.ts │ ├── shardTransition.test.ts │ ├── superseding.test.ts │ ├── sync.test.ts │ ├── syncOnce.test.ts │ ├── tiptap.test.ts │ ├── truancy.test.ts │ ├── unapplied.test.ts │ ├── undo.test.ts │ ├── unifiedDatabase.test.ts │ └── wip.test.ts ├── tsconfig.json └── vitest.config.ts ├── tsconfig.json ├── turbo.json ├── verdant.code-workspace └── vitest.workspace.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "minor", 10 | "ignore": [], 11 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 12 | "onlyUpdatePeerDependentsWhenOutOfRange": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/changesets.yaml: -------------------------------------------------------------------------------- 1 | name: Changesets 2 | on: 3 | push: 4 | branches: 5 | - main 6 | env: 7 | CI: true 8 | PNPM_CACHE_FOLDER: .pnpm-store 9 | jobs: 10 | version: 11 | timeout-minutes: 15 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout code repository 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | token: ${{ secrets.CI_PERSONAL_TOKEN }} 19 | - name: setup node.js 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 20 23 | - name: install pnpm 24 | run: npm i pnpm@9.15.0 -g 25 | - name: setup pnpm config 26 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER 27 | - name: install dependencies 28 | run: pnpm install 29 | - name: build packages to verify they are buildable 30 | run: pnpm run build 31 | - name: create and publish versions 32 | uses: changesets/action@v1 33 | with: 34 | commit: 'chore: update versions' 35 | title: 'chore: update versions' 36 | publish: pnpm ci:publish 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.CI_PERSONAL_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | name: Deploy to GitHub Pages 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: setup node.js 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 20 18 | - name: install pnpm 19 | run: npm i pnpm@9.15.0 -g 20 | - name: setup pnpm config 21 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER 22 | 23 | - name: Install dependencies 24 | run: pnpm install --filter docs --frozen-lockfile 25 | 26 | - name: Build website 27 | run: pnpm --filter docs build 28 | 29 | # Popular action to deploy to GitHub Pages: 30 | # Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus 31 | - name: Deploy to GitHub Pages 32 | uses: peaceiris/actions-gh-pages@v3 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | # Build output to publish to the `gh-pages` branch: 36 | publish_dir: ./docs/build 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | name: Run tests 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: setup node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 20 19 | - name: install pnpm 20 | run: npm i pnpm@9.15.0 -g 21 | - name: setup pnpm config 22 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER 23 | 24 | - name: Install dependencies 25 | run: pnpm install --frozen-lockfile 26 | - name: Install playwright 27 | run: pnpx playwright install --with-deps 28 | 29 | - name: Typecheck 30 | run: pnpm typecheck 31 | 32 | - name: Run package tests 33 | run: pnpm ci:test:unit 34 | 35 | - name: Run integration tests 36 | run: pnpm ci:test:integration 37 | env: 38 | CI: true 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | strict-peer-dependencies=false 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/verdant.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "name": "🫙 Store", 5 | "path": "../packages/store" 6 | }, 7 | { 8 | "name": "💽 Server", 9 | "path": "../packages/server" 10 | }, 11 | { 12 | "name": "📦 Common", 13 | "path": "../packages/common" 14 | }, 15 | { 16 | "name": "🧑🏻‍💻 CLI", 17 | "path": "../packages/cli" 18 | }, 19 | { 20 | "name": "⚛️ React", 21 | "path": "../packages/react" 22 | }, 23 | { 24 | "name": "🚀 Starter Kit", 25 | "path": "../packages/create-app" 26 | }, 27 | { 28 | "name": "🧪 Integration Tests", 29 | "path": "../test" 30 | }, 31 | { 32 | "name": "🗺️ Router", 33 | "path": "../packages/react-router" 34 | }, 35 | { 36 | "name": "📜 Docs", 37 | "path": "../docs" 38 | }, 39 | { 40 | "name": "💁🏻 Examples", 41 | "path": "../examples" 42 | }, 43 | { 44 | "name": "🗒️ Scratchpad", 45 | "path": "../scratchpad" 46 | }, 47 | { 48 | "name": "📈 Scale Test", 49 | "path": "../scaleTest" 50 | }, 51 | { 52 | "name": "🗄️ S3 Storage", 53 | "path": "../packages/file-storage-s3" 54 | }, 55 | { 56 | "name": "🔧 Workflows", 57 | "path": "../.github/workflows" 58 | } 59 | ], 60 | "settings": { 61 | "vitest.commandLine": "pnpm exec vitest" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Dockerfile.scaleTest: -------------------------------------------------------------------------------- 1 | # basic NodeJS image 2 | FROM node:18-alpine3.16 AS base 3 | 4 | RUN npm install -g pnpm 5 | WORKDIR /root/monorepo 6 | 7 | RUN apk add --no-cache git 8 | 9 | RUN apk add --no-cache libc6-compat 10 | 11 | ENV PNPM_HOME=/usr/local/share/pnpm 12 | ENV PATH="$PNPM_HOME:$PATH" 13 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 14 | 15 | FROM base as dev 16 | COPY ./pnpm-lock.yaml . 17 | RUN pnpm fetch 18 | 19 | COPY . . 20 | 21 | RUN pnpm install --filter . --frozen-lockfile 22 | RUN pnpm install --filter "@verdant-web/scale-test..." --frozen-lockfile --unsafe-perm 23 | 24 | WORKDIR /root/monorepo/scaleTest 25 | EXPOSE 3000 26 | ENV NODE_ENV=production 27 | ENTRYPOINT ["pnpm", "run", "server"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Grant Forrest 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /docs/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start", 7 | "problemMatcher": [], 8 | "label": "Preview Docs", 9 | "detail": "docusaurus start" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | ``` 8 | $ yarn 9 | ``` 10 | 11 | ### Local Development 12 | 13 | ``` 14 | $ yarn start 15 | ``` 16 | 17 | This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ### Build 20 | 21 | ``` 22 | $ yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ### Deployment 28 | 29 | Using SSH: 30 | 31 | ``` 32 | $ USE_SSH=true yarn deploy 33 | ``` 34 | 35 | Not using SSH: 36 | 37 | ``` 38 | $ GIT_USER= yarn deploy 39 | ``` 40 | 41 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 42 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/docs/integrations/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Integrations", 3 | "position": 7, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Support Verdant integrations with other tools" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/internals/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "How it works", 3 | "position": 7, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Explanations of Verdant's systems in depth" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/internals/assumptions.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Protocol assumptions 6 | 7 | Collecting some thoughts about assumptions made in Verdant's sync protocols. May format this to be more readable at some point, but for now it's mostly for me (doubt you're considering implementing your own replica client). 8 | 9 | A replica always does a `sync` exchange (`sync`, `sync-resp`, `sync-ack`) before sending any other messages (with the exception of `presence-update` which may be sent in parallel) 10 | 11 | Every operation which arrives to the server is guaranteed to be earlier in timestamp than any subsequently sent operations. In other words, replicas ensure operations are sent in time order, whether they be in `sync` or `op` messages. Hence always `sync` before sending any `op`s. Internal client batching behavior must also account for this (in the client this is ensured by rewriting outgoing operation timestamps to `now` before flushing a batch) 12 | -------------------------------------------------------------------------------- /docs/docs/internals/entity-ids.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Entity IDs 6 | 7 | Each "entity" (basically any document or nested object) has a unique ID (referred to in Verdant source code as an "oid" or "object ID"). This ID links operations to the entity they operate on. 8 | 9 | Root entities (documents) derive their ID directly from their collection and primary key: 10 | 11 | ``` 12 | {collection}/{primaryKey} 13 | ``` 14 | 15 | Nested entities (fields) derive their IDs from their root, plus a random segment: 16 | 17 | ``` 18 | {collection}/{primaryKey}:{random} 19 | ``` 20 | 21 | When deleting a document, we scan and delete all operations and baselines for all related sub-objects, basically deleting anything which matches an ID pattern: 22 | 23 | ``` 24 | {collection}/{primaryKey}(:[w+])? 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/docs/local-storage/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Storing & Querying", 3 | "position": 3, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "How to store and reactively query data in your local-first app" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/react/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "React Bindings", 3 | "position": 6, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "First-class React code generation and hooks for Verdant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/sync/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Sync", 3 | "position": 4, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Synchronizing local data with a server and enabling real-time collaboration" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/docs/sync/tips-and-tricks.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 11 3 | --- 4 | 5 | # Tips and tricks for working in distributed systems 6 | 7 | After a year or so of building distributed apps on Verdant, here's some tricks I've picked up. 8 | 9 | ## Using a custom `primaryKey` for 'canonical' documents 10 | 11 | **The problem:** You have many replicas writing to the same library, possibly offline, but you want a particular document to _converge_ when they sync together, even if it was created independently on multiple devices. 12 | 13 | One intuitive example is if you had a music app and you wanted all creations of the _Rock_ genre to converge into the same Genre object as multiple people add music and categorize it. ([Gnocchi](https://gnocchi.biscuits.club) uses this for Categories and Foods). 14 | 15 | Instead of using a generated ID for the primary key, you can define it yourself. Even if multiple clients `.put` the same document with the same ID, they will all end up sharing a history once sync replicates all changes. 16 | 17 | However, because of limitations in Verdant's sync design, the last writer will still 'win' and their initial value will overwrite any prior changes, even if those changes included multiple alterations. In practice this doesn't seem to present a huge problem; most of the time replicas are online and will sync the document instead of creating it themselves. 18 | -------------------------------------------------------------------------------- /docs/docs/sync/transports.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 6 3 | --- 4 | 5 | # Advanced: Transports 6 | 7 | Verdant can sync over HTTP requests or WebSockets. By default, it automatically uses HTTP when a user is the only one connected to a library, and switches to WebSockets when other users are online. 8 | 9 | You can disable this functionality by passing `sync.automaticTransportSelection: false` to your client descriptor config, and change transport manually by using `sync.setMode('realtime' | 'pull')`. 10 | 11 | For reference, the default behavior is to switch to `realtime` whenever presence detects another user, and back to `pull` if everyone else leaves. You could use more advanced logic to streamline your server usage, for example if users are able to view different pages or spaces in your app, you might only turn on realtime if your presence data indicates they are looking at the same stuff. 12 | 13 | Switching transports is seamless and will not affect the ability of the user to read or modify data. 14 | -------------------------------------------------------------------------------- /docs/docs/tutorials/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Tutorials", 3 | "position": 10, 4 | "link": { 5 | "type": "generated-index", 6 | "description": "Longer-form tutorials for how to use Verdant" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "3.7.0", 18 | "@docusaurus/preset-classic": "3.7.0", 19 | "@docusaurus/theme-classic": "3.7.0", 20 | "@mdx-js/react": "^3.1.0", 21 | "@types/react": "catalog:", 22 | "clsx": "^2.1.1", 23 | "prism-react-renderer": "^2.4.1", 24 | "react": "catalog:", 25 | "react-dom": "catalog:" 26 | }, 27 | "devDependencies": { 28 | "@docusaurus/module-type-aliases": "3.7.0", 29 | "@tsconfig/docusaurus": "^2.0.3", 30 | "typescript": "catalog:" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.5%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "engines": { 45 | "node": ">=16.14" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/.nojekyll -------------------------------------------------------------------------------- /docs/static/CNAME: -------------------------------------------------------------------------------- 1 | verdant.dev 2 | -------------------------------------------------------------------------------- /docs/static/Silence-sm.m4v: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/Silence-sm.m4v -------------------------------------------------------------------------------- /docs/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/favicon-16x16.png -------------------------------------------------------------------------------- /docs/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/favicon-32x32.png -------------------------------------------------------------------------------- /docs/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/favicon.ico -------------------------------------------------------------------------------- /docs/static/fonts/Cormorant-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/fonts/Cormorant-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /docs/static/fonts/Cormorant-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/fonts/Cormorant-VariableFont_wght.ttf -------------------------------------------------------------------------------- /docs/static/fonts/Lora-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/fonts/Lora-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /docs/static/fonts/Lora-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/fonts/Lora-VariableFont_wght.ttf -------------------------------------------------------------------------------- /docs/static/gif/tldraw.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/gif/tldraw.gif -------------------------------------------------------------------------------- /docs/static/images/local-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/images/local-only.png -------------------------------------------------------------------------------- /docs/static/images/multiplayer-web-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/images/multiplayer-web-app.png -------------------------------------------------------------------------------- /docs/static/images/personal-web-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/images/personal-web-app.png -------------------------------------------------------------------------------- /docs/static/images/tutorial-mood/app-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/images/tutorial-mood/app-1.png -------------------------------------------------------------------------------- /docs/static/images/tutorial-mood/fast-update.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/images/tutorial-mood/fast-update.mp4 -------------------------------------------------------------------------------- /docs/static/opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/docs/static/opengraph.png -------------------------------------------------------------------------------- /docs/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This file is not used in compilation. It is here just for a nice editor experience. 3 | "extends": "@tsconfig/docusaurus/tsconfig.json", 4 | "compilerOptions": { 5 | "baseUrl": "." 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/vanilla/README.md: -------------------------------------------------------------------------------- 1 | # Verdant Vanilla 2 | 3 | This is about as basic as you can get! A plain HTML file which loads `@verdant-web/store` from a CDN and contains the entire app. Just open the HTML file in a browser. 4 | 5 | The CDN version of `@verdant-web/store` defines all of the NPM package's exports as properties on a global object called `Verdant`. 6 | 7 | Since we're not using TypeScript here, the schema can just be a plain object. But be careful, since we're also not getting any type safety for our documents. 8 | 9 | I used a Custom Element (aka Web Component) to define the counter, just because I like to encapsulate components. 10 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for aglio on 2022-08-27T12:52:19-04:00 2 | 3 | app = "verdant-scale-test" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [build] 9 | dockerfile = "Dockerfile.scaleTest" 10 | 11 | [env] 12 | HOST = "https://verdant-scale-test.fly.dev" 13 | 14 | [experimental] 15 | allowed_public_ports = [] 16 | auto_rollback = true 17 | 18 | [[services]] 19 | internal_port = 3000 20 | protocol = "tcp" 21 | [services.concurrency] 22 | type = "connections" 23 | hard_limit = 1000 24 | soft_limit = 1000 25 | 26 | [[services.ports]] 27 | force_https = true 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.http_checks]] 36 | interval = 10000 37 | grace_period = "5s" 38 | method = "get" 39 | path = "/" 40 | protocol = "http" 41 | restart_limit = 0 42 | timeout = 2000 43 | tls_skip_verify = false 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "verdant", 3 | "version": "0.1.0-rc.11", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "packageManager": "pnpm@9.15.0", 9 | "scripts": { 10 | "dev": "turbo run dev", 11 | "build": "turbo run build", 12 | "test": "turbo run ci:test:unit && turbo run ci:test:integration", 13 | "test:integration": "pnpm -F @verdant-web/test test", 14 | "ci:test:unit": "turbo run ci:test:unit", 15 | "ci:test:integration": "turbo run ci:test:integration", 16 | "test:watch": "turbo run test", 17 | "ci:version": "pnpm changeset version", 18 | "ci:publish": "pnpm changeset publish --access=public", 19 | "prerelease": "pnpm changeset pre enter next", 20 | "link": "pnpm link --global", 21 | "benchmark": "pnpm run --filter @verdant-web/test benchmark", 22 | "typecheck": "turbo run typecheck" 23 | }, 24 | "repository": "https://github.com/a-type/verdant", 25 | "author": "Grant Forrest ", 26 | "devDependencies": { 27 | "@changesets/cli": "^2.26.2", 28 | "@types/node": "20.10.5", 29 | "tsx": "^3.12.1", 30 | "turbo": "^2.0.11", 31 | "typescript": "^5.4.2", 32 | "vitest": "catalog:" 33 | }, 34 | "dependencies": { 35 | "fake-indexeddb": "^5.0.1", 36 | "prettier": "^3.0.3" 37 | }, 38 | "volta": { 39 | "node": "22.14.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | .generated/ 2 | test/migrations/ 3 | .generated-cjs/ 4 | test/migrations-cjs/ 5 | temp-* 6 | test2/migrations/ 7 | -------------------------------------------------------------------------------- /packages/cli/.turbo/turbo-ci: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/cli/.turbo/turbo-ci -------------------------------------------------------------------------------- /packages/cli/README.md: -------------------------------------------------------------------------------- 1 | # verdant code generator CLI 2 | 3 | ## Generate a client from schema 4 | 5 | Beginning with a schema file like this: 6 | 7 | ```ts 8 | const todoCollection = collection({ 9 | name: 'todo', 10 | primaryKey: 'id', 11 | fields: { 12 | id: { type: 'string', indexed: true, unique: true }, 13 | content: { 14 | type: 'string', 15 | indexed: false, 16 | unique: false, 17 | }, 18 | done: { 19 | type: 'boolean', 20 | }, 21 | tags: { 22 | type: 'array', 23 | items: { 24 | type: 'string', 25 | }, 26 | }, 27 | category: { 28 | type: 'string', 29 | }, 30 | attachments: { 31 | type: 'array', 32 | items: { 33 | type: 'object', 34 | properties: { 35 | name: { 36 | type: 'string', 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | synthetics: { 43 | example: { 44 | type: 'string', 45 | compute: (doc) => doc.content, 46 | unique: false, 47 | }, 48 | }, 49 | compounds: { 50 | tagsSortedByDone: { 51 | of: ['tags', 'done'], 52 | }, 53 | categorySortedByDone: { 54 | of: ['category', 'done'], 55 | }, 56 | }, 57 | }); 58 | 59 | export default schema({ 60 | version: 1, 61 | collections: { 62 | todo: todoCollection, 63 | }, 64 | }); 65 | ``` 66 | 67 | Point the CLI to the location of your schema file and give it an output directory path to create the client module in. 68 | -------------------------------------------------------------------------------- /packages/cli/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "NodeNext" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/src/bin/compileSchema.ts: -------------------------------------------------------------------------------- 1 | import { isCommonJS } from '../env.js'; 2 | import { writeSchema } from '../schema.js'; 3 | 4 | export async function compileSchema({ 5 | schema, 6 | output, 7 | }: { 8 | schema: string; 9 | output: string; 10 | }) { 11 | const commonjs = await isCommonJS(); 12 | await writeSchema({ 13 | schemaPath: schema, 14 | output, 15 | commonjs, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/compare.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Recursively compares two objects by value, ignoring keys 3 | * if specified. 4 | * 5 | * Returns `true` if objects are the same 6 | */ 7 | export function compareObjects( 8 | a: any, 9 | b: any, 10 | { ignoreKeys }: { ignoreKeys?: string[] } = {}, 11 | ): boolean { 12 | if (a === b) { 13 | return true; 14 | } 15 | if (typeof a !== typeof b) { 16 | return false; 17 | } 18 | if (typeof a !== 'object') { 19 | return false; 20 | } 21 | if (Array.isArray(a) !== Array.isArray(b)) { 22 | return false; 23 | } 24 | if (Array.isArray(a)) { 25 | if (a.length !== b.length) { 26 | return false; 27 | } 28 | return a.every((item, i) => compareObjects(item, b[i])); 29 | } 30 | const aKeys = Object.keys(a).filter((key) => !ignoreKeys?.includes(key)); 31 | const bKeys = Object.keys(b).filter((key) => !ignoreKeys?.includes(key)); 32 | if (aKeys.length !== bKeys.length) { 33 | return false; 34 | } 35 | const keys = new Set([...aKeys, ...bKeys]); 36 | if (ignoreKeys) { 37 | ignoreKeys.forEach((key) => keys.delete(key)); 38 | } 39 | 40 | return [...keys].every((key) => compareObjects(a[key], b[key])); 41 | } 42 | -------------------------------------------------------------------------------- /packages/cli/src/env.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | 4 | export async function isCommonJS() { 5 | // find a package.json from cwd 6 | const cwd = process.cwd(); 7 | let pkgPath = cwd; 8 | while (pkgPath !== path.parse(pkgPath).root) { 9 | try { 10 | const pkg = JSON.parse( 11 | await fs.readFile(path.join(pkgPath, `package.json`), 'utf8'), 12 | ); 13 | return pkg.type === 'commonjs' || !pkg.type; 14 | } catch (err) { 15 | // ignore 16 | } 17 | pkgPath = path.dirname(pkgPath); 18 | } 19 | return true; 20 | } 21 | -------------------------------------------------------------------------------- /packages/cli/src/fs/code.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | export function openInCode(path: string) { 4 | try { 5 | execSync(`code ${path}`); 6 | } catch (err) { 7 | console.log( 8 | `|\n| 💀 Failed to open VS Code. You\'ll have to open the file yourself: ${path}`, 9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/fs/compareFiles.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | 3 | export async function compareFiles(from: string, to: string) { 4 | const fromContent = await fs.readFile(from, 'utf8'); 5 | const toContent = await fs.readFile(to, 'utf8'); 6 | return fromContent === toContent; 7 | } 8 | -------------------------------------------------------------------------------- /packages/cli/src/fs/copy.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | 3 | /** 4 | * Recursively and destructively copy all files from one directory to another. 5 | */ 6 | export async function copy(from: string, to: string) { 7 | const fromStat = await fs.stat(from); 8 | if (fromStat.isDirectory()) { 9 | await fs.mkdir(to, { recursive: true }); 10 | const files = await fs.readdir(from); 11 | for (const file of files) { 12 | await copy(`${from}/${file}`, `${to}/${file}`); 13 | } 14 | } else { 15 | await fs.copyFile(from, to); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/cli/src/fs/emptyDirectory.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | 4 | export async function emptyDirectory(dir: string, except: string[] = []) { 5 | const files = await fs.readdir(dir); 6 | await Promise.all( 7 | files.map((file) => { 8 | if (!except.includes(file)) { 9 | fs.unlink(path.resolve(dir, file)); 10 | } 11 | }), 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/cli/src/fs/exists.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | 3 | export async function fileExists(path: string) { 4 | try { 5 | const result = await fs.stat(path); 6 | return !!result; 7 | } catch (err) { 8 | return false; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/cli/src/fs/isEmpty.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'fs/promises'; 2 | 3 | export async function isEmpty(dir: string) { 4 | const files = await readdir(dir); 5 | return files.length === 0; 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/fs/makedir.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | 3 | export async function makeDir(path: string) { 4 | try { 5 | await fs.mkdir(path, { recursive: true }); 6 | } catch (e) { 7 | if ((e as any).code !== 'EEXIST') { 8 | throw e; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/cli/src/fs/posixRelative.ts: -------------------------------------------------------------------------------- 1 | import { posixify } from './posixify.js'; 2 | import path from 'path'; 3 | 4 | export function posixRelative(a: string, b: string) { 5 | return posixify(path.relative(path.resolve(a), path.resolve(b))); 6 | } 7 | -------------------------------------------------------------------------------- /packages/cli/src/fs/posixify.ts: -------------------------------------------------------------------------------- 1 | import * as pathTools from 'path'; 2 | 3 | export function posixify(path: string) { 4 | return path.replaceAll(pathTools.win32.sep, pathTools.posix.sep); 5 | } 6 | -------------------------------------------------------------------------------- /packages/cli/src/fs/rm.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | 3 | /** 4 | * Recursively deletes everything in a directory. 5 | */ 6 | export async function rm(path: string) { 7 | await fs.rm(path, { recursive: true }); 8 | } 9 | 10 | export async function rmIfExists(path: string) { 11 | try { 12 | await rm(path); 13 | } catch (e) { 14 | if ((e as any).code !== 'ENOENT') { 15 | throw e; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/cli/src/fs/tempDir.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as fsSync from 'fs'; 3 | 4 | export async function tempDir(base: string, cleanup = true) { 5 | const dir = await fs.mkdtemp(base); 6 | if (cleanup) { 7 | process.on('exit', () => { 8 | try { 9 | fsSync.rmdirSync(dir, { recursive: true }); 10 | } catch (err) { 11 | // ignore 12 | } 13 | }); 14 | } 15 | return dir; 16 | } 17 | -------------------------------------------------------------------------------- /packages/cli/src/fs/write.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import prettier from 'prettier'; 3 | import { transform } from 'esbuild'; 4 | 5 | export async function writeTS(path: string, source: string) { 6 | return fs.writeFile( 7 | path, 8 | await prettier.format(source, { parser: 'typescript' }), 9 | ); 10 | } 11 | 12 | export async function writeTSAsJS( 13 | path: string, 14 | source: string, 15 | commonjs = false, 16 | ) { 17 | // compile the TS source to JS and write it 18 | const compiled = await transform(source, { 19 | loader: 'ts', 20 | format: commonjs ? 'cjs' : 'esm', 21 | }); 22 | return fs.writeFile( 23 | path, 24 | await prettier.format(compiled.code, { parser: 'babel' }), 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/cli/test/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import v1 from "./v1.js"; 2 | 3 | export default [v1]; 4 | -------------------------------------------------------------------------------- /packages/cli/test/migrations/v1.ts: -------------------------------------------------------------------------------- 1 | import v1Schema, { 2 | MigrationTypes as V1Types, 3 | } from "../.generated/schemaVersions/v1.js"; 4 | import { createMigration } from "@verdant-web/store"; 5 | 6 | // this is your first migration, so no logic is necessary! but you can 7 | // include logic here to seed initial data for users 8 | export default createMigration(v1Schema, async ({ mutations }) => { 9 | // await mutations.post.create({ title: 'Welcome to my app!' }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/cli/test/setup/indexedDB.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import { 3 | IDBKeyRange, 4 | IDBCursor, 5 | IDBDatabase, 6 | IDBTransaction, 7 | IDBRequest, 8 | IDBFactory, 9 | } from 'fake-indexeddb'; 10 | window.IDBKeyRange = IDBKeyRange; 11 | window.IDBCursor = IDBCursor; 12 | window.IDBDatabase = IDBDatabase; 13 | window.IDBTransaction = IDBTransaction; 14 | window.IDBRequest = IDBRequest; 15 | window.IDBFactory = IDBFactory; 16 | -------------------------------------------------------------------------------- /packages/cli/test2/index.ts: -------------------------------------------------------------------------------- 1 | import { ClientDescriptor } from './.generated/index'; 2 | 3 | const client = await new ClientDescriptor({ 4 | migrations: [], 5 | namespace: 'test2', 6 | }).open(); 7 | 8 | const todo = await client.todos.put({ 9 | id: '1', 10 | content: 'test', 11 | done: false, 12 | }); 13 | 14 | todo.get('tags').get(0); 15 | -------------------------------------------------------------------------------- /packages/cli/test2/people.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/common'; 2 | 3 | export const people = schema.collection({ 4 | name: 'person', 5 | primaryKey: 'id', 6 | fields: { 7 | id: { 8 | type: 'string', 9 | default: () => Math.random().toString(36).slice(2, 9), 10 | }, 11 | name: { 12 | type: 'string', 13 | indexed: true, 14 | }, 15 | posts: { 16 | type: 'array', 17 | items: { 18 | type: 'string', 19 | }, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /packages/cli/test2/posts.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/common'; 2 | 3 | export const posts = schema.collection({ 4 | name: 'post', 5 | primaryKey: 'id', 6 | fields: { 7 | id: { 8 | type: 'string', 9 | default: () => Math.random().toString(36).slice(2, 9), 10 | }, 11 | title: { 12 | type: 'string', 13 | indexed: true, 14 | }, 15 | content: { 16 | type: 'string', 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /packages/cli/test2/schema.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/common'; 2 | import { todos } from './todos.js'; 3 | import { people } from './people.js'; 4 | import { posts } from './posts.js'; 5 | 6 | export default schema({ 7 | version: 2, 8 | collections: { 9 | todos, 10 | people, 11 | posts, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/cli/test2/todos.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/common'; 2 | 3 | export const todos = schema.collection({ 4 | name: 'todo', 5 | primaryKey: 'id', 6 | fields: { 7 | id: { 8 | type: 'string', 9 | default: () => Math.random().toString(36).slice(2, 9), 10 | }, 11 | content: { 12 | type: 'string', 13 | indexed: true, 14 | }, 15 | done: { 16 | type: 'boolean', 17 | }, 18 | tags: { 19 | type: 'array', 20 | items: { 21 | type: 'string', 22 | }, 23 | }, 24 | category: { 25 | type: 'string', 26 | nullable: true, 27 | }, 28 | attachments: { 29 | type: 'array', 30 | items: { 31 | type: 'object', 32 | properties: { 33 | name: { 34 | type: 'string', 35 | }, 36 | test: { 37 | type: 'number', 38 | default: 1, 39 | }, 40 | }, 41 | }, 42 | }, 43 | }, 44 | synthetics: { 45 | example: { 46 | type: 'string', 47 | compute: (doc) => doc.content, 48 | }, 49 | }, 50 | compounds: { 51 | tagsSortedByDone: { 52 | of: ['tags', 'done'], 53 | }, 54 | categorySortedByDone: { 55 | of: ['category', 'done'], 56 | }, 57 | }, 58 | }); 59 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist" 6 | }, 7 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/cli/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | clearMocks: true, 7 | 8 | setupFiles: ['test/setup/indexedDB.ts'], 9 | }, 10 | resolve: { 11 | conditions: ['development', 'default'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/common/.turbo/turbo-ci: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/common/.turbo/turbo-ci -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/common", 3 | "version": "2.9.1", 4 | "access": "public", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "development": "./src/index.ts", 9 | "import": "./dist/esm/index.js", 10 | "types": "./dist/esm/index.d.ts" 11 | } 12 | }, 13 | "publishConfig": { 14 | "exports": { 15 | ".": { 16 | "import": "./dist/esm/index.js", 17 | "types": "./dist/esm/index.d.ts" 18 | } 19 | }, 20 | "access": "public" 21 | }, 22 | "files": [ 23 | "dist/", 24 | "src/" 25 | ], 26 | "scripts": { 27 | "test": "vitest", 28 | "ci:test:unit": "vitest run", 29 | "build": "tsc -p tsconfig.json", 30 | "prepublish": "pnpm run build", 31 | "link": "pnpm link --global", 32 | "typecheck": "tsc --noEmit" 33 | }, 34 | "dependencies": { 35 | "cuid": "^2.1.8", 36 | "object-hash": "^3.0.0", 37 | "uuid": "^8.3.2" 38 | }, 39 | "devDependencies": { 40 | "@types/object-hash": "^3.0.4", 41 | "@types/uuid": "^8.3.4", 42 | "typescript": "^5.4.2", 43 | "vitest": "^2.0.5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/common/src/baseline.ts: -------------------------------------------------------------------------------- 1 | // A: Docs without a base state don't have a baseline, it's 2 | // written upon rebasing. If you apply ops to an undefined 3 | // snapshot without an initialize, it remains undefined. 4 | 5 | import { AuthorizationKey } from './authz.js'; 6 | 7 | export type DocumentBaseline = { 8 | oid: string; 9 | snapshot: T; 10 | timestamp: string; 11 | authz?: AuthorizationKey; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/common/src/error.ts: -------------------------------------------------------------------------------- 1 | export enum VerdantErrorCode { 2 | InvalidRequest = 4000, 3 | BodyRequired = 4001, 4 | NoToken = 4010, 5 | InvalidToken = 4011, 6 | TokenExpired = 4012, 7 | Forbidden = 4030, 8 | NotFound = 4040, 9 | Unexpected = 5000, 10 | ConfigurationError = 5010, 11 | NoFileStorage = 5011, 12 | 13 | // Client errors 14 | 15 | MigrationPathNotFound = 7001, 16 | ImportFailed = 7002, 17 | // some functionality was invoked which requires online status, and 18 | // client couldn't connect. 19 | Offline = 7003, 20 | } 21 | 22 | export class VerdantError extends Error { 23 | static Code = VerdantErrorCode; 24 | 25 | constructor( 26 | public code: VerdantErrorCode, 27 | cause?: Error | undefined, 28 | message?: string, 29 | ) { 30 | super(message ?? `Verdant error: ${code}`, { 31 | cause, 32 | }); 33 | } 34 | 35 | get httpStatus() { 36 | const status = Math.floor(this.code / 10); 37 | if (status < 600) { 38 | return status; 39 | } 40 | return 500; 41 | } 42 | 43 | toResponse = () => { 44 | return JSON.stringify({ 45 | code: this.code, 46 | }); 47 | }; 48 | } 49 | 50 | export function isVerdantErrorResponse(body: any): body is { code: number } { 51 | return ( 52 | typeof body === 'object' && 'code' in body && typeof body.code === 'number' 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authz.js'; 2 | export * from './baseline.js'; 3 | export * from './batching.js'; 4 | export * from './diffing.js'; 5 | export * from './error.js'; 6 | export * from './EventSubscriber.js'; 7 | export * from './files.js'; 8 | export * from './indexes.js'; 9 | export * from './memo.js'; 10 | export { 11 | createDefaultMigration, 12 | createMigration, 13 | migrate, 14 | migrationRange, 15 | } from './migration.js'; 16 | export type { 17 | Migration, 18 | MigrationEngine, 19 | MigrationIndexDescription, 20 | } from './migration.js'; 21 | export * from './oids.js'; 22 | export * from './oidsLegacy.js'; 23 | export * from './operation.js'; 24 | export * from './patch.js'; 25 | export type * from './presence.js'; 26 | export { initialInternalPresence } from './presence.js'; 27 | export * from './protocol.js'; 28 | export { compareRefs, isRef, makeFileRef, makeObjectRef } from './refs.js'; 29 | export type { Ref } from './refs.js'; 30 | export * from './replica.js'; 31 | export * from './schema/index.js'; 32 | export * from './timestamp.js'; 33 | export * from './undo.js'; 34 | export * from './utils.js'; 35 | -------------------------------------------------------------------------------- /packages/common/src/memo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { memoByKeys } from './memo.js'; 3 | 4 | describe('memoByKeys', () => { 5 | it('memoizes from provided key list', () => { 6 | let i = 0; 7 | const a = { 8 | value: 0, 9 | }; 10 | const b = { 11 | value: 0, 12 | }; 13 | const compute = vi.fn(() => a.value + b.value); 14 | const keys = [a, b]; 15 | 16 | const memoized = memoByKeys(compute, () => keys); 17 | 18 | expect(memoized()).toEqual(0); 19 | expect(memoized()).toEqual(0); 20 | // ok, change the values - but not key identities 21 | a.value = 1; 22 | expect(memoized()).toEqual(0); 23 | 24 | expect(compute).toHaveBeenCalledOnce(); 25 | 26 | keys.push({ value: 0 }); 27 | expect(memoized()).toEqual(1); 28 | expect(memoized()).toEqual(1); 29 | expect(compute).toHaveBeenCalledTimes(2); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/common/src/memo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Memoizes the last invocation of a function with the same memo keys. 3 | * As long as key identity and set doesn't change, the last computed 4 | * value will be returned. 5 | */ 6 | export function memoByKeys( 7 | fn: (...args: unknown[]) => TRet, 8 | getKeys: () => TKeys, 9 | ): (...args: unknown[]) => TRet { 10 | let cachedKeys: TKeys | undefined; 11 | let cachedResult: TRet | undefined; 12 | return (...args: unknown[]) => { 13 | const keys = getKeys(); 14 | if ( 15 | cachedKeys && 16 | cachedKeys.length === keys.length && 17 | cachedKeys.every((key, i) => key === keys[i]) 18 | ) { 19 | return cachedResult!; 20 | } 21 | cachedKeys = [...keys] as TKeys; 22 | cachedResult = fn(...args); 23 | return cachedResult; 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/common/src/refs.ts: -------------------------------------------------------------------------------- 1 | import { FileRef, isFileRef } from './files.js'; 2 | import { isObjectRef, ObjectRef } from './operation.js'; 3 | 4 | export function isRef(obj: any): obj is ObjectRef | FileRef { 5 | return isObjectRef(obj) || isFileRef(obj); 6 | } 7 | 8 | export function compareRefs(a: any, b: any) { 9 | if (a === b) return true; 10 | if (!isRef(a) || !isRef(b)) return false; 11 | if (a['@@type'] !== b['@@type']) return false; 12 | if (a.id !== b.id) return false; 13 | return true; 14 | } 15 | 16 | export type Ref = ObjectRef | FileRef; 17 | 18 | export function makeObjectRef(oid: string): ObjectRef { 19 | return { '@@type': 'ref', id: oid }; 20 | } 21 | 22 | export function makeFileRef(oid: string): FileRef { 23 | return { '@@type': 'file', id: oid }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/common/src/replica.ts: -------------------------------------------------------------------------------- 1 | export interface ReplicaInfo { 2 | id: string; 3 | ackedLogicalTime: string | null; 4 | } 5 | 6 | /** 7 | * Different token types allow different replica client behaviors. 8 | * - Realtime: allows the client to subscribe to realtime events. 9 | * - Push: allows the client to push and pull data with HTTP, but not use realtime. 10 | * - PassivePush: allows the client to push and pull data with HTTP, but offline changes 11 | * will be discarded on reconnect. 12 | * - PassiveRealtime: allows the client to subscribe to realtime events, but offline changes 13 | * will be discarded on reconnect. 14 | * - ReadOnlyPull: the client may only pull changes using HTTP. It may not subscribe 15 | * to realtime events or push changes. 16 | * - ReadOnlyRealtime: the client may only subscribe to realtime events or pull from HTTP. 17 | * It may not push changes. 18 | * 19 | * Choosing the right token type can optimize client storage metrics significantly when 20 | * many replicas are connecting to a library. 21 | */ 22 | export enum ReplicaType { 23 | Realtime, 24 | Push, 25 | PassiveRealtime, 26 | PassivePush, 27 | ReadOnlyPull, 28 | ReadOnlyRealtime, 29 | } 30 | -------------------------------------------------------------------------------- /packages/common/src/schema/children.ts: -------------------------------------------------------------------------------- 1 | import { StorageFieldSchema, StorageFieldsSchema } from './types.js'; 2 | 3 | export function getChildFieldSchema( 4 | schema: StorageFieldSchema | StorageFieldsSchema, 5 | key: string | number, 6 | ): StorageFieldSchema | null { 7 | if (schema.type === 'object') { 8 | return schema.properties[key]; 9 | } else if (schema.type === 'array') { 10 | return schema.items; 11 | } else if (schema.type === 'map') { 12 | return schema.values; 13 | } else if (schema.type === 'any') { 14 | return schema; 15 | } else if (!('type' in schema)) { 16 | return schema[key] ?? null; 17 | } 18 | return null; 19 | } 20 | -------------------------------------------------------------------------------- /packages/common/src/schema/types.ts: -------------------------------------------------------------------------------- 1 | import { StorageCollectionSchema } from './types/collection.js'; 2 | 3 | export * from './types/collection.js'; 4 | export * from './types/compounds.js'; 5 | export * from './types/fields.js'; 6 | export * from './types/filters.js'; 7 | export * from './types/shapes.js'; 8 | export * from './types/synthetics.js'; 9 | 10 | export type StorageSchema< 11 | Collections extends { 12 | [k: string]: StorageCollectionSchema; 13 | } = { 14 | [k: string]: StorageCollectionSchema; 15 | }, 16 | > = { version: number; wip?: true; collections: Collections }; 17 | 18 | export type SchemaCollectionName> = 19 | Schema extends StorageSchema 20 | ? Exclude 21 | : never; 22 | 23 | export type SchemaCollection< 24 | Schema extends StorageSchema, 25 | Name extends SchemaCollectionName, 26 | > = Schema extends StorageSchema ? Cs[Name] : never; 27 | -------------------------------------------------------------------------------- /packages/common/src/schema/types/collection.ts: -------------------------------------------------------------------------------- 1 | import { CollectionCompoundIndices } from './compounds.js'; 2 | import { StorageFieldsSchema } from './fields.js'; 3 | import { 4 | DirectIndexableFieldName, 5 | StorageSyntheticIndices, 6 | } from './synthetics.js'; 7 | 8 | /** 9 | * The main collection schema 10 | */ 11 | export type StorageCollectionSchema< 12 | Fields extends StorageFieldsSchema = StorageFieldsSchema, 13 | Synthetics extends 14 | StorageSyntheticIndices = StorageSyntheticIndices, 15 | Compounds extends CollectionCompoundIndices< 16 | Fields, 17 | Synthetics 18 | > = CollectionCompoundIndices, 19 | > = { 20 | name: string; 21 | /** 22 | * Your primary key must be a string, number, or boolean field. It must also 23 | * not be rewritten. 24 | */ 25 | primaryKey: DirectIndexableFieldName; 26 | fields: Fields; 27 | indexes?: Synthetics; 28 | compounds?: Compounds; 29 | /** 30 | * @deprecated - plural name is the key used to index this collection in the schema. this field is no longer used. 31 | */ 32 | pluralName?: string; 33 | /** @deprecated - use "indexes" */ 34 | synthetics?: Synthetics; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/common/src/schema/types/filters.ts: -------------------------------------------------------------------------------- 1 | export type MatchCollectionIndexFilter = { 2 | where: string; 3 | equals: any; 4 | order?: 'asc' | 'desc'; 5 | }; 6 | 7 | export type RangeCollectionIndexFilter = { 8 | where: string; 9 | gte?: any; 10 | lte?: any; 11 | gt?: any; 12 | lt?: any; 13 | order?: 'asc' | 'desc'; 14 | }; 15 | 16 | export type CollectionCompoundIndexFilter = { 17 | where: string; 18 | match: Record; 19 | order: 'asc' | 'desc'; 20 | }; 21 | 22 | export type SortIndexFilter = { 23 | where: string; 24 | order: 'asc' | 'desc'; 25 | }; 26 | 27 | export type StartsWithIndexFilter = { 28 | where: string; 29 | startsWith: string; 30 | order?: 'asc' | 'desc'; 31 | }; 32 | 33 | export type CollectionIndexFilter = 34 | | MatchCollectionIndexFilter 35 | | RangeCollectionIndexFilter 36 | | CollectionCompoundIndexFilter 37 | | StartsWithIndexFilter 38 | | SortIndexFilter; 39 | export type CollectionFilter = CollectionIndexFilter; 40 | -------------------------------------------------------------------------------- /packages/common/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { assignOid, assignOidsToAllSubObjects, getOid } from './oids.js'; 3 | import { cloneDeep, getSortedIndex } from './utils.js'; 4 | 5 | describe('utils', () => { 6 | describe('get sorted index', () => { 7 | it('finds the right insertion index', () => { 8 | function compare(a: number, b: number) { 9 | return a - b; 10 | } 11 | expect(getSortedIndex([1, 2, 3, 4, 5], 6, compare)).toBe(5); 12 | expect(getSortedIndex([1, 2, 3, 4, 5], 0, compare)).toBe(0); 13 | expect(getSortedIndex([1, 2, 3, 4, 5], -1, compare)).toBe(0); 14 | expect(getSortedIndex([1, 2, 3, 4, 5], 20, compare)).toBe(5); 15 | }); 16 | }); 17 | 18 | describe('clone deep', () => { 19 | it('preserves OIDs', () => { 20 | const obj = { 21 | foo: 'bar', 22 | qux: [ 23 | { 24 | corge: true, 25 | }, 26 | ], 27 | }; 28 | assignOid(obj, 'test/a'); 29 | assignOidsToAllSubObjects(obj); 30 | 31 | const cloned = cloneDeep(obj); 32 | 33 | expect(getOid(cloned)).toEqual(getOid(obj)); 34 | expect(getOid(cloned.qux)).toEqual(getOid(obj.qux)); 35 | expect(getOid(cloned.qux[0])).toEqual(getOid(obj.qux[0])); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/common/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | clearMocks: true, 6 | environment: 'jsdom', 7 | }, 8 | resolve: { 9 | conditions: ['development', 'default'], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/create-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/create-app", 3 | "version": "0.6.1", 4 | "description": "Verdant app bootstrapper CLI", 5 | "main": "dist/index.js", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "bin": { 10 | "create-verdant-app": "dist/index.js" 11 | }, 12 | "type": "module", 13 | "scripts": { 14 | "build": "tsc -p tsconfig.json", 15 | "link": "pnpm link --global" 16 | }, 17 | "keywords": [], 18 | "author": "Grant Forrest", 19 | "license": "MIT", 20 | "dependencies": { 21 | "@clack/prompts": "^0.5.1", 22 | "cp-tpl": "^1.0.9", 23 | "fs-extra": "^11.1.0" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "^20.10.5", 27 | "typescript": "^5.4.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/create-app/templates/common/apps/web/src/components/updatePrompt/UpdatePrompt.tsx: -------------------------------------------------------------------------------- 1 | import { useRegisterSW } from 'virtual:pwa-register/react'; 2 | 3 | export interface UpdatePromptProps {} 4 | 5 | const TEST = false; 6 | 7 | /** 8 | * This is an example of a component that responds to the 9 | * service worker downloading a new version of the app and 10 | * prompts the user to update. 11 | * 12 | * To avoid sync complications for synced apps, you may 13 | * want to make this a blocking modal that prevents 14 | * the user from using the app until they update. 15 | */ 16 | export function UpdatePrompt({}: UpdatePromptProps) { 17 | const { 18 | needRefresh: [needRefresh], 19 | updateServiceWorker, 20 | } = useRegisterSW({ 21 | onRegisteredSW(swUrl, r) { 22 | console.log('Service worker registered', swUrl); 23 | r && 24 | setInterval(() => { 25 | r.update(); 26 | // hourly 27 | }, 60 * 60 * 1000); 28 | }, 29 | onRegisterError(error) { 30 | console.error('Service worker registration error', error); 31 | }, 32 | }); 33 | 34 | if (needRefresh || TEST) { 35 | return ( 36 |
37 | Update available! 38 | 39 |
40 | ); 41 | } 42 | 43 | return null; 44 | } 45 | -------------------------------------------------------------------------------- /packages/create-app/templates/common/packages/verdant/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}/verdant", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "src/index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "generate": "verdant -s ./src/schema.ts -o ./src/client -r", 9 | "preflight": "verdant preflight -s ./src/schema.ts -o ./src/client", 10 | "build": "pnpm run preflight" 11 | }, 12 | "peerDependencies": { 13 | "react": "^18.2.0" 14 | }, 15 | "dependencies": { 16 | "@verdant-web/cli": "^2.1.0", 17 | "@verdant-web/common": "^1.13.4", 18 | "@verdant-web/react": "^20.0.3", 19 | "@verdant-web/store": "^2.5.2", 20 | "cuid": "^2.1.8" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/create-app/templates/common/packages/verdant/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client/index.js'; 2 | export * from './client/react.js'; 3 | export { default as migrations } from './migrations/index.js'; 4 | -------------------------------------------------------------------------------- /packages/create-app/templates/common/packages/verdant/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/store'; 2 | import cuid from 'cuid'; 3 | 4 | /** 5 | * Welcome to your Verdant schema! 6 | * 7 | * The schema is where you define your data model. 8 | * 9 | * Read more at https://verdant.dev/docs/local-storage/schema 10 | * 11 | * The code below is provided as an example, but you'll 12 | * probably want to delete it and replace it with your 13 | * own schema. 14 | * 15 | * The schema is used to generate the client code for Verdant. 16 | * After you've replaced this example schema, run `pnpm generate -f` 17 | * in the root directory to bootstrap your client. 18 | * 19 | * For subsequent changes to your schema, use just `pnpm generate`. 20 | */ 21 | 22 | const items = schema.collection({ 23 | name: 'item', 24 | primaryKey: 'id', 25 | fields: { 26 | id: schema.fields.string({ 27 | default: cuid, 28 | }), 29 | content: schema.fields.string({ 30 | default: '', 31 | }), 32 | done: schema.fields.boolean({ 33 | default: false, 34 | }), 35 | createdAt: schema.fields.number({ 36 | default: () => Date.now(), 37 | }), 38 | }, 39 | indexes: { 40 | createdAt: { 41 | field: 'createdAt', 42 | }, 43 | }, 44 | }); 45 | 46 | export default schema({ 47 | version: 1, 48 | collections: { 49 | items, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /packages/create-app/templates/common/packages/verdant/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "baseUrl": ".", 8 | "lib": ["es2021", "dom"] 9 | }, 10 | "include": ["**/*.ts", "**/*.mts"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .github 4 | tests 5 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Production Deploy 2 | on: 3 | push: 4 | branches: [main] 5 | env: 6 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 7 | 8 | jobs: 9 | changed_files: 10 | name: Determine changed files 11 | runs-on: ubuntu-latest 12 | outputs: 13 | api: ${{ steps.api.outputs.any_changed }} 14 | ui: ${{ steps.ui.outputs.any_changed }} 15 | steps: 16 | - uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: Get API changed files 20 | id: api 21 | uses: tj-actions/changed-files@v23 22 | with: 23 | since_last_remote_commit: true 24 | files: | 25 | **/apps/api/src/**/*.ts 26 | **/apps/api/src/**/*.tsx 27 | **/packages/**/src/**/*.ts 28 | **/packages/**/src/**/*.tsx 29 | ./Dockerfile 30 | ./fly.toml 31 | **/apps/api/package.json 32 | ./pnpm-lock.yaml 33 | 34 | deploy: 35 | needs: [changed_files] 36 | runs-on: ubuntu-latest 37 | name: Deploy to production 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: superfly/flyctl-actions/setup-flyctl@master 41 | 42 | - name: Deploy API 43 | if: needs.changed_files.outputs.api == 'true' 44 | run: flyctl deploy --remote-only 45 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine3.16 AS base 2 | ENV CI=true 3 | 4 | RUN npm install -g pnpm 5 | WORKDIR /root/monorepo 6 | 7 | # add git 8 | RUN apk add --no-cache git 9 | 10 | # missing dep for turbo - mentioned by @nathanhammond 11 | # on https://github.com/vercel/turborepo/issues/2293 12 | RUN apk add --no-cache libc6-compat 13 | 14 | ENV CYPRESS_INSTALL_BINARY=0 15 | ENV PNPM_HOME=/usr/local/share/pnpm 16 | ENV PATH="$PNPM_HOME:$PATH" 17 | 18 | FROM base as dev 19 | COPY ./pnpm-lock.yaml . 20 | RUN pnpm fetch 21 | 22 | COPY . . 23 | 24 | RUN pnpm add --global ts-node 25 | 26 | RUN pnpm install --filter . --frozen-lockfile 27 | RUN pnpm install --filter "@{{todo}}/api..." --frozen-lockfile --unsafe-perm 28 | RUN pnpm --filter "@{{todo}}/api" run build 29 | RUN pnpm --filter "@{{todo}}/api" run gen 30 | 31 | WORKDIR /root/monorepo/apps/api 32 | EXPOSE 3001 33 | ENV NODE_ENV=production 34 | ENTRYPOINT ["pnpm", "start"] 35 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/.env-template: -------------------------------------------------------------------------------- 1 | VERDANT_DB_FILE=lofi.sqlite 2 | 3 | # This is the secret used to sign the JWT tokens 4 | 5 | # It should be nice and long and random 6 | 7 | LOFI_SECRET= 8 | 9 | # Local directory for storing uploaded files 10 | 11 | LOFI_FILE_STORAGE_ROOT=files 12 | 13 | # When you deploy, this should be a nice long random string 14 | 15 | SESSION_SECRET= 16 | 17 | # These are required for the built-in Google OAuth2 login. 18 | 19 | # You can get these by creating a Google OAuth2 client ID at 20 | 21 | # https://console.developers.google.com/apis/credentials 22 | 23 | # and setting the redirect URI to http://localhost:3001/api/auth/google/callback 24 | 25 | GOOGLE_AUTH_CLIENT_ID= 26 | GOOGLE_AUTH_CLIENT_SECRET= 27 | 28 | STRIPE_SECRET_KEY= 29 | STRIPE_WEBHOOK_SECRET= 30 | 31 | HOST=http://localhost:3001 32 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}]/api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "gaforres@gmail.com", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "env-cmd tsx watch ./src/server.ts", 9 | "prestart": "cd ../../packages/prisma && prisma migrate deploy && prisma db seed", 10 | "start": "pnpm prestart && tsx --tsconfig ./tsconfig.json ./src/server.ts", 11 | "start:local": "env-cmd pnpm start", 12 | "build": "tsc --build tsconfig.json" 13 | }, 14 | "dependencies": { 15 | "@{{todo}}/prisma": "workspace:*", 16 | "@verdant-web/common": "^1.13.4", 17 | "@verdant-web/server": "^1.8.8", 18 | "@types/better-sqlite3": "^7.6.0", 19 | "@types/body-parser": "^1.19.2", 20 | "@types/cookie": "^0.5.1", 21 | "@types/cors": "^2.8.12", 22 | "@types/express": "4.17.13", 23 | "@types/jsonwebtoken": "^8.5.8", 24 | "@types/node": "^18.0.0", 25 | "@types/ws": "^8.5.3", 26 | "better-sqlite3": "^7.6.2", 27 | "body-parser": "^1.20.0", 28 | "cookie": "^0.5.0", 29 | "cors": "^2.8.5", 30 | "cuid": "^3.0.0", 31 | "env-cmd": "^10.1.0", 32 | "express": "^4.18.1", 33 | "googleapis": "^105.0.0", 34 | "iso8601-duration": "^2.1.1", 35 | "jsonwebtoken": "^8.5.1", 36 | "stripe": "^11.12.0", 37 | "tsx": "^3.8.2", 38 | "typescript": "^5.4.2", 39 | "utf-8-validate": "^5.0.9", 40 | "ws": "^8.8.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/auth/google/login.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { setInviteIdCookie, setReturnToCookie } from '../../../cookies.js'; 3 | import { googleOauth } from '../../../lib/googleOAuth.js'; 4 | 5 | export default async function googleLoginHandler(req: Request, res: Response) { 6 | setReturnToCookie(req, res); 7 | setInviteIdCookie(req, res); 8 | 9 | const authorizationUrl = googleOauth.generateAuthUrl({ 10 | access_type: 'online', 11 | scope: [ 12 | 'https://www.googleapis.com/auth/userinfo.email', 13 | 'https://www.googleapis.com/auth/userinfo.profile', 14 | ], 15 | include_granted_scopes: true, 16 | }); 17 | 18 | res.writeHead(302, { Location: authorizationUrl }).end(); 19 | } 20 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { json, Router } from 'express'; 2 | import googleCallbackHandler from './google/callback.js'; 3 | import loginSuccessHandler from './loginSuccess.js'; 4 | import googleLoginHandler from './google/login.js'; 5 | import logoutHandler from './logout.js'; 6 | import sessionHandler from './session.js'; 7 | import verdantAuthHandler from './verdant.js'; 8 | 9 | const authRouter: Router = Router(); 10 | authRouter.use(json()); 11 | 12 | authRouter.post('/logout', logoutHandler); 13 | authRouter.get('/session', sessionHandler); 14 | authRouter.use('/loginSuccess', loginSuccessHandler); 15 | authRouter.post('/google/login', googleLoginHandler); 16 | authRouter.use('/google/callback', googleCallbackHandler); 17 | authRouter.use('/verdant', verdantAuthHandler); 18 | 19 | export default authRouter; 20 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/auth/loginSuccess.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { URL } from 'url'; 3 | import { getReturnToCookie, removeReturnToCookie } from '../../cookies.js'; 4 | import { uiHost } from '../../config.js'; 5 | 6 | export default async function loginSuccessHandler(req: Request, res: Response) { 7 | // read returnTo cookie to see if we have a redirect, 8 | // otherwise redirect to / 9 | 10 | const returnTo = getReturnToCookie(req) || '/'; 11 | 12 | // remove the cookie 13 | removeReturnToCookie(res); 14 | 15 | const returnToUrl = new URL(returnTo, uiHost); 16 | 17 | // redirect to returnTo 18 | res.writeHead(302, { Location: returnToUrl.toString() }); 19 | res.end(); 20 | } 21 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { removeTokenCookie } from '../../cookies.js'; 3 | import { uiHost } from '../../config.js'; 4 | 5 | export default async function logoutHandler(req: Request, res: Response) { 6 | removeTokenCookie(res); 7 | res.writeHead(302, { Location: uiHost }); 8 | res.end(); 9 | } 10 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/auth/session.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { getLoginSession, setLoginSession } from '../../session.js'; 3 | import { getSubscriptionStatusError } from '../../auth/subscriptions.js'; 4 | import { removeTokenCookie } from '../../cookies.js'; 5 | 6 | export default async function sessionHandler(req: Request, res: Response) { 7 | const session = await getLoginSession(req); 8 | if (!session) { 9 | return res.status(401).send({ 10 | error: 'Please log in', 11 | }); 12 | } 13 | 14 | const planStatusError = await getSubscriptionStatusError(session); 15 | 16 | if ( 17 | planStatusError === 'No account found' || 18 | planStatusError === 'Plan changed' 19 | ) { 20 | // our session is invalid, so we need to log the user out 21 | removeTokenCookie(res); 22 | return res.status(401).send({ 23 | error: planStatusError, 24 | }); 25 | } 26 | 27 | // refresh session 28 | await setLoginSession(res, session); 29 | 30 | res 31 | .status(200) 32 | .json({ 33 | session, 34 | isSubscribed: !planStatusError, 35 | planStatus: planStatusError || 'Subscribed', 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/auth/verdant.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { getLoginSession } from '../../session.js'; 3 | import { verdantSecret, serverHost } from '../../config.js'; 4 | import { ReplicaType, TokenProvider } from '@verdant-web/server'; 5 | import { verifySubscription } from '../../auth/subscriptions.js'; 6 | 7 | const tokenProvider = new TokenProvider({ 8 | secret: verdantSecret, 9 | }); 10 | 11 | export default async function verdantAuthHandler(req: Request, res: Response) { 12 | const session = await getLoginSession(req); 13 | if (!session) { 14 | return res.status(401).send('Please log in'); 15 | } 16 | 17 | try { 18 | await verifySubscription(session); 19 | 20 | const token = tokenProvider.getToken({ 21 | userId: session.userId, 22 | libraryId: session.planId, 23 | syncEndpoint: `${serverHost}/verdant`, 24 | type: ReplicaType.Realtime, 25 | }); 26 | 27 | res.status(200).json({ accessToken: token }); 28 | } catch (e) { 29 | res.status(402).send('Please upgrade your subscription'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import authRouter from './auth/index.js'; 3 | import planRouter from './plan/index.js'; 4 | import stripeRouter from './stripe/index.js'; 5 | 6 | const apiRouter: Router = Router(); 7 | 8 | apiRouter.use('/auth', authRouter); 9 | apiRouter.use('/plan', planRouter); 10 | apiRouter.use('/stripe', stripeRouter); 11 | 12 | export default apiRouter; 13 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/plan/create.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { getLoginSession, setLoginSession } from '../../session.js'; 3 | import { prisma } from '@{{todo}}/prisma'; 4 | 5 | export async function createPlanHandler(req: Request, res: Response) { 6 | const session = await getLoginSession(req); 7 | 8 | if (!session) { 9 | return res.status(401).send('Please log in'); 10 | } 11 | 12 | if (session.planId) { 13 | return res.status(400).send('You already have a plan'); 14 | } 15 | 16 | const plan = await prisma.plan.create({ 17 | data: { 18 | members: { 19 | connect: { 20 | id: session.userId, 21 | }, 22 | }, 23 | }, 24 | }); 25 | 26 | await setLoginSession(res, { 27 | ...session, 28 | planId: plan.id, 29 | }); 30 | 31 | return res.status(200).json({ 32 | planId: plan.id, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/plan/index.ts: -------------------------------------------------------------------------------- 1 | import { json, Router } from 'express'; 2 | import { createPlanHandler } from './create.js'; 3 | import { 4 | claimPlanInviteHandler, 5 | createPlanInviteHandler, 6 | planInviteDetailsHandler, 7 | } from './invite.js'; 8 | import planStatusHandler from './status.js'; 9 | 10 | const planRouter: Router = Router(); 11 | planRouter.use(json()); 12 | 13 | planRouter.get('/', planStatusHandler); 14 | planRouter.post('/', createPlanHandler); 15 | planRouter.post('/invite', createPlanInviteHandler); 16 | planRouter.post('/invite/claim/:inviteId', claimPlanInviteHandler); 17 | planRouter.get('/invite/:inviteId', planInviteDetailsHandler); 18 | 19 | export default planRouter; 20 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/plan/status.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { getLoginSession } from '../../session.js'; 3 | import { getSubscriptionStatusError } from '../../auth/subscriptions.js'; 4 | 5 | export default async function planStatusHandler(req: Request, res: Response) { 6 | const session = await getLoginSession(req); 7 | if (!session) { 8 | return res.status(401).send('Please log in'); 9 | } 10 | const statusError = await getSubscriptionStatusError(session); 11 | 12 | return res.status(200).json({ 13 | planId: session.planId, 14 | statusError, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/stripe/createPortal.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | import { getLoginSession } from '../../session.js'; 3 | import { uiHost } from '../../config.js'; 4 | import { prisma } from '@{{todo}}/prisma'; 5 | import { stripe } from '../../lib/stripe.js'; 6 | 7 | export async function createPortalHandler(req: Request, res: Response) { 8 | const session = await getLoginSession(req); 9 | if (!session) { 10 | return res.status(401).send('Please log in'); 11 | } 12 | 13 | const plan = await prisma.plan.findUnique({ 14 | where: { id: session.planId }, 15 | }); 16 | 17 | if (!plan) { 18 | return res.status(400).send('You do not have a plan'); 19 | } 20 | 21 | if (!plan.stripeCustomerId) { 22 | return res.status(400).send('No subscription'); 23 | } 24 | 25 | const portalSession = await stripe.billingPortal.sessions.create({ 26 | customer: plan.stripeCustomerId, 27 | return_url: uiHost, 28 | }); 29 | 30 | return res.redirect(portalSession.url); 31 | } 32 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/api/stripe/index.ts: -------------------------------------------------------------------------------- 1 | import { raw, Router } from 'express'; 2 | import { createCheckoutHandler } from './createCheckout.js'; 3 | import { createPortalHandler } from './createPortal.js'; 4 | import { webhookHandler } from './webhook.js'; 5 | 6 | const stripeRouter: Router = Router(); 7 | 8 | stripeRouter.post('/create-checkout', createCheckoutHandler); 9 | stripeRouter.post('/create-portal', createPortalHandler); 10 | stripeRouter.post( 11 | '/webhook', 12 | raw({ type: 'application/json' }), 13 | webhookHandler, 14 | ); 15 | 16 | export default stripeRouter; 17 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/lib/googleOAuth.ts: -------------------------------------------------------------------------------- 1 | import { google, Auth } from 'googleapis'; 2 | import { 3 | googleAuthClientId, 4 | googleAuthClientSecret, 5 | serverHost, 6 | } from '../config.js'; 7 | 8 | export const googleOauth: Auth.OAuth2Client = new google.auth.OAuth2( 9 | googleAuthClientId, 10 | googleAuthClientSecret, 11 | `${serverHost}/api/auth/google/callback`, 12 | ); 13 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { stripeSecretKey } from '../config.js'; 3 | 4 | export const stripe = new Stripe(stripeSecretKey, { 5 | apiVersion: '2022-08-01', 6 | }); 7 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/src/verdant.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@{{todo}}/prisma'; 2 | import { Server, UserProfiles, LocalFileStorage } from '@verdant-web/server'; 3 | import { Server as HttpServer } from 'http'; 4 | import { 5 | verdantDbFile, 6 | verdantFileStorageRoot, 7 | verdantSecret, 8 | serverHost, 9 | } from './config.js'; 10 | 11 | class Profiles 12 | implements 13 | UserProfiles<{ 14 | id: string; 15 | name: string; 16 | imageUrl: string | null; 17 | }> 18 | { 19 | get = async (userId: string) => { 20 | const user = await prisma.user.findUnique({ 21 | where: { 22 | id: userId, 23 | }, 24 | }); 25 | if (user) { 26 | return { 27 | id: user.id, 28 | name: user.name, 29 | imageUrl: user.imageUrl, 30 | }; 31 | } else { 32 | return { 33 | id: userId, 34 | name: 'Anonymous', 35 | imageUrl: null, 36 | }; 37 | } 38 | }; 39 | } 40 | 41 | export function attachVerdantServer(httpServer: HttpServer) { 42 | const verdant = new Server({ 43 | httpServer, 44 | databaseFile: verdantDbFile, 45 | tokenSecret: verdantSecret, 46 | profiles: new Profiles(), 47 | replicaTruancyMinutes: 7 * 24 * 60, 48 | fileStorage: new LocalFileStorage({ 49 | rootDirectory: verdantFileStorageRoot, 50 | host: serverHost, 51 | }), 52 | }); 53 | 54 | verdant.on('error', console.error); 55 | 56 | return verdant; 57 | } 58 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/.env-template: -------------------------------------------------------------------------------- 1 | VITE_API_HOST=http://localhost:3001 2 | VITE_PUBLIC_URL=http://localhost:3000 3 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}/web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@{{todo}}/verdant": "workspace:*", 13 | "@tanstack/react-query": "^4.24.10", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-router-dom": "^6.8.2", 17 | "workbox-core": "^6.5.4", 18 | "workbox-expiration": "^6.5.4", 19 | "workbox-precaching": "^6.5.4", 20 | "workbox-routing": "^6.5.4", 21 | "workbox-strategies": "^6.5.4", 22 | "workbox-window": "^6.5.4" 23 | }, 24 | "devDependencies": { 25 | "@types/react": "^18.0.28", 26 | "@types/react-dom": "^18.0.11", 27 | "@vitejs/plugin-react": "^3.1.0", 28 | "vite": "^3.1.0", 29 | "vite-plugin-pwa": "^0.12.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/public/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/create-app/templates/full/apps/web/public/192x192.png -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/public/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/create-app/templates/full/apps/web/public/512x512.png -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/create-app/templates/full/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { UpdatePrompt } from '@/components/updatePrompt/UpdatePrompt.jsx'; 2 | import { useIsLoggedIn } from '@/hooks/useSession.js'; 3 | import { Pages } from '@/pages/Pages.jsx'; 4 | import { clientDescriptor, hooks } from '@/store.js'; 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 6 | import { ReactNode, Suspense, useState } from 'react'; 7 | import { CompleteSubscription } from './components/subscription/CompleteSubscription.jsx'; 8 | 9 | export interface AppProps {} 10 | 11 | export function App({}: AppProps) { 12 | const [queryClient] = useState(() => new QueryClient()); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | function LofiProvider({ children }: { children: ReactNode }) { 28 | // only sync if logged in to the server 29 | const isLoggedIn = useIsLoggedIn(); 30 | return ( 31 | 32 | {children} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/components/auth/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { Link, LinkProps } from 'react-router-dom'; 3 | 4 | export function LoginButton({ 5 | returnTo, 6 | children, 7 | className, 8 | inviteId, 9 | ...rest 10 | }: { 11 | returnTo?: string; 12 | children?: ReactNode; 13 | inviteId?: string; 14 | } & Omit) { 15 | return ( 16 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/components/auth/OAuthSigninButton.tsx: -------------------------------------------------------------------------------- 1 | import { apiHost } from '@/config.js'; 2 | import { ReactNode } from 'react'; 3 | 4 | export function OAuthSignInButton({ 5 | provider, 6 | returnTo, 7 | children, 8 | className, 9 | inviteId, 10 | ...rest 11 | }: { 12 | provider: string; 13 | returnTo?: string | null; 14 | children?: ReactNode; 15 | inviteId?: string | null; 16 | className?: string; 17 | }) { 18 | const url = new URL( 19 | `${apiHost}/api/auth/${provider}/login`, 20 | ); 21 | if (returnTo) { 22 | url.searchParams.set('returnTo', returnTo); 23 | } 24 | if (inviteId) { 25 | url.searchParams.set('inviteId', inviteId); 26 | } 27 | 28 | return ( 29 |
30 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/components/subscription/CompleteSubscription.tsx: -------------------------------------------------------------------------------- 1 | import { apiHost } from '@/config.js'; 2 | import { useSession } from '@/hooks/useSession.js'; 3 | 4 | export interface CompleteSubscriptionProps {} 5 | 6 | /** 7 | * Renders a button to complete subscription signup 8 | * after a user signs up for an account 9 | */ 10 | export function CompleteSubscription({}: CompleteSubscriptionProps) { 11 | const { data, isLoading } = useSession(); 12 | 13 | // only show if the user has signed up for an account 14 | // but has not yet subscribed 15 | const show = !isLoading && data?.session && data?.isSubscribed === false; 16 | 17 | if (!show) return null; 18 | 19 | return ( 20 |
21 | 22 | 23 | Cancel anytime. Your list will still be on this device forever. 24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/components/subscription/ManageSubscriptionButton.tsx: -------------------------------------------------------------------------------- 1 | import { apiHost } from '@/config.js'; 2 | 3 | export interface ManageSubscriptionButtonProps { 4 | className?: string; 5 | } 6 | 7 | export function ManageSubscriptionButton({ 8 | className, 9 | ...props 10 | }: ManageSubscriptionButtonProps) { 11 | return ( 12 |
17 | 20 | Update your card or unsubscribe 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/config.ts: -------------------------------------------------------------------------------- 1 | export const apiHost = import.meta.env.VITE_API_HOST || 'http://localhost:3001'; 2 | export const host = 3 | import.meta.env.VITE_PUBLIC_URL || 4 | import.meta.env.VITE_HOST || 5 | 'http://localhost:3000'; 6 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/hooks/useCreateInviteLink.ts: -------------------------------------------------------------------------------- 1 | import { apiHost, host } from '@/config.js'; 2 | import { useCallback } from 'react'; 3 | 4 | export function useCreateInviteLink() { 5 | return useCallback(async function generateLink() { 6 | const res = await fetch(`${apiHost}/api/plan/invite`, { 7 | method: 'post', 8 | headers: { 9 | Accept: 'application/json', 10 | }, 11 | credentials: 'include', 12 | }); 13 | if (res.ok) { 14 | const resp = await res.json(); 15 | const link = `${host}/claim/${resp.inviteId}`; 16 | return link; 17 | } else { 18 | throw new Error('Failed to generate invite link'); 19 | } 20 | }, []); 21 | } 22 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { App } from './App.js'; 4 | 5 | function main() { 6 | const root = createRoot(document.getElementById('root')!); 7 | root.render( 8 | 9 | 10 | , 11 | ); 12 | } 13 | 14 | main(); 15 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { TodoList } from '@/components/todos/TodoList.jsx'; 3 | 4 | export interface HomePageProps {} 5 | 6 | export function HomePage({}: HomePageProps) { 7 | return ( 8 |
9 |

Hello Verdant!

10 |
    11 |
  • 12 | Login or sign up 13 |
  • 14 |
  • 15 | Settings 16 |
  • 17 |
18 | 19 |
20 | ); 21 | } 22 | 23 | export default HomePage; 24 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/pages/JoinPage.tsx: -------------------------------------------------------------------------------- 1 | import { OAuthSignInButton } from '@/components/auth/OAuthSigninButton.jsx'; 2 | import { useSearchParams } from 'react-router-dom'; 3 | 4 | export interface JoinPageProps {} 5 | 6 | export function JoinPage({}: JoinPageProps) { 7 | const [params] = useSearchParams({ returnTo: '/', inviteId: '' }); 8 | 9 | let returnTo = params.get('returnTo') || undefined; 10 | 11 | return ( 12 |
13 |

Join

14 |
15 | 16 | Sign up with Google 17 | 18 |
19 |
20 | ); 21 | } 22 | 23 | export default JoinPage; 24 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/pages/Pages.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 2 | import { HomePage } from './HomePage.jsx'; 3 | import { lazy } from 'react'; 4 | 5 | // dynamically import pages that may not be visited 6 | const JoinPage = lazy(() => import('./JoinPage.jsx')); 7 | const ClaimInvitePage = lazy(() => import('./ClaimInvitePage.jsx')); 8 | const SettingsPage = lazy(() => import('./SettingsPage.jsx')); 9 | 10 | const router = createBrowserRouter([ 11 | { 12 | path: '/', 13 | element: , 14 | }, 15 | { 16 | path: '/join', 17 | element: , 18 | }, 19 | { 20 | path: '/claim/:inviteId', 21 | element: , 22 | }, 23 | { 24 | path: '/settings', 25 | element: , 26 | }, 27 | ]); 28 | 29 | export function Pages() { 30 | return ; 31 | } 32 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/src/pages/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import { ManageSubscriptionButton } from '@/components/subscription/ManageSubscriptionButton.jsx'; 2 | import { useCreateInviteLink } from '@/hooks/useCreateInviteLink.js'; 3 | import { useSession } from '@/hooks/useSession.js'; 4 | import { useCallback, useState } from 'react'; 5 | 6 | export interface SettingsPageProps {} 7 | 8 | export function SettingsPage({}: SettingsPageProps) { 9 | const { data } = useSession(); 10 | 11 | return ( 12 |
13 |

Settings

14 | {!!data && } 15 | {data?.isSubscribed && } 16 |
17 | ); 18 | } 19 | 20 | function InviteLink() { 21 | const [link, setLink] = useState(''); 22 | const createLink = useCreateInviteLink(); 23 | 24 | const generate = useCallback(async () => { 25 | setLink(await createLink()); 26 | }, [createLink, setLink]); 27 | 28 | return ( 29 |
30 | 31 | 32 |
33 | ); 34 | } 35 | 36 | export default SettingsPage; 37 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "outDir": "./dist", 6 | "rootDir": ".", 7 | "baseUrl": ".", 8 | "lib": ["es2017", "dom", "dom.iterable", "ES2021"], 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | "types": ["vite/client", "vite-plugin-pwa"] 13 | }, 14 | "include": ["src"], 15 | } 16 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/apps/web/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] 3 | } 4 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/fly.toml: -------------------------------------------------------------------------------- 1 | app = "{{todo}}" 2 | kill_signal = "SIGINT" 3 | kill_timeout = 5 4 | processes = [] 5 | 6 | [build] 7 | dockerfile = "Dockerfile" 8 | 9 | [env] 10 | DATABASE_URL = "file:/data/{{todo}}.db" 11 | VERDANT_DB_FILE = "/data/{{todo}}-lofi.db" 12 | HOST = "https://{{todo}}.todo" 13 | 14 | [experimental] 15 | allowed_public_ports = [] 16 | auto_rollback = true 17 | 18 | [[services]] 19 | internal_port = 3001 20 | protocol = "tcp" 21 | [services.concurrency] 22 | type = "connections" 23 | # don't limit websocket connections 24 | hard_limit = 10000 25 | soft_limit = 10000 26 | 27 | [[services.ports]] 28 | force_https = true 29 | handlers = ["http"] 30 | port = 80 31 | 32 | [[services.ports]] 33 | handlers = ["tls", "http"] 34 | port = 443 35 | 36 | [[services.http_checks]] 37 | interval = 10000 38 | grace_period = "5s" 39 | method = "get" 40 | path = "/" 41 | protocol = "http" 42 | restart_limit = 0 43 | timeout = 2000 44 | tls_skip_verify = false 45 | 46 | [mounts] 47 | source = "{{todo}}_data" 48 | destination = "/data" 49 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}/root", 3 | "version": "1.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "dev": "turbo run dev", 11 | "build": "turbo run build", 12 | "test": "turbo run test", 13 | "prisma": "pnpm --filter @{{todo}}/prisma run prisma", 14 | "generate": "pnpm --filter @{{todo}}/verdant run generate", 15 | "preflight": "pnpm --filter @{{todo}}/verdant run preflight" 16 | }, 17 | "devDependencies": { 18 | "turbo": "^1.6.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/packages/prisma/.env-template: -------------------------------------------------------------------------------- 1 | DATABASE_URL=file:./db.sqlite 2 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/packages/prisma/.gitignore: -------------------------------------------------------------------------------- 1 | .generated/ 2 | *.sqlite 3 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/packages/prisma/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from './.generated/prisma/index.js'; 2 | export * from './.generated/prisma/index.js'; 3 | 4 | export const prisma = new PrismaClient(); 5 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/packages/prisma/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}/prisma", 3 | "version": "0.0.1", 4 | "private": true, 5 | "main": "index.ts", 6 | "type": "module", 7 | "scripts": { 8 | "prisma": "prisma", 9 | "postinstall": "prisma generate", 10 | "create-migration": "prisma migrate dev" 11 | }, 12 | "dependencies": { 13 | "@prisma/client": "^4.10.1", 14 | "prisma": "^4.10.1" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^18.7.20", 18 | "typescript": "^5.4.2" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/packages/prisma/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": ".", 7 | "baseUrl": "." 8 | }, 9 | "include": ["*.ts", "*.mts", "prisma/*.ts", "./typings"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'apps/*' 4 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noLib": false, 5 | "target": "es2017", 6 | "sourceMap": true, 7 | "module": "es2022", 8 | "moduleResolution": "NodeNext", 9 | "lib": ["dom", "esnext", "webworker"], 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "jsx": "react-jsx", 17 | "downlevelIteration": true, 18 | "experimentalDecorators": true, 19 | "isolatedModules": true 20 | }, 21 | "exclude": ["node_modules", "**/*/dist"], 22 | "references": [ 23 | { 24 | "path": "./packages/prisma" 25 | }, 26 | { 27 | "path": "./packages/verdant" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/create-app/templates/full/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": [".next/**", "dist/**"] 7 | }, 8 | "test": { 9 | "outputs": [], 10 | "inputs": ["**/*.tsx", "**/*.ts", "**/*.test.tsx", "**/*.test.ts"] 11 | }, 12 | "dev": { 13 | "cache": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/README.md: -------------------------------------------------------------------------------- 1 | # {{todo}} 2 | 3 | Welcome to your Verdant PWA! 4 | 5 | This is a very opinionated starter aimed at getting you working on your idea on day 1, not fussing with tools or common setup. 6 | 7 | It comes with a bunch of things out of the box. Some stuff you may not want to keep, but it should serve as a guide on what to build in replacement. 8 | 9 | # 👀 Your Checklist 👀 10 | 11 | Here's what you need to do before your app is ready to use: 12 | 13 | - [ ] Edit `./packages/verdant/src/schema.ts` and add your first Verdant schema 14 | - [ ] Run `pnpm generate` to generate the Verdant client 15 | - [ ] Run `pnpm prisma migrate dev` to set up the database 16 | 17 | Finally, run `pnpm dev` to start the API and PWA in parallel. 18 | 19 | ## Client 20 | 21 | ### PWA 22 | 23 | ## Server 24 | 25 | ### Authentication and users 26 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/.env-template: -------------------------------------------------------------------------------- 1 | VITE_API_HOST=http://localhost:3001 2 | VITE_PUBLIC_URL=http://localhost:3000 3 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}/web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@{{todo}}/verdant": "workspace:*", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-router-dom": "^6.8.2", 16 | "workbox-core": "^6.5.4", 17 | "workbox-expiration": "^6.5.4", 18 | "workbox-precaching": "^6.5.4", 19 | "workbox-routing": "^6.5.4", 20 | "workbox-strategies": "^6.5.4", 21 | "workbox-window": "^6.5.4" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^18.0.28", 25 | "@types/react-dom": "^18.0.11", 26 | "@vitejs/plugin-react": "^3.1.0", 27 | "vite": "^3.1.0", 28 | "vite-plugin-pwa": "^0.12.7" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/public/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/create-app/templates/local/apps/web/public/192x192.png -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/public/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/create-app/templates/local/apps/web/public/512x512.png -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/create-app/templates/local/apps/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { UpdatePrompt } from '@/components/updatePrompt/UpdatePrompt.jsx'; 2 | import { clientDescriptor, hooks } from '@/store.js'; 3 | import { ReactNode, Suspense } from 'react'; 4 | import { Pages } from '@/pages/Pages.jsx'; 5 | 6 | export interface AppProps {} 7 | 8 | export function App({}: AppProps) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | function VerdantProvider({ children }: { children: ReactNode }) { 20 | return {children}; 21 | } 22 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/src/config.ts: -------------------------------------------------------------------------------- 1 | export const host = 2 | import.meta.env.VITE_PUBLIC_URL || 3 | import.meta.env.VITE_HOST || 4 | 'http://localhost:3000'; 5 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { App } from './App.js'; 4 | 5 | function main() { 6 | const root = createRoot(document.getElementById('root')!); 7 | root.render( 8 | 9 | 10 | , 11 | ); 12 | } 13 | 14 | main(); 15 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { TodoList } from '@/components/todos/TodoList.jsx'; 2 | 3 | export interface HomePageProps {} 4 | 5 | export function HomePage({}: HomePageProps) { 6 | return ( 7 |
8 |

Hello Verdant!

9 | 10 |
11 | ); 12 | } 13 | 14 | export default HomePage; 15 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/src/pages/Pages.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 2 | import { HomePage } from './HomePage.jsx'; 3 | import { lazy } from 'react'; 4 | 5 | const router = createBrowserRouter([ 6 | { 7 | path: '/', 8 | element: , 9 | }, 10 | ]); 11 | 12 | export function Pages() { 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "outDir": "./dist", 6 | "rootDir": ".", 7 | "baseUrl": ".", 8 | "lib": ["es2017", "dom", "dom.iterable", "ES2021"], 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | }, 12 | "types": ["vite/client", "vite-plugin-pwa"] 13 | }, 14 | "include": ["src"], 15 | } 16 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/apps/web/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }] 3 | } 4 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@{{todo}}/root", 3 | "version": "1.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "dev": "turbo run dev", 11 | "build": "turbo run build", 12 | "test": "turbo run test", 13 | "prisma": "pnpm --filter @{{todo}}/prisma run prisma", 14 | "generate": "pnpm --filter @{{todo}}/verdant run generate", 15 | "preflight": "pnpm --filter @{{todo}}/verdant run preflight" 16 | }, 17 | "devDependencies": { 18 | "turbo": "^1.6.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'apps/*' 4 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noLib": false, 5 | "target": "es2017", 6 | "sourceMap": true, 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "lib": ["dom", "esnext", "webworker"], 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "jsx": "react-jsx", 17 | "downlevelIteration": true, 18 | "experimentalDecorators": true, 19 | "isolatedModules": true 20 | }, 21 | "exclude": ["node_modules", "**/*/dist"], 22 | "references": [ 23 | { 24 | "path": "./packages/verdant" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /packages/create-app/templates/local/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": [".next/**", "dist/**"] 7 | }, 8 | "test": { 9 | "outputs": [], 10 | "inputs": ["**/*.tsx", "**/*.ts", "**/*.test.tsx", "**/*.test.ts"] 11 | }, 12 | "dev": { 13 | "cache": false 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/create-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/file-storage-s3/README.md: -------------------------------------------------------------------------------- 1 | # verdant server 2 | 3 | This server is designed to sync data from clients. That includes replication between clients and realtime updates, including presence. 4 | 5 | The server includes flexibility of transport - using either websockets or push/pull HTTP requests. To do this cleanly, the concept of a `clientKey` is employed. Each client connection (socket or individual request) is assigned a key and added to a collection (based on its associated library). When the server responds to a client, abstractly, it submits that response to the client's key. The connection management code then either sends that response as a socket message, or includes it in the HTTP response. 6 | -------------------------------------------------------------------------------- /packages/file-storage-s3/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/s3-file-storage", 3 | "version": "1.0.38", 4 | "access": "public", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "development": "./src/index.ts", 9 | "import": "./dist/esm/index.js", 10 | "types": "./dist/types/index.d.ts" 11 | } 12 | }, 13 | "publishConfig": { 14 | "exports": { 15 | ".": { 16 | "import": "./dist/esm/index.js", 17 | "types": "./dist/types/index.d.ts" 18 | } 19 | }, 20 | "access": "public" 21 | }, 22 | "files": [ 23 | "dist/", 24 | "src/" 25 | ], 26 | "scripts": { 27 | "build": "tsc -p tsconfig.json", 28 | "prepublish": "pnpm run build", 29 | "link": "pnpm link --global", 30 | "//test": "vitest" 31 | }, 32 | "dependencies": { 33 | "@aws-sdk/client-s3": "^3.456.0", 34 | "@aws-sdk/lib-storage": "^3.456.0", 35 | "@verdant-web/server": "workspace:*" 36 | }, 37 | "devDependencies": { 38 | "typescript": "^5.4.2", 39 | "vitest": "^2.0.5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/file-storage-s3/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/file-storage-s3/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | clearMocks: true, 6 | environment: 'node', 7 | }, 8 | resolve: { 9 | conditions: ['development', 'default'], 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/persistence-capacitor-sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/persistence-capacitor-sqlite", 3 | "version": "3.0.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "development": "./src/index.ts", 8 | "import": "./dist/esm/index.js", 9 | "types": "./dist/esm/index.d.ts" 10 | } 11 | }, 12 | "publishConfig": { 13 | "exports": { 14 | ".": { 15 | "import": "./dist/esm/index.js", 16 | "types": "./dist/esm/index.d.ts" 17 | } 18 | }, 19 | "access": "public" 20 | }, 21 | "files": [ 22 | "dist/", 23 | "src/" 24 | ], 25 | "scripts": { 26 | "test": "vitest", 27 | "build": "tsc", 28 | "prepublishOnly": "pnpm run build" 29 | }, 30 | "dependencies": { 31 | "@capacitor-community/sqlite": "^6.0.2", 32 | "@capacitor/filesystem": "^6.0.1", 33 | "@verdant-web/persistence-sqlite": "workspace:*", 34 | "capacitor-sqlite-kysely": "^1.0.1", 35 | "kysely": "^0.27.5" 36 | }, 37 | "peerDependencies": { 38 | "@verdant-web/store": "^4.3.0" 39 | }, 40 | "devDependencies": { 41 | "@verdant-web/store": "workspace:*", 42 | "vitest": "2.1.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/persistence-capacitor-sqlite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm", 6 | "lib": ["esnext", "dom"], 7 | "moduleResolution": "bundler", 8 | "module": "es2020" 9 | }, 10 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/persistence-capacitor-sqlite/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | clearMocks: true, 7 | 8 | setupFiles: ['src/__tests__/setup/indexedDB.ts'], 9 | }, 10 | resolve: { 11 | conditions: ['development', 'default'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/persistence-sqlite", 3 | "version": "3.0.0", 4 | "type": "module", 5 | "exports": { 6 | ".": { 7 | "development": "./src/index.ts", 8 | "import": "./dist/esm/index.js", 9 | "types": "./dist/esm/index.d.ts" 10 | } 11 | }, 12 | "publishConfig": { 13 | "exports": { 14 | ".": { 15 | "import": "./dist/esm/index.js", 16 | "types": "./dist/esm/index.d.ts" 17 | } 18 | }, 19 | "access": "public" 20 | }, 21 | "files": [ 22 | "dist/", 23 | "src/" 24 | ], 25 | "scripts": { 26 | "test": "vitest", 27 | "build": "tsc", 28 | "prepublishOnly": "pnpm run build" 29 | }, 30 | "dependencies": {}, 31 | "peerDependencies": { 32 | "@verdant-web/store": "^4.3.0", 33 | "kysely": "^0.27.5" 34 | }, 35 | "devDependencies": { 36 | "@verdant-web/store": "workspace:*", 37 | "kysely": "^0.27.5", 38 | "vitest": "2.1.3" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/src/SqliteService.ts: -------------------------------------------------------------------------------- 1 | import { QueryMode } from '@verdant-web/store'; 2 | import { Disposable } from '@verdant-web/store/internal'; 3 | import { Database, Tables, Transaction } from './kysely.js'; 4 | 5 | export class SqliteService extends Disposable { 6 | private globalAbortController; 7 | 8 | constructor(protected db: Database) { 9 | super(); 10 | const abortController = new AbortController(); 11 | const abort = abortController.abort.bind(abortController); 12 | this.globalAbortController = abortController; 13 | // this.addDispose(abort); 14 | } 15 | 16 | transaction = async ( 17 | opts: { mode?: QueryMode; storeNames: string[]; abort?: AbortSignal }, 18 | procedure: (tx: Transaction) => Promise, 19 | ): Promise => { 20 | if (this.globalAbortController.signal.aborted) { 21 | throw new Error('Global abort signal is already aborted'); 22 | } 23 | // return this.db.transaction().execute(procedure); 24 | const result = await procedure(this.db as any); 25 | return result; 26 | }; 27 | 28 | protected tableStats = async (tableName: keyof Tables) => { 29 | return await this.db 30 | .selectFrom(tableName) 31 | .select((eb) => [eb.fn.countAll().as('count')]) 32 | .executeTakeFirst(); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './SqlitePersistence.js'; 2 | export * from './nodeFilesystem.js'; 3 | export type * from './interfaces.js'; 4 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface FilesystemImplementation { 2 | writeFile: (path: string, data: Blob) => Promise; 3 | readDirectory: (path: string) => Promise; 4 | copyDirectory: (options: { from: string; to: string }) => Promise; 5 | copyFile: (options: { from: string; to: string }) => Promise; 6 | deleteFile: (path: string) => Promise; 7 | readFile: (path: string) => Promise; 8 | } 9 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/src/metadata/migrations/index.ts: -------------------------------------------------------------------------------- 1 | export * as v0001 from './v0001-initial.js' 2 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/src/sqlContext.ts: -------------------------------------------------------------------------------- 1 | export interface SqlContext { 2 | migrationLock?: Promise; 3 | } 4 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm", 6 | "lib": ["esnext", "dom"] 7 | }, 8 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/persistence-sqlite/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | clearMocks: true, 7 | 8 | setupFiles: ['src/__tests__/setup/indexedDB.ts'], 9 | }, 10 | resolve: { 11 | conditions: ['development', 'default'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/react-router/README.md: -------------------------------------------------------------------------------- 1 | # @verdant-web/react-router 2 | 3 | A small, client-focused router for React with cutting-edge Suspense and Transitions support. Designed to make your PWA feel like a native app. 4 | 5 | ## Usage 6 | 7 | See the [verdant docs](https://verdant.dev/docs/react-router) 8 | -------------------------------------------------------------------------------- /packages/react-router/demo/ScrollTester.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { Link, RestoreScroll } from '../src'; 3 | 4 | export function ScrollTester(props: any) { 5 | return ( 6 |
7 | 8 | {new Array(10).fill(0).map((_, i) => ( 9 |
19 |
{i}
20 |
21 | ))} 22 |
23 | ); 24 | } 25 | 26 | export function ContainerScrollTester(props: any) { 27 | const ref = useRef(null); 28 | return ( 29 |
37 | 38 | {new Array(10).fill(0).map((_, i) => ( 39 |
49 |
{i}
50 |
51 | ))} 52 | Search params 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /packages/react-router/demo/data/fakePosts.ts: -------------------------------------------------------------------------------- 1 | import { delay } from './utils.js'; 2 | 3 | type Post = { 4 | id: string; 5 | title: string; 6 | content: string; 7 | }; 8 | 9 | const preloads = new Map>(); 10 | const preloaded = new Map(); 11 | 12 | export async function loadPost(id: string, debugSource: string) { 13 | console.log(`loadPost(${id}) from ${debugSource}`); 14 | if (preloaded.has(id)) { 15 | console.log(`already preloaded this post, bailing...`); 16 | return preloaded.get(id)!; 17 | } 18 | if (preloads.has(id)) { 19 | console.log(`already preloading this post, bailing...`); 20 | return preloads.get(id); 21 | } 22 | const preload = delay(2000).then(() => { 23 | preloads.delete(id); 24 | preloaded.set(id, { 25 | id, 26 | title: `Post ${id}`, 27 | content: `This is post ${id}.`, 28 | }); 29 | return preloaded.get(id)!; 30 | }); 31 | preloads.set(id, preload); 32 | return preload; 33 | } 34 | 35 | export function getPost(id: string) { 36 | return preloaded.get(id); 37 | } 38 | -------------------------------------------------------------------------------- /packages/react-router/demo/data/utils.ts: -------------------------------------------------------------------------------- 1 | export function delay(time: number) { 2 | return new Promise((resolve) => setTimeout(resolve, time)); 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-router/demo/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | display: flex; 9 | flex-direction: column; 10 | min-height: 100vh; 11 | } 12 | 13 | #root { 14 | display: flex; 15 | flex-direction: column; 16 | flex: 1; 17 | } 18 | 19 | a { 20 | font-weight: bold; 21 | } 22 | 23 | a[data-transitioning='true'] { 24 | opacity: 0.5; 25 | pointer-events: none; 26 | } 27 | 28 | nav { 29 | display: flex; 30 | flex-direction: row; 31 | gap: 1rem; 32 | } 33 | 34 | .main { 35 | display: flex; 36 | flex-direction: column; 37 | flex: 1; 38 | } 39 | 40 | .content { 41 | flex: 1; 42 | display: flex; 43 | align-items: stretch; 44 | } 45 | 46 | .page { 47 | width: 100%; 48 | } 49 | -------------------------------------------------------------------------------- /packages/react-router/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @verdant-web/react-router demo 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/react-router/demo/routes/Comment.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from '../../src/index.js'; 2 | 3 | export function Comment() { 4 | const { id, commentId } = useParams<{ id: string; commentId: string }>(); 5 | 6 | return ( 7 |
8 | Comment {commentId} on post {id} 9 |
10 | ); 11 | } 12 | export default Comment; 13 | -------------------------------------------------------------------------------- /packages/react-router/demo/routes/Home.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Link, RouteTree } from '../../src/index.js'; 3 | import { ScrollTester } from '../ScrollTester.js'; 4 | 5 | export function Home() { 6 | return ( 7 |
8 |
Home
9 | Direct post link 10 |
Route tree for /posts
11 | Loading...
}> 12 | 13 | 14 | {/* */} 15 | 16 | ); 17 | } 18 | export default Home; 19 | -------------------------------------------------------------------------------- /packages/react-router/demo/routes/Post.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Outlet, RouteByPath, useParams } from '../../src/index.js'; 2 | import { getPost, loadPost } from '../data/fakePosts.js'; 3 | 4 | export function Post() { 5 | const { id } = useParams<{ id: string }>(); 6 | 7 | const post = getPost(id); 8 | if (!post) { 9 | throw loadPost(id, 'component'); 10 | } 11 | 12 | return ( 13 |
14 |

{post.title}

15 |

{post.content}

16 |
    17 |
  • 18 | Test 19 |
  • 20 |
  • 21 | Demo comment 22 |
  • 23 |
24 | 25 |
Preview test:
26 | 27 |
28 | ); 29 | } 30 | export default Post; 31 | -------------------------------------------------------------------------------- /packages/react-router/demo/routes/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { Link } from '../../src/Link.js'; 3 | import { Outlet } from '../../src/Outlet.js'; 4 | import { ContainerScrollTester, ScrollTester } from '../ScrollTester.js'; 5 | 6 | export function Posts() { 7 | return ( 8 |
9 |

Posts

10 |
    11 |
  • 12 | Post 1 13 |
  • 14 |
  • 15 | 16 | Post 2 (no transition) 17 | 18 |
  • 19 |
  • 20 | 21 | Home (cancel navigation) 22 | 23 |
  • 24 |
25 |
26 | Loading post...
}> 27 | 28 | 29 |
30 | 31 | 32 | ); 33 | } 34 | export default Posts; 35 | -------------------------------------------------------------------------------- /packages/react-router/demo/routes/Test.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from '../../src/index.js'; 2 | 3 | export function Test() { 4 | const params = useParams(); 5 | 6 | return ( 7 |
8 |
Nested params test
9 |
{JSON.stringify(params)}
10 |
11 | ); 12 | } 13 | 14 | export default Test; 15 | -------------------------------------------------------------------------------- /packages/react-router/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist/esm", 6 | "rootDir": "./src", 7 | "jsx": "react-jsx", 8 | "lib": ["DOM", "ESNext"] 9 | }, 10 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/react-router/demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { fileURLToPath } from 'url'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | include: ['react/jsx-runtime', 'react', 'react-dom'], 9 | }, 10 | resolve: { 11 | alias: { 12 | '~': fileURLToPath(new URL('./src', import.meta.url)), 13 | }, 14 | }, 15 | server: { 16 | port: 3010, 17 | }, 18 | build: { 19 | sourcemap: true, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/react-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/react-router", 3 | "version": "0.8.0", 4 | "access": "public", 5 | "type": "module", 6 | "exports": { 7 | ".": { 8 | "development": "./src/index.ts", 9 | "import": "./dist/esm/index.js", 10 | "types": "./dist/types/index.d.ts" 11 | } 12 | }, 13 | "publishConfig": { 14 | "exports": { 15 | ".": { 16 | "import": "./dist/esm/index.js", 17 | "types": "./dist/types/index.d.ts" 18 | } 19 | }, 20 | "access": "public" 21 | }, 22 | "files": [ 23 | "dist/", 24 | "src/" 25 | ], 26 | "scripts": { 27 | "build": "tsc -p tsconfig.json", 28 | "prepublish": "pnpm run build", 29 | "link": "pnpm link --global", 30 | "demo": "vite --config ./demo/vite.config.ts ./demo", 31 | "test": "vitest" 32 | }, 33 | "peerDependencies": { 34 | "react": "^19" 35 | }, 36 | "dependencies": { 37 | "path-to-regexp": "^6.2.1" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "catalog:", 41 | "@types/react-dom": "catalog:", 42 | "@types/use-sync-external-store": "^0.0.6", 43 | "@vitejs/plugin-react": "catalog:", 44 | "jsdom": "^20.0.0", 45 | "react": "catalog:", 46 | "react-dom": "catalog:", 47 | "typescript": "catalog:", 48 | "vite": "catalog:", 49 | "vitest": "catalog:" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/react-router/src/Outlet.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, ReactNode, useContext } from 'react'; 2 | import { RouteLevelContext } from './context.js'; 3 | import { useMatchingRouteForPath } from './hooks.js'; 4 | import { RouteRenderer } from './Route.js'; 5 | import { RouteMatch } from './types.js'; 6 | 7 | export interface OutletProps { 8 | /** 9 | * Supply children to override the default show/hide behavior of the outlet. 10 | * You must render a RouteRenderer, passing match and params as props, to 11 | * properly show a matched route. 12 | */ 13 | children?: ( 14 | match: RouteMatch | null, 15 | params: Record | undefined, 16 | ) => ReactElement | null; 17 | } 18 | 19 | export function Outlet({ children }: OutletProps) { 20 | const { match: parent, params: upstreamParams } = 21 | useContext(RouteLevelContext); 22 | 23 | const match = useMatchingRouteForPath( 24 | parent?.remainingPath ?? '', 25 | parent?.route.children ?? null, 26 | ); 27 | 28 | if (children) { 29 | return children(match, upstreamParams); 30 | } 31 | 32 | if (!match) return null; 33 | 34 | return ; 35 | } 36 | -------------------------------------------------------------------------------- /packages/react-router/src/events.ts: -------------------------------------------------------------------------------- 1 | export class WillNavigateEvent extends CustomEvent<{}> { 2 | constructor(detail: {} = {}) { 3 | super('willnavigate', { 4 | detail, 5 | }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-router/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Router.js'; 2 | export * from './Outlet.js'; 3 | export type { RouteConfig } from './types.js'; 4 | export * from './Link.js'; 5 | export * from './TransitionIndicator.js'; 6 | export { makeRoutes } from './routes.js'; 7 | export { 8 | useNavigate, 9 | useMatch, 10 | useSearchParams, 11 | useOnLocationChange, 12 | useMatchingRoutes, 13 | useMatchingRoute, 14 | useNextMatchingRoute, 15 | useScrollRestoration, 16 | useParams, 17 | type PreviousLocation, 18 | type RouteState, 19 | } from './hooks.js'; 20 | export { Route, RouteTree, RouteRenderer } from './Route.js'; 21 | export type { 22 | RouteProps, 23 | RouteTreeProps, 24 | RouteRendererProps, 25 | } from './Route.js'; 26 | export { Route as RouteByPath } from './Route.js'; 27 | export * from './RestoreScroll.js'; 28 | -------------------------------------------------------------------------------- /packages/react-router/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { RouteConfig } from './types.js'; 2 | 3 | export function makeRoutes( 4 | routes: Routes, 5 | ): Routes { 6 | return routes; 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-router/src/scrollPositions.ts: -------------------------------------------------------------------------------- 1 | let scrollPositions = 2 | typeof window !== 'undefined' 3 | ? JSON.parse(sessionStorage.getItem('scrollPositions') || '{}') 4 | : {}; 5 | 6 | export function recordScrollPosition( 7 | id: string, 8 | position: [number, number] | null, 9 | ) { 10 | if (position) { 11 | scrollPositions[id] = position; 12 | } else { 13 | delete scrollPositions[id]; 14 | } 15 | sessionStorage.setItem('scrollPositions', JSON.stringify(scrollPositions)); 16 | } 17 | 18 | export function getScrollPosition(id: string): [number, number] | false { 19 | return scrollPositions[id] || false; 20 | } 21 | 22 | export function consumeScrollPosition(id: string): [number, number] | false { 23 | const position = getScrollPosition(id); 24 | delete scrollPositions[id]; 25 | sessionStorage.setItem('scrollPositions', JSON.stringify(scrollPositions)); 26 | return position; 27 | } 28 | -------------------------------------------------------------------------------- /packages/react-router/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | 3 | type CommonRouteConfig = { 4 | component: ComponentType; 5 | children?: RouteConfig[]; 6 | onVisited?: (params: { 7 | [key: string]: string; 8 | }) => void | (() => void) | Promise | Promise<() => void>; 9 | data?: any; 10 | }; 11 | 12 | export type PathRouteConfig = CommonRouteConfig & { 13 | path: string; 14 | exact?: boolean; 15 | }; 16 | 17 | export type IndexRouteConfig = CommonRouteConfig & { 18 | index: true; 19 | }; 20 | 21 | export type RouteConfig = PathRouteConfig | IndexRouteConfig; 22 | 23 | export type RouteMatch = { 24 | route: RouteConfig; 25 | path: string; 26 | params: Record; 27 | remainingPath: string; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/react-router/src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { joinPaths } from './util.js'; 3 | 4 | describe('joinPaths', () => { 5 | it('resolves relative paths to base', () => { 6 | expect(joinPaths('/foo', 'bar')).toBe('/foo/bar'); 7 | }); 8 | it('eliminates double slashes', () => { 9 | expect(joinPaths('/foo/', '/bar')).toBe('/foo/bar'); 10 | }); 11 | it("doesn't mess up relatie paths", () => { 12 | expect(joinPaths('', 'foo')).toBe('foo'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react-router/src/util.ts: -------------------------------------------------------------------------------- 1 | export function joinPaths(path1: string, path2: string | undefined) { 2 | if (!path2) { 3 | return path1; 4 | } 5 | if (!path1) { 6 | return path2; 7 | } 8 | if (path1.endsWith('/') && path2.startsWith('/')) { 9 | return path1 + path2.slice(1); 10 | } 11 | if (!path1.endsWith('/') && !path2.startsWith('/')) { 12 | return path1 + '/' + path2; 13 | } 14 | 15 | return path1 + path2; 16 | } 17 | 18 | export function generateId() { 19 | return Math.random().toString(36).slice(2, 9); 20 | } 21 | 22 | export function removeBasePath(pathname: string, basePath: string | undefined) { 23 | if (basePath && pathname.startsWith(basePath)) { 24 | return pathname.slice(basePath.length); 25 | } 26 | return pathname; 27 | } 28 | -------------------------------------------------------------------------------- /packages/react-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm", 6 | "lib": ["esnext", "dom"] 7 | }, 8 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/react-router/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | clearMocks: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/react/.gitignore: -------------------------------------------------------------------------------- 1 | __screenshots__ 2 | -------------------------------------------------------------------------------- /packages/react/src/generation.test.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/store'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { createHooks } from './hooks.js'; 4 | 5 | describe('generated hooks', () => { 6 | it('should create singular and plural hooks for all collections', () => { 7 | const authors = schema.collection({ 8 | name: 'author', 9 | primaryKey: 'id', 10 | fields: { 11 | id: { 12 | type: 'string', 13 | }, 14 | name: { 15 | type: 'string', 16 | }, 17 | }, 18 | }); 19 | const tallies = schema.collection({ 20 | name: 'tally', 21 | primaryKey: 'id', 22 | fields: { 23 | id: { 24 | type: 'string', 25 | }, 26 | count: { 27 | type: 'number', 28 | }, 29 | }, 30 | }); 31 | const testSchema = schema({ 32 | version: 1, 33 | collections: { 34 | authors, 35 | tallies, 36 | }, 37 | }); 38 | 39 | const hooks = createHooks(testSchema); 40 | 41 | expect(hooks).toHaveProperty('useAuthor'); 42 | expect(hooks).toHaveProperty('useAllAuthors'); 43 | expect(hooks).toHaveProperty('useTally'); 44 | expect(hooks).toHaveProperty('useAllTallies'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './hooks.js'; 2 | 3 | export { useOnChange, useWatch } from './watch.js'; 4 | -------------------------------------------------------------------------------- /packages/react/tests/setup/indexedDB.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import { IDBKeyRange, IDBCursor, IDBDatabase, IDBTransaction, IDBRequest, IDBFactory } from 'fake-indexeddb'; 3 | window.IDBKeyRange = IDBKeyRange; 4 | window.IDBCursor = IDBCursor; 5 | window.IDBDatabase = IDBDatabase; 6 | window.IDBTransaction = IDBTransaction; 7 | window.IDBRequest = IDBRequest; 8 | window.IDBFactory = IDBFactory; 9 | -------------------------------------------------------------------------------- /packages/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm", 6 | "lib": ["esnext", "dom"], 7 | "types": ["@vitest/browser/providers/playwright"] 8 | }, 9 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/react/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | browser: { 6 | provider: 'playwright', 7 | enabled: true, 8 | instances: [ 9 | { 10 | browser: 'chromium', 11 | headless: true, 12 | }, 13 | ], 14 | }, 15 | clearMocks: true, 16 | }, 17 | resolve: { 18 | conditions: ['development', 'default'], 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # verdant server 2 | 3 | This server is designed to sync data from clients. That includes replication between clients and realtime updates, including presence. 4 | 5 | The server includes flexibility of transport - using either websockets or push/pull HTTP requests. To do this cleanly, the concept of a `clientKey` is employed. Each client connection (socket or individual request) is assigned a key and added to a collection (based on its associated library). When the server responds to a client, abstractly, it submits that response to the client's key. The connection management code then either sends that response as a socket message, or includes it in the HTTP response. 6 | -------------------------------------------------------------------------------- /packages/server/src/MessageSender.ts: -------------------------------------------------------------------------------- 1 | import { ServerMessage } from '@verdant-web/common'; 2 | 3 | export interface MessageSender { 4 | broadcast( 5 | libraryId: string, 6 | message: ServerMessage, 7 | omitKeys?: string[], 8 | ): void; 9 | send(libraryId: string, clientKey: string, message: ServerMessage): void; 10 | } 11 | -------------------------------------------------------------------------------- /packages/server/src/Profiles.ts: -------------------------------------------------------------------------------- 1 | export interface UserProfiles { 2 | get(userId: string): Promise; 3 | } 4 | 5 | export class UserProfileLoader { 6 | private cache = new Map>(); 7 | 8 | constructor(private source: UserProfiles) {} 9 | 10 | get = async (userId: string) => { 11 | if (!this.cache.has(userId)) { 12 | this.cache.set(userId, this.source.get(userId)); 13 | } 14 | 15 | return this.cache.get(userId)!; 16 | }; 17 | 18 | evict = (userId: string) => { 19 | this.cache.delete(userId); 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /packages/server/src/ReplicaKeepaliveTimers.ts: -------------------------------------------------------------------------------- 1 | import { EventSubscriber } from '@verdant-web/common'; 2 | 3 | /** 4 | * A set of timers per-replica which are 5 | * refreshed whenever the replica sends 6 | * a message to the server. If a timer expires, 7 | * the replica is removed from presence. This 8 | * is a failsafe for socket-based replicas but 9 | * essential for pull-based replicas. 10 | */ 11 | export class ReplicaKeepaliveTimers extends EventSubscriber<{ 12 | lost: (libraryId: string, replicaId: string) => void; 13 | }> { 14 | private timers = new Map(); 15 | 16 | constructor(private timeout = 30 * 1000) { 17 | super(); 18 | } 19 | 20 | refresh = (libraryId: string, replicaId: string) => { 21 | const existing = this.timers.get(replicaId); 22 | if (existing) { 23 | clearTimeout(existing); 24 | } 25 | 26 | this.timers.set( 27 | replicaId, 28 | setTimeout(() => { 29 | this.emit('lost', libraryId, replicaId); 30 | this.timers.delete(replicaId); 31 | }, this.timeout), 32 | ); 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/src/TokenProvider.ts: -------------------------------------------------------------------------------- 1 | import { ReplicaType } from '@verdant-web/common'; 2 | import jwt from 'jsonwebtoken'; 3 | 4 | export class TokenProvider { 5 | constructor(private config: { secret: string }) {} 6 | 7 | getToken = ({ 8 | userId, 9 | libraryId, 10 | syncEndpoint, 11 | role, 12 | type = ReplicaType.Realtime, 13 | expiresIn = '1d', 14 | fileEndpoint, 15 | }: { 16 | userId: string; 17 | libraryId: string; 18 | syncEndpoint: string; 19 | fileEndpoint?: string; 20 | role?: string; 21 | type?: ReplicaType; 22 | /** 23 | * The time when the token will expire. A string in the format of "3d" or a number of seconds. 24 | */ 25 | expiresIn?: string | number; 26 | }) => { 27 | return jwt.sign( 28 | { 29 | sub: userId, 30 | lib: libraryId, 31 | url: syncEndpoint, 32 | file: fileEndpoint, 33 | role, 34 | type, 35 | }, 36 | this.config.secret, 37 | { 38 | expiresIn, 39 | }, 40 | ); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/server/src/TokenVerifier.ts: -------------------------------------------------------------------------------- 1 | import { assert, ReplicaType, VerdantError } from '@verdant-web/common'; 2 | import jwt, { JwtPayload } from 'jsonwebtoken'; 3 | 4 | export class TokenVerifier { 5 | constructor(private config: { secret: string }) {} 6 | 7 | verifyToken = (token: string): TokenInfo => { 8 | try { 9 | const decoded = jwt.verify(token, this.config.secret, { 10 | complete: false, 11 | }) as JwtPayload; 12 | assert(decoded.sub); 13 | return { 14 | userId: decoded.sub, 15 | libraryId: decoded.lib, 16 | syncEndpoint: decoded.url, 17 | role: decoded.role, 18 | type: decoded.type, 19 | token, 20 | }; 21 | } catch (e) { 22 | if (e instanceof jwt.TokenExpiredError) { 23 | throw new VerdantError(VerdantError.Code.TokenExpired); 24 | } 25 | if (e instanceof Error) { 26 | throw new VerdantError(VerdantError.Code.Unexpected, e); 27 | } 28 | throw new VerdantError( 29 | VerdantError.Code.Unexpected, 30 | undefined, 31 | JSON.stringify(e), 32 | ); 33 | } 34 | }; 35 | } 36 | 37 | export interface TokenInfo { 38 | userId: string; 39 | libraryId: string; 40 | syncEndpoint: string; 41 | role?: string; 42 | type: ReplicaType; 43 | token: string; 44 | } 45 | -------------------------------------------------------------------------------- /packages/server/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { MessageSender } from './MessageSender.js'; 2 | export type { UserProfiles } from './Profiles.js'; 3 | export type { ClientMessage, ServerMessage } from '@verdant-web/common'; 4 | export { ReplicaType } from '@verdant-web/common'; 5 | export { Server } from './Server.js'; 6 | export { TokenProvider } from './TokenProvider.js'; 7 | export { LocalFileStorage } from './files/FileStorage.js'; 8 | export type { FileStorage, FileInfo } from './files/FileStorage.js'; 9 | export type { TokenInfo } from './TokenVerifier.js'; 10 | export type { LibraryInfo } from './types.js'; 11 | -------------------------------------------------------------------------------- /packages/server/src/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Storage.js'; 2 | export * from './sql/sqlStorage.js'; 3 | export * from './sqlShard/sqlShardStorage.js'; 4 | -------------------------------------------------------------------------------- /packages/server/src/storage/sql/database.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, SqliteDialect } from 'kysely'; 2 | import { Database as DatabaseTypes } from './tables.js'; 3 | import Database from 'better-sqlite3'; 4 | import { migrateToLatest } from '@a-type/kysely'; 5 | import migrations from './migrations.js'; 6 | 7 | export function openDatabase( 8 | file: string, 9 | options: { 10 | skipMigrations?: boolean; 11 | disableWal?: boolean; 12 | } = {}, 13 | ) { 14 | const internalDb = new Database(file); 15 | if (!options.disableWal) { 16 | internalDb.pragma('journal_mode = WAL'); 17 | } 18 | const db = new Kysely({ 19 | dialect: new SqliteDialect({ 20 | database: internalDb, 21 | }), 22 | }); 23 | let ready = Promise.resolve(undefined); 24 | if (!options.skipMigrations) { 25 | ready = migrateToLatest(db, migrations); 26 | } 27 | 28 | return { 29 | db, 30 | ready, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/storage/sql/migrations.ts: -------------------------------------------------------------------------------- 1 | import * as v1 from './migrations/v1.js'; 2 | import * as v2 from './migrations/v2.js'; 3 | 4 | export default { v1, v2 }; 5 | -------------------------------------------------------------------------------- /packages/server/src/storage/sql/migrations/v2.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely) { 4 | // add authz column to OperationHistory and DocumentBaseline 5 | await db.schema 6 | .alterTable('OperationHistory') 7 | .addColumn('authz', 'text', (cb) => cb.defaultTo(null)) 8 | .execute(); 9 | 10 | await db.schema 11 | .alterTable('DocumentBaseline') 12 | .addColumn('authz', 'text', (cb) => cb.defaultTo(null)) 13 | .execute(); 14 | } 15 | 16 | export async function down(db: Kysely) { 17 | await db.schema.alterTable('OperationHistory').dropColumn('authz').execute(); 18 | 19 | await db.schema.alterTable('DocumentBaseline').dropColumn('authz').execute(); 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/storage/sqlShard/database.ts: -------------------------------------------------------------------------------- 1 | import { Kysely, SqliteDialect } from 'kysely'; 2 | import { join } from 'path'; 3 | import { Database as DatabaseTypes } from './tables.js'; 4 | import Database from 'better-sqlite3'; 5 | import { migrateToLatest } from '@a-type/kysely'; 6 | import migrations from './migrations.js'; 7 | 8 | export async function openDatabase( 9 | directory: string, 10 | libraryId: string, 11 | options: { 12 | skipMigrations?: boolean; 13 | disableWal?: boolean; 14 | } = {}, 15 | ) { 16 | const label = `openDatabase ${libraryId}`; 17 | console.time(label); 18 | const filePath = 19 | directory === ':memory:' 20 | ? ':memory:' 21 | : join(directory, `${libraryId}.sqlite`); 22 | const internalDb = new Database(filePath); 23 | if (!options.disableWal) { 24 | internalDb.pragma('journal_mode = WAL'); 25 | } 26 | const db = new Kysely({ 27 | dialect: new SqliteDialect({ 28 | database: internalDb, 29 | }), 30 | }); 31 | // only migrate on first open 32 | if (!options.skipMigrations) { 33 | await migrateToLatest(db, migrations); 34 | } 35 | console.timeEnd(label); 36 | return db; 37 | } 38 | -------------------------------------------------------------------------------- /packages/server/src/storage/sqlShard/migrations.ts: -------------------------------------------------------------------------------- 1 | import * as v1 from './migrations/v1.js'; 2 | import * as v2 from './migrations/v2.js'; 3 | 4 | export default { v1, v2 }; 5 | -------------------------------------------------------------------------------- /packages/server/src/storage/sqlShard/migrations/v2.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely) { 4 | // add authz column to OperationHistory and DocumentBaseline 5 | await db.schema 6 | .alterTable('OperationHistory') 7 | .addColumn('authz', 'text', (cb) => cb.defaultTo(null)) 8 | .execute(); 9 | 10 | await db.schema 11 | .alterTable('DocumentBaseline') 12 | .addColumn('authz', 'text', (cb) => cb.defaultTo(null)) 13 | .execute(); 14 | } 15 | 16 | export async function down(db: Kysely) { 17 | await db.schema.alterTable('OperationHistory').dropColumn('authz').execute(); 18 | 19 | await db.schema.alterTable('DocumentBaseline').dropColumn('authz').execute(); 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/src/storage/sqlShard/tables.ts: -------------------------------------------------------------------------------- 1 | import { ReplicaType } from '@verdant-web/common'; 2 | import { Generated, Selectable } from 'kysely'; 3 | 4 | export interface Database { 5 | OperationHistory: OperationHistoryTable; 6 | DocumentBaseline: DocumentBaselineTable; 7 | ReplicaInfo: ReplicaInfoTable; 8 | FileMetadata: FileMetadataTable; 9 | } 10 | 11 | export interface OperationHistoryTable { 12 | oid: string; 13 | timestamp: string; 14 | data: string; 15 | serverOrder: number; 16 | replicaId: string; 17 | authz: string | null; 18 | } 19 | export type OperationHistoryRow = Selectable; 20 | 21 | export interface DocumentBaselineTable { 22 | oid: string; 23 | snapshot: string; 24 | timestamp: string; 25 | authz: string | null; 26 | } 27 | export type DocumentBaselineRow = Selectable; 28 | 29 | export interface ReplicaInfoTable { 30 | id: string; 31 | clientId: string; 32 | lastSeenWallClockTime: number | null; 33 | ackedLogicalTime: string | null; 34 | type: ReplicaType; 35 | ackedServerOrder: Generated; 36 | } 37 | 38 | export type ReplicaInfoRow = Selectable; 39 | 40 | export interface FileMetadataTable { 41 | fileId: string; 42 | name: string; 43 | type: string; 44 | pendingDeleteAt: number | null; 45 | } 46 | 47 | export type FileMetadataRow = Selectable; 48 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm" 6 | }, 7 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | clearMocks: true, 6 | environment: 'node', 7 | }, 8 | resolve: { 9 | conditions: ['development', 'default'], 10 | }, 11 | build: { 12 | sourcemap: true, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/store/README.md: -------------------------------------------------------------------------------- 1 | # `@lofi/web` 2 | -------------------------------------------------------------------------------- /packages/store/perf/.gitignore: -------------------------------------------------------------------------------- 1 | perfTest.js 2 | perfTest.js.map 3 | -------------------------------------------------------------------------------- /packages/store/perf/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Title 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/store/perf/results/2023-11-12T20-21-14.272Z.dc425561435e22e33b2f655a3aae4bf2a56cfccc.run.json: -------------------------------------------------------------------------------- 1 | {"time":1736.1000000238419,"maxHeap":70163076,"allocatedObjects":47723} -------------------------------------------------------------------------------- /packages/store/perf/results/2023-11-12T21-30-02.913Z.ab178e293af25632918d6f8ca5ef808b97273633.run.json: -------------------------------------------------------------------------------- 1 | {"time":1877.7999999523163,"maxHeap":73072056,"allocatedObjects":20683} -------------------------------------------------------------------------------- /packages/store/perf/results/2024-03-16T00-05-58.434Z.ecc696c7525cb2b83a67d238a1e6bf75d2c7a33a.run.json: -------------------------------------------------------------------------------- 1 | {"time":1568.2000000029802,"maxHeap":57839624,"allocatedObjects":55408} -------------------------------------------------------------------------------- /packages/store/perf/results/2024-03-16T00-06-35.534Z.ecc696c7525cb2b83a67d238a1e6bf75d2c7a33a.run.json: -------------------------------------------------------------------------------- 1 | {"time":1858.7000000029802,"maxHeap":58223368,"allocatedObjects":55433} -------------------------------------------------------------------------------- /packages/store/src/BackoffScheduler.ts: -------------------------------------------------------------------------------- 1 | import { EventSubscriber } from '@verdant-web/common'; 2 | 3 | export class BackoffScheduler extends EventSubscriber<{ 4 | trigger: () => void; 5 | }> { 6 | private readonly backoff: Backoff; 7 | private timer: NodeJS.Timeout | null = null; 8 | private isScheduled = false; 9 | 10 | constructor(backoff: Backoff) { 11 | super(); 12 | this.backoff = backoff; 13 | } 14 | 15 | next = () => { 16 | if (!this.isScheduled) { 17 | this.isScheduled = true; 18 | this.timer = setTimeout(() => { 19 | this.emit('trigger'); 20 | this.isScheduled = false; 21 | this.backoff.next(); 22 | }, this.backoff.current); 23 | } 24 | }; 25 | 26 | reset = () => { 27 | this.backoff.reset(); 28 | if (this.timer) { 29 | clearTimeout(this.timer); 30 | this.timer = null; 31 | } 32 | }; 33 | } 34 | 35 | export class Backoff { 36 | current = 0; 37 | private readonly max: number; 38 | private readonly factor: number; 39 | 40 | constructor(max: number, factor: number) { 41 | this.max = max; 42 | this.factor = factor; 43 | } 44 | 45 | next = () => { 46 | this.current = Math.min(this.max, this.current * this.factor); 47 | }; 48 | 49 | reset = () => { 50 | this.current = 0; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/store/src/FakeWeakRef.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This exists since I'm a little anxious about using WeakRef in production 3 | * and want to be able to roll it back quickly to debug issues. Basically by adding 4 | * import { WeakRef } from 'FakeWeakRef' to the top of the file, we can switch back 5 | * to using a simple object reference. 6 | */ 7 | 8 | export class FakeWeakRef { 9 | constructor(value: T) { 10 | this.value = value; 11 | } 12 | 13 | value: T; 14 | 15 | deref(): T | undefined { 16 | return this.value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/store/src/__tests__/setup/indexedDB.ts: -------------------------------------------------------------------------------- 1 | import 'fake-indexeddb/auto'; 2 | import { IDBKeyRange, IDBCursor, IDBDatabase, IDBTransaction, IDBRequest, IDBFactory } from 'fake-indexeddb'; 3 | window.IDBKeyRange = IDBKeyRange; 4 | window.IDBCursor = IDBCursor; 5 | window.IDBDatabase = IDBDatabase; 6 | window.IDBTransaction = IDBTransaction; 7 | window.IDBRequest = IDBRequest; 8 | window.IDBFactory = IDBFactory; 9 | 10 | // patch URL.createObjectURL to return a string 11 | // @ts-ignore 12 | URL.createObjectURL = (blob: Blob) => { 13 | return `blob:${blob.type}:${blob.size}`; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/store/src/authorization.ts: -------------------------------------------------------------------------------- 1 | import { authz } from '@verdant-web/common'; 2 | 3 | export const authorization = { 4 | private: authz.onlyMe(), 5 | public: undefined, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/store/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const TEST_API = '__unsafe__test_api__'; 2 | -------------------------------------------------------------------------------- /packages/store/src/context/ShutdownHandler.ts: -------------------------------------------------------------------------------- 1 | export class ShutdownHandler { 2 | private consumed = false; 3 | private readonly handlers: (() => Promise)[] = []; 4 | constructor( 5 | private log?: ( 6 | level: 'debug' | 'info' | 'warn' | 'error' | 'critical', 7 | ...args: any[] 8 | ) => void, 9 | ) {} 10 | 11 | register(handler: () => Promise) { 12 | this.handlers.push(handler); 13 | } 14 | 15 | async shutdown() { 16 | if (this.consumed) { 17 | this.log?.('warn', 'ShutdownHandler already consumed'); 18 | } 19 | 20 | this.consumed = true; 21 | await Promise.all(this.handlers.map((handler) => handler())); 22 | this.handlers.length = 0; 23 | } 24 | 25 | get isShuttingDown() { 26 | return this.consumed; 27 | } 28 | 29 | reset = () => { 30 | this.consumed = false; 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /packages/store/src/context/Time.ts: -------------------------------------------------------------------------------- 1 | import { TimestampProvider } from '@verdant-web/common'; 2 | 3 | export class Time { 4 | private overrideNow?: () => string; 5 | constructor( 6 | private base: TimestampProvider, 7 | private version: number, 8 | ) {} 9 | 10 | get now() { 11 | return this.overrideNow ? this.overrideNow() : this.base.now(this.version); 12 | } 13 | 14 | withMigrationTime = async (version: number, run: () => Promise) => { 15 | this.overrideNow = () => { 16 | return this.base.zero(version); 17 | }; 18 | await run(); 19 | this.overrideNow = undefined; 20 | }; 21 | 22 | update = this.base.update.bind(this.base); 23 | 24 | nowWithVersion = (version: number) => { 25 | return this.base.now(version); 26 | }; 27 | 28 | get zero() { 29 | return this.base.zero(this.version); 30 | } 31 | 32 | zeroWithVersion = (version: number) => { 33 | return this.base.zero(version); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /packages/store/src/entities/EntityCache.ts: -------------------------------------------------------------------------------- 1 | import { ObjectIdentifier } from '@verdant-web/common'; 2 | import { Context } from '../internal.js'; 3 | import { Entity, EntityInit } from './Entity.js'; 4 | 5 | export class EntityCache { 6 | private ctx: Context; 7 | private cache = new Map>(); 8 | 9 | constructor({ initial, ctx }: { initial?: Entity[]; ctx: Context }) { 10 | this.ctx = ctx; 11 | if (initial) { 12 | for (const entity of initial) { 13 | this.cache.set(entity.oid, ctx.weakRef(entity)); 14 | } 15 | } 16 | } 17 | 18 | get = (init: EntityInit): Entity => { 19 | const cached = this.getCached(init.oid); 20 | if (cached) return cached; 21 | const entity = new Entity(init); 22 | this.cache.set(init.oid, this.ctx.weakRef(entity)); 23 | return entity; 24 | }; 25 | 26 | has = (oid: ObjectIdentifier) => { 27 | return this.cache.has(oid); 28 | }; 29 | 30 | getCached = (oid: string) => { 31 | if (this.cache.has(oid)) { 32 | const ref = this.cache.get(oid); 33 | const derefed = ref?.deref(); 34 | if (derefed) { 35 | return derefed as Entity; 36 | } else { 37 | this.cache.delete(oid); 38 | } 39 | } 40 | return null; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/store/src/entities/entityFieldSubscriber.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from './Entity.js'; 2 | import { EntityChangeInfo } from './types.js'; 3 | 4 | export function entityFieldSubscriber( 5 | entity: Entity, 6 | field: string | number | symbol, 7 | subscriber: ( 8 | newValue: T, 9 | info: EntityChangeInfo & { previousValue: T }, 10 | ) => void, 11 | ) { 12 | const valueHolder = { 13 | previousValue: entity.get(field), 14 | isLocal: false, 15 | }; 16 | function handler( 17 | this: { previousValue: T } & EntityChangeInfo, 18 | info: EntityChangeInfo, 19 | ) { 20 | if (entity.deleted) { 21 | return; 22 | } 23 | const newValue = entity.get(field); 24 | if (newValue !== this.previousValue) { 25 | this.isLocal = info.isLocal; 26 | subscriber(newValue, this); 27 | this.previousValue = newValue; 28 | } 29 | } 30 | return entity.subscribe('change', handler.bind(valueHolder)); 31 | } 32 | -------------------------------------------------------------------------------- /packages/store/src/errors.ts: -------------------------------------------------------------------------------- 1 | export enum VerdantErrorCode { 2 | MigrationPathNotFound = 50001, 3 | } 4 | 5 | export class VerdantError extends Error { 6 | static Code = VerdantErrorCode; 7 | constructor( 8 | public code: VerdantErrorCode, 9 | message: string, 10 | ) { 11 | super(message); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/store/src/internal.ts: -------------------------------------------------------------------------------- 1 | export type { Context, InitialContext } from './context/context.js'; 2 | export { 3 | decomposeOid, 4 | createOid, 5 | generateId, 6 | isRangeIndexFilter, 7 | isCompoundIndexFilter, 8 | isDirectSynthetic, 9 | isMatchIndexFilter, 10 | isSortIndexFilter, 11 | isStartsWithIndexFilter, 12 | isMultiValueIndex, 13 | assert, 14 | createCompoundIndexValue, 15 | createLowerBoundIndexValue, 16 | createUpperBoundIndexValue, 17 | getIndexValues, 18 | } from '@verdant-web/common'; 19 | export type { 20 | CollectionCompoundIndexFilter, 21 | MatchCollectionIndexFilter, 22 | RangeCollectionIndexFilter, 23 | CollectionIndexFilter, 24 | } from '@verdant-web/common'; 25 | export * from './persistence/migration/paths.js'; 26 | export * from './persistence/migration/types.js'; 27 | export { Disposable } from './utils/Disposable.js'; 28 | -------------------------------------------------------------------------------- /packages/store/src/logger.ts: -------------------------------------------------------------------------------- 1 | export type VerdantLogger = ( 2 | level: 'debug' | 'info' | 'warn' | 'error' | 'critical', 3 | ...args: any[] 4 | ) => void; 5 | 6 | export const noLogger = () => {}; 7 | 8 | export const makeLogger = 9 | (prefix?: string): VerdantLogger => 10 | (level, ...args) => { 11 | if (level === 'critical') { 12 | args.unshift('[CRITICAL]'); 13 | } 14 | if (prefix) { 15 | args.unshift(`[${prefix}]`); 16 | } 17 | if (level === 'critical') { 18 | console.error(...args); 19 | } else { 20 | console[level](...args); 21 | } 22 | }; 23 | 24 | export function debugLogger(prefix?: string): VerdantLogger { 25 | if (!localStorage.getItem('DEBUG')) { 26 | return noLogger; 27 | } 28 | return makeLogger(prefix); 29 | } 30 | -------------------------------------------------------------------------------- /packages/store/src/persistence/migration/types.ts: -------------------------------------------------------------------------------- 1 | import { Context } from '../../context/context.js'; 2 | 3 | /** During migration, only a partial context is available */ 4 | export type OpenDocumentDbContext = Omit; 5 | -------------------------------------------------------------------------------- /packages/store/src/queries/FindAllQuery.ts: -------------------------------------------------------------------------------- 1 | import { CollectionFilter } from '@verdant-web/common'; 2 | import { BaseQuery, BaseQueryOptions, UPDATE } from './BaseQuery.js'; 3 | import { areIndexesEqual } from './utils.js'; 4 | 5 | export class FindAllQuery extends BaseQuery { 6 | private index; 7 | private hydrate; 8 | 9 | constructor({ 10 | index, 11 | hydrate, 12 | ...rest 13 | }: { 14 | index?: CollectionFilter; 15 | hydrate: (oid: string) => Promise; 16 | } & Omit, 'initial'>) { 17 | super({ 18 | initial: [], 19 | ...rest, 20 | }); 21 | this.index = index; 22 | this.hydrate = hydrate; 23 | } 24 | 25 | protected run = async () => { 26 | const { result: oids } = await this.context.documents.findAllOids({ 27 | collection: this.collection, 28 | index: this.index, 29 | }); 30 | this.context.log( 31 | 'debug', 32 | `FindAllQuery: ${oids.length} oids found: ${oids}`, 33 | ); 34 | this.setValue(await Promise.all(oids.map(this.hydrate))); 35 | }; 36 | 37 | [UPDATE] = (index: CollectionFilter | undefined) => { 38 | if (areIndexesEqual(this.index, index)) return; 39 | this.index = index; 40 | this.execute(); 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/store/src/queries/FindOneQuery.ts: -------------------------------------------------------------------------------- 1 | import { CollectionFilter } from '@verdant-web/common'; 2 | import { BaseQuery, BaseQueryOptions, UPDATE } from './BaseQuery.js'; 3 | import { areIndexesEqual } from './utils.js'; 4 | 5 | export class FindOneQuery extends BaseQuery { 6 | private index; 7 | private hydrate; 8 | 9 | constructor({ 10 | index, 11 | hydrate, 12 | ...rest 13 | }: { 14 | index?: CollectionFilter; 15 | hydrate: (oid: string) => Promise; 16 | } & Omit, 'initial'>) { 17 | super({ 18 | initial: null, 19 | ...rest, 20 | }); 21 | this.index = index; 22 | this.hydrate = hydrate; 23 | } 24 | 25 | protected run = async () => { 26 | const oid = await this.context.documents.findOneOid({ 27 | collection: this.collection, 28 | index: this.index, 29 | }); 30 | this.setValue(oid ? await this.hydrate(oid) : null); 31 | }; 32 | 33 | [UPDATE] = (index: CollectionFilter | undefined) => { 34 | if (areIndexesEqual(this.index, index)) return; 35 | this.index = index; 36 | this.execute(); 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /packages/store/src/queries/GetQuery.ts: -------------------------------------------------------------------------------- 1 | import { createOid } from '@verdant-web/common'; 2 | import { BaseQuery, BaseQueryOptions } from './BaseQuery.js'; 3 | 4 | export class GetQuery extends BaseQuery { 5 | private hydrate; 6 | private oid; 7 | 8 | constructor({ 9 | id, 10 | hydrate, 11 | ...rest 12 | }: { 13 | id: string; 14 | hydrate: (oid: string) => Promise; 15 | } & Omit, 'initial'>) { 16 | super({ 17 | initial: null, 18 | ...rest, 19 | }); 20 | this.oid = createOid(rest.collection, id); 21 | this.hydrate = hydrate; 22 | } 23 | 24 | protected run = async () => { 25 | const value = await this.hydrate(this.oid); 26 | this.setValue(value); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /packages/store/src/queries/keys.ts: -------------------------------------------------------------------------------- 1 | import { hashObject } from '@verdant-web/common'; 2 | 3 | export interface QueryParams { 4 | collection: string; 5 | range: IDBKeyRange | IDBValidKey | undefined; 6 | index?: string; 7 | direction?: IDBCursorDirection; 8 | limit?: number; 9 | single?: boolean; 10 | write?: boolean; 11 | } 12 | 13 | export function getQueryKey({ range, ...rest }: QueryParams) { 14 | let hashedRange; 15 | if (range instanceof IDBKeyRange) { 16 | hashedRange = hashObject({ 17 | includes: range.includes, 18 | lower: range.lower, 19 | lowerOpen: range.lowerOpen, 20 | upper: range.upper, 21 | upperOpen: range.upperOpen, 22 | }); 23 | } else { 24 | hashedRange = range; 25 | } 26 | return hashObject({ range: hashedRange, ...rest }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/store/src/queries/types.ts: -------------------------------------------------------------------------------- 1 | import type { FindAllQuery } from './FindAllQuery.js'; 2 | import type { FindPageQuery } from './FindPageQuery.js'; 3 | import type { FindOneQuery } from './FindOneQuery.js'; 4 | import type { FindInfiniteQuery } from './FindInfiniteQuery.js'; 5 | import type { GetQuery } from './GetQuery.js'; 6 | 7 | export type Query = 8 | | FindAllQuery 9 | | FindPageQuery 10 | | FindOneQuery 11 | | FindInfiniteQuery 12 | | GetQuery; 13 | -------------------------------------------------------------------------------- /packages/store/src/queries/utils.ts: -------------------------------------------------------------------------------- 1 | import { CollectionIndexFilter, hashObject } from '@verdant-web/common'; 2 | import { Entity } from '../entities/Entity.js'; 3 | 4 | function existsFilter(x: T | null): x is T { 5 | return x !== null; 6 | } 7 | 8 | export function filterResultSet(results: any): any { 9 | if (Array.isArray(results)) { 10 | return results.map(filterResultSet).filter(existsFilter); 11 | } else if (results instanceof Entity) { 12 | return results.deleted ? null : results; 13 | } else { 14 | return results; 15 | } 16 | } 17 | 18 | export function areIndexesEqual( 19 | a?: CollectionIndexFilter, 20 | b?: CollectionIndexFilter, 21 | ) { 22 | return (!a && !b) || (a && b && hashObject(a) === hashObject(b)); 23 | } 24 | -------------------------------------------------------------------------------- /packages/store/src/sync/background.ts: -------------------------------------------------------------------------------- 1 | export async function attemptToRegisterBackgroundSync() { 2 | try { 3 | const status = await navigator.permissions.query({ 4 | name: 'periodic-background-sync' as any, 5 | }); 6 | if (status.state === 'granted') { 7 | // Periodic background sync can be used. 8 | const registration = await navigator.serviceWorker.ready; 9 | if ('periodicSync' in registration) { 10 | try { 11 | await (registration.periodicSync as any).register('verdant-sync', { 12 | // An interval of one day. 13 | minInterval: 24 * 60 * 60 * 1000, 14 | }); 15 | } catch (error) { 16 | // Periodic background sync cannot be used. 17 | console.warn('Failed to register background sync:', error); 18 | } 19 | } 20 | } else { 21 | // Periodic background sync cannot be used. 22 | console.debug('Background sync permission is not granted:', status); 23 | } 24 | } catch (error) { 25 | console.error('Failed to initiate background sync:', error); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/store/src/sync/cliSync.ts: -------------------------------------------------------------------------------- 1 | export function cliSync( 2 | libraryId: string, 3 | { 4 | port = 3242, 5 | initialPresence = {} as any, 6 | }: { port?: number; initialPresence?: Presence } = {}, 7 | ) { 8 | let userId = localStorage.getItem('verdant-userId'); 9 | if (!userId) { 10 | userId = `user-${Math.random().toString(36).slice(2)}`; 11 | localStorage.setItem('verdant-userId', userId); 12 | } 13 | return { 14 | defaultProfile: { id: userId }, 15 | initialPresence, 16 | authEndpoint: `http://localhost:${port}/auth/${libraryId}?userId=${userId}`, 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /packages/store/src/sync/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | import type { ClientDescriptor } from '../client/ClientDescriptor.js'; 2 | 3 | export async function registerBackgroundSync(clientDesc: ClientDescriptor) { 4 | self.addEventListener('periodicsync', (event: any) => { 5 | if (event.tag === 'verdant-sync') { 6 | // See the "Think before you sync" section for 7 | // checks you could perform before syncing. 8 | event.waitUntil(sync(clientDesc)); 9 | } 10 | }); 11 | } 12 | 13 | async function sync(clientDesc: ClientDescriptor) { 14 | try { 15 | const client = await clientDesc.open(); 16 | 17 | await client.sync.syncOnce(); 18 | } catch (err) { 19 | console.error('Failed to sync:', err); 20 | if (err instanceof Error) { 21 | localStorage.setItem( 22 | 'backgroundSyncError', 23 | `${err.name}: ${err.message}`, 24 | ); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/store/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Operation, OperationPatch } from '@verdant-web/common'; 2 | 3 | export type LogFunction = (...args: any[]) => void; 4 | 5 | /** 6 | * Operations moving through the client system are 7 | * tagged with whether or not they are stored in 8 | * the database (confirmed) or only in memory, 9 | * which might mean they are lost if the client 10 | * crashes. 11 | */ 12 | export interface TaggedOperation extends Operation { 13 | confirmed?: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /packages/store/src/utils/Disposable.ts: -------------------------------------------------------------------------------- 1 | export class Disposable { 2 | private _disposes: (() => void | Promise)[] = []; 3 | protected disposed = false; 4 | 5 | dispose = async () => { 6 | this.disposed = true; 7 | await Promise.all( 8 | this._disposes.map(async (dispose) => { 9 | try { 10 | await dispose(); 11 | } catch (err) { 12 | console.error('Error disposing', err); 13 | } 14 | }), 15 | ); 16 | this._disposes = []; 17 | }; 18 | 19 | compose = (disposable: { dispose: () => void | Promise }) => 20 | this.addDispose(disposable.dispose.bind(disposable)); 21 | 22 | protected addDispose = (dispose: () => void | Promise) => { 23 | this._disposes.push(dispose); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/store/src/utils/Resolvable.ts: -------------------------------------------------------------------------------- 1 | export class Resolvable { 2 | readonly promise: Promise; 3 | private _resolve: (value: T) => void; 4 | private _reject: (reason?: any) => void; 5 | 6 | constructor() { 7 | let resolve: (value: T) => void; 8 | let reject: (reason?: any) => void; 9 | this.promise = new Promise((res, rej) => { 10 | resolve = res; 11 | reject = rej; 12 | }); 13 | this._resolve = resolve!; 14 | this._reject = reject!; 15 | } 16 | 17 | resolve(value: T) { 18 | this._resolve(value); 19 | } 20 | 21 | reject(reason?: any) { 22 | this._reject(reason); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/store/src/utils/id.ts: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid'; 2 | 3 | export function id(short?: boolean) { 4 | if (short) return cuid.slug(); 5 | return cuid(); 6 | } 7 | -------------------------------------------------------------------------------- /packages/store/src/utils/versions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DocumentBaseline, 3 | getTimestampSchemaVersion, 4 | Operation, 5 | } from '@verdant-web/common'; 6 | 7 | export function getLatestVersion(data: { 8 | operations: Operation[]; 9 | baselines?: DocumentBaseline[]; 10 | }) { 11 | const timestamps = data.operations 12 | .map((op) => op.timestamp) 13 | .concat(data.baselines?.map((b) => b.timestamp) ?? []); 14 | const latestVersion = timestamps.reduce((v, ts) => { 15 | const tsVersion = getTimestampSchemaVersion(ts); 16 | if (tsVersion > v) { 17 | return tsVersion; 18 | } 19 | return v; 20 | }, 1); 21 | 22 | return latestVersion; 23 | } 24 | -------------------------------------------------------------------------------- /packages/store/src/utils/wip.ts: -------------------------------------------------------------------------------- 1 | import { hashObject, StorageSchema } from '@verdant-web/common'; 2 | 3 | export function getWipNamespace(namespace: string, schema: StorageSchema) { 4 | return `@@wip-${namespace}-${hashObject(schema)}`; 5 | } 6 | -------------------------------------------------------------------------------- /packages/store/src/vanilla.ts: -------------------------------------------------------------------------------- 1 | import * as everything from './index.js'; 2 | 3 | (window as any).Verdant = everything; 4 | -------------------------------------------------------------------------------- /packages/store/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm", 6 | "lib": ["dom", "esnext", "webworker"] 7 | }, 8 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts", "./typings"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/store/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'jsdom', 6 | clearMocks: true, 7 | 8 | setupFiles: ['src/__tests__/setup/indexedDB.ts'], 9 | }, 10 | resolve: { 11 | conditions: ['development', 'default'], 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /packages/tiptap/.gitignore: -------------------------------------------------------------------------------- 1 | __screenshots__ 2 | .databases 3 | .files 4 | -------------------------------------------------------------------------------- /packages/tiptap/demo/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-family: sans-serif; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | display: flex; 9 | flex-direction: column; 10 | min-height: 100vh; 11 | } 12 | 13 | #root { 14 | display: flex; 15 | flex-direction: column; 16 | flex: 1; 17 | } 18 | 19 | a { 20 | font-weight: bold; 21 | } 22 | 23 | a[data-transitioning='true'] { 24 | opacity: 0.5; 25 | pointer-events: none; 26 | } 27 | 28 | nav { 29 | display: flex; 30 | flex-direction: row; 31 | gap: 1rem; 32 | } 33 | 34 | .main { 35 | display: flex; 36 | flex-direction: column; 37 | flex: 1; 38 | } 39 | 40 | .content { 41 | flex: 1; 42 | display: flex; 43 | align-items: stretch; 44 | } 45 | 46 | .page { 47 | width: 100%; 48 | } 49 | 50 | img, 51 | video, 52 | audio { 53 | max-width: 100%; 54 | height: auto; 55 | } 56 | -------------------------------------------------------------------------------- /packages/tiptap/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @verdant-web/react-router demo 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/client.js: -------------------------------------------------------------------------------- 1 | export * from "@verdant-web/store"; 2 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ClientDescriptorOptions } from "./client.js"; 2 | export * from "./client.js"; 3 | import { ClientDescriptor as StorageDescriptor } from "./client.js"; 4 | export * from "@verdant-web/store"; 5 | export declare class ClientDescriptor extends StorageDescriptor { 6 | constructor(init: ClientDescriptorOptions); 7 | } 8 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/index.js: -------------------------------------------------------------------------------- 1 | export * from "./client.js"; 2 | import schema from "./schema.js"; 3 | import oldSchemas from "./schemaVersions/index.js"; 4 | import { ClientDescriptor as StorageDescriptor } from "./client.js"; 5 | import migrations from "../migrations/index.js"; 6 | export * from "@verdant-web/store"; 7 | export class ClientDescriptor extends StorageDescriptor { 8 | constructor(init) { 9 | const defaultedSchema = init.schema || schema; 10 | const defaultedMigrations = init.migrations || migrations; 11 | const defaultedOldSchemas = init.oldSchemas || oldSchemas; 12 | super(Object.assign(Object.assign({}, init), { schema: defaultedSchema, migrations: defaultedMigrations, oldSchemas: defaultedOldSchemas })); 13 | } 14 | } 15 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAC;AAC5B,OAAO,MAAM,MAAM,aAAa,CAAC;AACjC,OAAO,UAAU,MAAM,2BAA2B,CAAC;AACnD,OAAO,EAAE,gBAAgB,IAAI,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,UAAU,MAAM,wBAAwB,CAAC;AAChD,cAAc,oBAAoB,CAAC;AAEnC,MAAM,OAAO,gBAGX,SAAQ,iBAAoC;IAC5C,YAAY,IAAgD;QAC1D,MAAM,eAAe,GAAG,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC;QAC9C,MAAM,mBAAmB,GAAG,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC;QAC1D,MAAM,mBAAmB,GAAG,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC;QAC1D,KAAK,iCACA,IAAI,KACP,MAAM,EAAE,eAAe,EACvB,UAAU,EAAE,mBAAmB,EAC/B,UAAU,EAAE,mBAAmB,IAC/B,CAAC;IACL,CAAC;CACF"} -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/index.ts: -------------------------------------------------------------------------------- 1 | import { ClientDescriptorOptions } from "./client.js"; 2 | export * from "./client.js"; 3 | import schema from "./schema.js"; 4 | import oldSchemas from "./schemaVersions/index.js"; 5 | import { ClientDescriptor as StorageDescriptor } from "./client.js"; 6 | import migrations from "../migrations/index.js"; 7 | export * from "@verdant-web/store"; 8 | 9 | export class ClientDescriptor< 10 | Presence = unknown, 11 | Profile = unknown, 12 | > extends StorageDescriptor { 13 | constructor(init: ClientDescriptorOptions) { 14 | const defaultedSchema = init.schema || schema; 15 | const defaultedMigrations = init.migrations || migrations; 16 | const defaultedOldSchemas = init.oldSchemas || oldSchemas; 17 | super({ 18 | ...init, 19 | schema: defaultedSchema, 20 | migrations: defaultedMigrations, 21 | oldSchemas: defaultedOldSchemas, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/meta.json: -------------------------------------------------------------------------------- 1 | {"verdantCLI":1} -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/react.js: -------------------------------------------------------------------------------- 1 | import { createHooks as baseCreateHooks } from "@verdant-web/react"; 2 | import schema from "./schema.js"; 3 | 4 | export function createHooks(options) { 5 | return baseCreateHooks(schema, options); 6 | } 7 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/schema.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./schemaVersions/v1.js"; 2 | export { default } from "./schemaVersions/v1.js"; 3 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/schema.js: -------------------------------------------------------------------------------- 1 | import schema from './schemaVersions/v1.js'; 2 | const finalSchema = { wip: false, ...schema }; 3 | export default finalSchema; -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/schemaVersions/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | import { StorageSchema } from '@verdant-web/common'; 3 | declare const versions: StorageSchema[]; 4 | export default versions; 5 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/.generated/schemaVersions/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @generated - do not modify this file. 4 | */ 5 | import v1 from './v1.js'; 6 | 7 | export default [v1] 8 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/index.ts: -------------------------------------------------------------------------------- 1 | export { ClientDescriptor } from './.generated/index.js'; 2 | export { createHooks } from './.generated/react.js'; 3 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/index.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: any[]; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/index.js: -------------------------------------------------------------------------------- 1 | import v1 from "./v1.js"; 2 | export default [v1]; 3 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AAEzB,eAAe,CAAC,EAAE,CAAC,CAAC"} -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import v1 from "./v1.js"; 2 | 3 | export default [v1]; 4 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/v1.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: any; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/v1.js: -------------------------------------------------------------------------------- 1 | import v1Schema from "../.generated/schemaVersions/v1.js"; 2 | import { createMigration } from "@verdant-web/store"; 3 | // this is your first migration, so no logic is necessary! but you can 4 | // include logic here to seed initial data for users 5 | export default createMigration(v1Schema, async ({ mutations }) => { 6 | // await mutations.post.create({ title: 'Welcome to my app!' }); 7 | }); 8 | //# sourceMappingURL=v1.js.map -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/v1.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"v1.js","sourceRoot":"","sources":["v1.ts"],"names":[],"mappings":"AAAA,OAAO,QAEN,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAErD,sEAAsE;AACtE,oDAAoD;AACpD,eAAe,eAAe,CAAU,QAAQ,EAAE,KAAK,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE;IACxE,gEAAgE;AAClE,CAAC,CAAC,CAAC"} -------------------------------------------------------------------------------- /packages/tiptap/demo/store/migrations/v1.ts: -------------------------------------------------------------------------------- 1 | import v1Schema, { 2 | MigrationTypes as V1Types, 3 | } from "../.generated/schemaVersions/v1.js"; 4 | import { createMigration } from "@verdant-web/store"; 5 | 6 | // this is your first migration, so no logic is necessary! but you can 7 | // include logic here to seed initial data for users 8 | export default createMigration(v1Schema, async ({ mutations }) => { 9 | // await mutations.post.create({ title: 'Welcome to my app!' }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/tiptap/demo/store/schema.ts: -------------------------------------------------------------------------------- 1 | import { schema } from '@verdant-web/common'; 2 | import { 3 | createTipTapFieldSchema, 4 | createTipTapFileMapSchema, 5 | } from '../../src/fields.js'; 6 | 7 | export default schema({ 8 | version: 1, 9 | collections: { 10 | posts: schema.collection({ 11 | name: 'post', 12 | primaryKey: 'id', 13 | fields: { 14 | id: schema.fields.id(), 15 | nullableBody: createTipTapFieldSchema({ default: null }), 16 | requiredBody: createTipTapFieldSchema({ 17 | default: { 18 | type: 'doc', 19 | content: [], 20 | }, 21 | }), 22 | files: createTipTapFileMapSchema(), 23 | }, 24 | }), 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /packages/tiptap/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "./dist", 6 | "rootDir": ".", 7 | "jsx": "react-jsx", 8 | "noEmit": true, 9 | "lib": ["DOM", "ESNext"], 10 | "customConditions": [] 11 | }, 12 | "include": ["**/*.ts", "**/*.tsx"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/tiptap/demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { fileURLToPath } from 'url'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | include: ['react/jsx-runtime', 'react', 'react-dom'], 9 | }, 10 | resolve: { 11 | alias: { 12 | '~': fileURLToPath(new URL('./src', import.meta.url)), 13 | }, 14 | }, 15 | server: { 16 | port: 3010, 17 | }, 18 | build: { 19 | sourcemap: true, 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/tiptap/src/__browserTests__/fixtures/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/tiptap/src/__browserTests__/fixtures/cat.jpg -------------------------------------------------------------------------------- /packages/tiptap/src/__browserTests__/fixtures/cat.m4a: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/tiptap/src/__browserTests__/fixtures/cat.m4a -------------------------------------------------------------------------------- /packages/tiptap/src/__browserTests__/fixtures/cat.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/packages/tiptap/src/__browserTests__/fixtures/cat.mp4 -------------------------------------------------------------------------------- /packages/tiptap/src/extensions/attributes.ts: -------------------------------------------------------------------------------- 1 | export const verdantIdAttribute = 'data-verdant-oid'; 2 | 3 | export const fileKeyAttribute = 'data-verdant-file-key'; 4 | export const fileTypeAttribute = 'data-verdant-file-type'; 5 | export const fileNameAttribute = 'data-verdant-file-name'; 6 | export const fileIdAttribute = 'data-verdant-file-id'; 7 | -------------------------------------------------------------------------------- /packages/tiptap/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './extensions/attributes.js'; 2 | export * from './extensions/NodeId.js'; 3 | export * from './extensions/Verdant.js'; 4 | export * from './extensions/VerdantMedia.js'; 5 | export * from './extensions/VerdantMediaRenderer.js'; 6 | export * from './fields.js'; 7 | -------------------------------------------------------------------------------- /packages/tiptap/src/server/index.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from '@verdant-web/server'; 2 | import { 3 | fileIdAttribute, 4 | fileNameAttribute, 5 | fileTypeAttribute, 6 | } from '../extensions/attributes.js'; 7 | 8 | export { VerdantMediaRendererExtension } from '../extensions/VerdantMediaRenderer.js'; 9 | 10 | /** 11 | * Attaches file URLs to all Verdant media nodes in the document, according 12 | * to the file service attached to your Verdant server. 13 | */ 14 | export async function attachFileUrls( 15 | document: any, 16 | libraryId: string, 17 | server: Server, 18 | ) { 19 | await visitNode(document, libraryId, server); 20 | return document; 21 | } 22 | 23 | async function visitNode(node: any, libraryId: string, server: Server) { 24 | if (node.type === 'verdant-media') { 25 | const fileId = node.attrs[fileIdAttribute]; 26 | if (fileId) { 27 | const file = await server.getFileData(libraryId, fileId); 28 | if (file) { 29 | node.attrs.src = file.url; 30 | node.attrs[fileTypeAttribute] = file.type; 31 | node.attrs[fileNameAttribute] = file.name; 32 | node.attrs[fileIdAttribute] = file.id; 33 | } 34 | } 35 | } else if (node.content) { 36 | await Promise.all( 37 | node.content.map((child: any) => visitNode(child, libraryId, server)), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/tiptap/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "outDir": "./dist/esm", 6 | "lib": ["dom", "esnext", "webworker"], 7 | "types": ["@vitest/browser/providers/playwright"] 8 | }, 9 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.mts"], 10 | "exclude": [ 11 | "node_modules", 12 | "dist", 13 | "src/**/*.test.ts", 14 | "src/**/*.test.tsx", 15 | "src/server/*.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/tiptap/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "lib": ["ESNext"], 6 | "outDir": "./dist/esm", 7 | "rootDir": "./src", 8 | "declarationDir": "./dist/esm", 9 | "declaration": true 10 | }, 11 | "include": ["src/server/**/*", "src/extensions/attributes.ts"], 12 | "exclude": ["node_modules", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/tiptap/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import basicSsl from '@vitejs/plugin-basic-ssl'; 2 | import viteReact from '@vitejs/plugin-react'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | browser: { 8 | provider: 'playwright', 9 | enabled: true, 10 | // headless: true, 11 | 12 | instances: [ 13 | { 14 | browser: 'chromium', 15 | }, 16 | ], 17 | }, 18 | deps: { 19 | optimizer: { 20 | web: { 21 | exclude: [ 22 | '@verdant-web/store', 23 | '@verdant-web/common', 24 | '@verdant-web/react', 25 | ], 26 | }, 27 | ssr: { 28 | exclude: [ 29 | '@verdant-web/store', 30 | '@verdant-web/common', 31 | '@verdant-web/react', 32 | ], 33 | }, 34 | }, 35 | }, 36 | clearMocks: true, 37 | }, 38 | server: { 39 | https: {}, 40 | }, 41 | optimizeDeps: { 42 | exclude: [ 43 | '@verdant-web/store', 44 | '@verdant-web/common', 45 | '@verdant-web/react', 46 | ], 47 | }, 48 | resolve: { 49 | conditions: ['development', 'default'], 50 | }, 51 | assetsInclude: ['./src/__browserTests__/fixtures/**/*'], 52 | plugins: [ 53 | viteReact(), 54 | basicSsl({ 55 | /** name of certification */ 56 | name: 'test', 57 | }), 58 | ], 59 | }); 60 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'docs' 4 | - 'test' 5 | - 'examples/**' 6 | - 'scaleTest' 7 | - 'scratchpad' 8 | - 'configs' 9 | 10 | catalog: 11 | better-sqlite3: ^11.8.1 12 | typescript: 5.7.3 13 | '@types/node': 22.13.4 14 | vitest: 3.0.8 15 | '@vitest/browser': 3.0.8 16 | vitest-browser-react: 0.1.1 17 | playwright: 1.51.0 18 | react: 19.0.0 19 | '@types/react': 19.0.10 20 | vite: 6.1.1 21 | '@vitejs/plugin-react': 4.3.4 22 | react-dom: 19.0.0 23 | '@types/react-dom': 19.0.4 24 | -------------------------------------------------------------------------------- /scaleTest/client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | verdant scale test 6 | 7 | 8 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/client/index.js: -------------------------------------------------------------------------------- 1 | import schema from './schema.js'; 2 | import { Storage, StorageDescriptor } from '@verdant-web/store'; 3 | export * from '@verdant-web/store'; 4 | 5 | export const Client = Storage; 6 | export class ClientDescriptor extends StorageDescriptor { 7 | constructor(init) { 8 | super({ ...init, schema }); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "verdant-client", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "typings": "./index.d.ts", 7 | "exports": { 8 | ".": { 9 | "types": "./index.d.ts", 10 | "import": "./index.js" 11 | }, 12 | "./react": { 13 | "types": "./react.d.ts", 14 | "import": "./react.js" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/client/react.js: -------------------------------------------------------------------------------- 1 | import { createHooks as baseCreateHooks } from '@verdant-web/react'; 2 | import schema from './schema.js'; 3 | 4 | export function createHooks() { 5 | return baseCreateHooks(schema); 6 | } 7 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/client/schema.js: -------------------------------------------------------------------------------- 1 | import { schema, collection } from '@verdant-web/store'; 2 | import cuid from 'cuid'; 3 | const item = collection({ 4 | name: 'item', 5 | primaryKey: 'id', 6 | fields: { 7 | id: { 8 | type: 'string', 9 | default: () => cuid(), 10 | }, 11 | done: { 12 | type: 'boolean', 13 | default: false, 14 | }, 15 | name: { 16 | type: 'string', 17 | default: '', 18 | }, 19 | categoryId: { 20 | type: 'string', 21 | nullable: true, 22 | }, 23 | }, 24 | synthetics: { 25 | doneIndex: { 26 | type: 'boolean', 27 | compute: (item) => item.done, 28 | }, 29 | }, 30 | }); 31 | const category = collection({ 32 | name: 'category', 33 | primaryKey: 'id', 34 | pluralName: 'categories', 35 | fields: { 36 | id: { 37 | type: 'string', 38 | default: () => cuid(), 39 | }, 40 | name: { 41 | type: 'string', 42 | }, 43 | }, 44 | }); 45 | export default schema({ 46 | version: 1, 47 | collections: { 48 | item, 49 | category, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/client/schemaVersions/v1.ts: -------------------------------------------------------------------------------- 1 | import { schema, collection } from '@verdant-web/store'; 2 | import cuid from 'cuid'; 3 | 4 | const items = collection({ 5 | name: 'item', 6 | primaryKey: 'id', 7 | fields: { 8 | id: { 9 | type: 'string', 10 | default: () => cuid(), 11 | }, 12 | done: { 13 | type: 'boolean', 14 | default: false, 15 | }, 16 | name: { 17 | type: 'string', 18 | default: '', 19 | }, 20 | categoryId: { 21 | type: 'string', 22 | nullable: true, 23 | }, 24 | }, 25 | synthetics: { 26 | doneIndex: { 27 | type: 'boolean', 28 | compute: (item) => item.done, 29 | }, 30 | }, 31 | }); 32 | 33 | const categories = collection({ 34 | name: 'category', 35 | primaryKey: 'id', 36 | fields: { 37 | id: { 38 | type: 'string', 39 | default: () => cuid(), 40 | }, 41 | name: { 42 | type: 'string', 43 | }, 44 | }, 45 | }); 46 | 47 | export default schema({ 48 | version: 1, 49 | collections: { items, categories }, 50 | }); 51 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid'; 2 | import { ClientDescriptor } from './client/index.js'; 3 | import migrations from './migrations/index.js'; 4 | 5 | export * from './client/index.js'; 6 | 7 | export function createClient(apiHost: string, library: string) { 8 | const storeDesc = new ClientDescriptor({ 9 | migrations, 10 | // each client gets a new, random namespace, so storage won't collide 11 | // across tabs 12 | namespace: `${library}-${cuid()}`, 13 | sync: { 14 | defaultProfile: {}, 15 | initialPresence: { emoji: '' }, 16 | authEndpoint: `${apiHost}/auth/${library}?user=${cuid.slug()}}`, 17 | autoStart: true, 18 | initialTransport: 'realtime', 19 | automaticTransportSelection: false, 20 | }, 21 | }); 22 | 23 | return storeDesc.open(); 24 | } 25 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import v1 from "./v1.js"; 2 | 3 | export default [v1]; 4 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/migrations/v1.ts: -------------------------------------------------------------------------------- 1 | import v1Schema from '../client/schemaVersions/v1.js'; 2 | import { migrate } from '@verdant-web/store'; 3 | 4 | // this is your first migration, so no logic is necessary! 5 | export default migrate(v1Schema, async ({ mutations }) => { 6 | // for version 1, there isn't any data to modify, but you can 7 | // still use mutations to seed initial data here. 8 | }); 9 | -------------------------------------------------------------------------------- /scaleTest/client/src/store/schema.ts: -------------------------------------------------------------------------------- 1 | import { schema, collection } from '@verdant-web/store'; 2 | import cuid from 'cuid'; 3 | 4 | const items = collection({ 5 | name: 'item', 6 | primaryKey: 'id', 7 | fields: { 8 | id: { 9 | type: 'string', 10 | default: () => cuid(), 11 | }, 12 | done: { 13 | type: 'boolean', 14 | default: false, 15 | }, 16 | name: { 17 | type: 'string', 18 | default: '', 19 | }, 20 | categoryId: { 21 | type: 'string', 22 | nullable: true, 23 | }, 24 | }, 25 | synthetics: { 26 | doneIndex: { 27 | type: 'boolean', 28 | compute: (item) => item.done, 29 | }, 30 | }, 31 | }); 32 | 33 | const categories = collection({ 34 | name: 'category', 35 | primaryKey: 'id', 36 | fields: { 37 | id: { 38 | type: 'string', 39 | default: () => cuid(), 40 | }, 41 | name: { 42 | type: 'string', 43 | }, 44 | }, 45 | }); 46 | 47 | export default schema({ 48 | version: 1, 49 | collections: { items, categories }, 50 | }); 51 | -------------------------------------------------------------------------------- /scaleTest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/scale-test", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "scripts": { 6 | "generate": "verdant -s ./client/src/store/schema.ts -o ./client/src/store/client", 7 | "client": "tsx ./client/index.ts", 8 | "server": "tsx ./server/index.ts" 9 | }, 10 | "dependencies": { 11 | "@verdant-web/cli": "workspace:*", 12 | "@verdant-web/server": "workspace:*", 13 | "@verdant-web/store": "workspace:*", 14 | "cors": "^2.8.5", 15 | "cuid": "^2.1.8", 16 | "express": "^4.18.2", 17 | "puppeteer": "^19.4.1", 18 | "tsx": "^4.6.2", 19 | "typescript": "^5.4.2", 20 | "vite": "^4.0.4" 21 | }, 22 | "devDependencies": { 23 | "@types/express": "^4.17.15", 24 | "@types/cors": "^2.8.13" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scaleTest/test-db.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/scaleTest/test-db.sqlite -------------------------------------------------------------------------------- /scaleTest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "rootDir": "." 6 | }, 7 | "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /scratchpad/.gitignore: -------------------------------------------------------------------------------- 1 | src/ 2 | *.sqlite 3 | files/ 4 | -------------------------------------------------------------------------------- /scratchpad/README.md: -------------------------------------------------------------------------------- 1 | This is just a place to test out the Verdant libraries in real-life scenarios without publishing the packages. The `src` directory is gitignored; put anything you want in there to test out Verdant functionality. 2 | -------------------------------------------------------------------------------- /scratchpad/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | verdant scratchpad 6 | 7 | 8 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /scratchpad/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@verdant-web/scratchpad", 3 | "type": "module", 4 | "dependencies": { 5 | "@verdant-web/cli": "workspace:*", 6 | "@verdant-web/server": "workspace:*", 7 | "@verdant-web/store": "workspace:*", 8 | "@verdant-web/react": "workspace:*", 9 | "@verdant-web/tiptap": "workspace:*", 10 | "@types/express": "^4.17.14", 11 | "concurrently": "^7.5.0", 12 | "express": "^4.18.2", 13 | "gfynonce": "^1.0.2", 14 | "tsx": "^4.6.2", 15 | "typescript": "^5.4.2", 16 | "vite": "^6.0.3", 17 | "react": "19.0.0", 18 | "react-dom": "19.0.0", 19 | "@types/react": "19.0.1", 20 | "@types/react-dom": "19.0.2", 21 | "@vitejs/plugin-react-swc": "3.7.2", 22 | "@tiptap/core": "2.11.5", 23 | "@tiptap/react": "2.11.5", 24 | "@tiptap/starter-kit": "2.11.5" 25 | }, 26 | "scripts": { 27 | "dev": "vite", 28 | "devserver": "verdant-server", 29 | "generate": "verdant --schema ./src/schema.ts --react --output ./src/client", 30 | "preflight": "verdant-preflight --output ./src/client" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /scratchpad/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "noLib": false, 5 | "target": "es2017", 6 | "sourceMap": true, 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "lib": ["dom", "esnext", "webworker"], 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "jsx": "preserve", 17 | "downlevelIteration": true, 18 | "experimentalDecorators": true, 19 | "isolatedModules": true, 20 | "customConditions": ["development"] 21 | }, 22 | "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /scratchpad/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react-swc'; 3 | import path from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | optimizeDeps: { 9 | exclude: [], 10 | include: [], 11 | }, 12 | server: { 13 | port: 4060, 14 | host: '0.0.0.0', 15 | }, 16 | build: { 17 | sourcemap: true, 18 | }, 19 | resolve: { 20 | alias: { 21 | react: path.resolve('./node_modules/react'), 22 | 'react-dom': path.resolve('./node_modules/react-dom'), 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /test/.client-sqlite/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/test/.client-sqlite/.gitkeep -------------------------------------------------------------------------------- /test/.databases/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-type/verdant/e224cee33b146294e21754e84bbc5f49e7e2481b/test/.databases/.gitkeep -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | processed.txt 3 | *.journal 4 | .client-sqlite/* 5 | !.client-sqlite/.gitkeep 6 | -------------------------------------------------------------------------------- /test/client/client.js: -------------------------------------------------------------------------------- 1 | export * from "@verdant-web/store"; 2 | -------------------------------------------------------------------------------- /test/client/index.ts: -------------------------------------------------------------------------------- 1 | import { ClientDescriptorOptions } from "./client.js"; 2 | export * from "./client.js"; 3 | import schema from "./schema.js"; 4 | import oldSchemas from "./schemaVersions/index.js"; 5 | import { ClientDescriptor as StorageDescriptor } from "./client.js"; 6 | import migrations from "../migrations/index.js"; 7 | export * from "@verdant-web/store"; 8 | 9 | export class ClientDescriptor< 10 | Presence = unknown, 11 | Profile = unknown, 12 | > extends StorageDescriptor { 13 | constructor(init: ClientDescriptorOptions) { 14 | const defaultedSchema = init.schema || schema; 15 | const defaultedMigrations = init.migrations || migrations; 16 | const defaultedOldSchemas = init.oldSchemas || oldSchemas; 17 | super({ 18 | ...init, 19 | schema: defaultedSchema, 20 | migrations: defaultedMigrations, 21 | oldSchemas: defaultedOldSchemas, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/client/meta.json: -------------------------------------------------------------------------------- 1 | {"verdantCLI":1} -------------------------------------------------------------------------------- /test/client/react.js: -------------------------------------------------------------------------------- 1 | import { createHooks as baseCreateHooks } from "@verdant-web/react"; 2 | import schema from "./schema.js"; 3 | 4 | export function createHooks() { 5 | return baseCreateHooks(schema); 6 | } 7 | -------------------------------------------------------------------------------- /test/client/schema.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./schemaVersions/v1.js"; 2 | export { default } from "./schemaVersions/v1.js"; 3 | -------------------------------------------------------------------------------- /test/client/schema.js: -------------------------------------------------------------------------------- 1 | import schema from './schemaVersions/v1.js'; 2 | const finalSchema = { wip: false, ...schema }; 3 | export default finalSchema; -------------------------------------------------------------------------------- /test/client/schemaVersions/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | import { StorageSchema } from '@verdant-web/common'; 3 | declare const versions: StorageSchema[]; 4 | export default versions; 5 | -------------------------------------------------------------------------------- /test/client/schemaVersions/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * @generated - do not modify this file. 4 | */ 5 | import v1 from './v1.js'; 6 | 7 | export default [v1] 8 | -------------------------------------------------------------------------------- /test/lib/createTestFile.ts: -------------------------------------------------------------------------------- 1 | export function createTestFile(content?: string) { 2 | return new window.File( 3 | content ? [new Blob([content], { type: 'text/plain' })] : [], 4 | 'test.txt', 5 | { 6 | type: 'text/plain', 7 | }, 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /test/lib/log.ts: -------------------------------------------------------------------------------- 1 | export function log(msg: string) { 2 | console.log(`⭐⭐⭐⭐ ${msg}`); 3 | } 4 | -------------------------------------------------------------------------------- /test/migrations/index.ts: -------------------------------------------------------------------------------- 1 | import v1 from "./v1.js"; 2 | 3 | export default [v1]; 4 | -------------------------------------------------------------------------------- /test/migrations/v1.ts: -------------------------------------------------------------------------------- 1 | import v1Schema, { 2 | MigrationTypes as V1Types, 3 | } from "../client/schemaVersions/v1.js"; 4 | import { createMigration } from "@verdant-web/store"; 5 | 6 | // this is your first migration, so no logic is necessary! 7 | export default createMigration(v1Schema); 8 | -------------------------------------------------------------------------------- /test/scripts/cleanupDbs.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | const dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | 7 | // empty .client-sqlite directory 8 | const clientSqliteDir = path.join(dirname, '..', '.client-sqlite'); 9 | const files = await fs.readdir(clientSqliteDir); 10 | for (const file of files) { 11 | if (file === '.gitkeep') continue; 12 | try { 13 | await fs.unlink(path.join(clientSqliteDir, file)); 14 | } finally { 15 | } 16 | } 17 | 18 | // remove all .sqlite, .sqlite-shm, and .sqlite-wal files from root directory 19 | const rootDir = path.join(dirname, '..'); 20 | const rootFiles = await fs.readdir(rootDir); 21 | for (const file of rootFiles) { 22 | if ( 23 | file.endsWith('.sqlite') || 24 | file.endsWith('.sqlite-shm') || 25 | file.endsWith('.sqlite-wal') 26 | ) { 27 | try { 28 | await fs.unlink(path.join(rootDir, file)); 29 | } finally { 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/setup/indexedDB.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IDBCursor, 3 | IDBDatabase, 4 | IDBFactory, 5 | IDBKeyRange, 6 | IDBRequest, 7 | IDBTransaction, 8 | } from 'fake-indexeddb'; 9 | import 'fake-indexeddb/auto'; 10 | import { WebSocket } from 'ws'; 11 | 12 | if (typeof window !== 'undefined') { 13 | window.IDBKeyRange = IDBKeyRange; 14 | window.IDBCursor = IDBCursor; 15 | window.IDBDatabase = IDBDatabase; 16 | window.IDBTransaction = IDBTransaction; 17 | window.IDBRequest = IDBRequest; 18 | window.IDBFactory = IDBFactory; 19 | } 20 | 21 | // @ts-ignore 22 | global.WebSocket = WebSocket; 23 | 24 | // patch URL.createObjectURL to return a string 25 | // @ts-ignore 26 | URL.createObjectURL = (blob: Blob) => { 27 | return `blob:${blob.type}:${blob.size}`; 28 | }; 29 | URL.revokeObjectURL = () => {}; 30 | 31 | process.env.TEST = 'true'; 32 | process.env.NODE_ENV = 'test'; 33 | process.env.VITEST = 'true'; 34 | 35 | // FAIL LOUDLY on unhandled promise rejections / errors 36 | process.on('unhandledRejection', (reason) => { 37 | // eslint-disable-next-line no-console 38 | console.error(`❌ FAILED TO HANDLE PROMISE REJECTION`, reason); 39 | 40 | throw reason; 41 | }); 42 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "esModuleInterop": true, 6 | "skipLibCheck": true, 7 | "lib": ["ESNext", "DOM"] 8 | }, 9 | "include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig((c) => ({ 4 | test: { 5 | // environment: process.env.SQLITE ? 'node' : 'jsdom', 6 | environment: 'jsdom', 7 | clearMocks: true, 8 | 9 | setupFiles: ['setup/indexedDB.ts'], 10 | 11 | testTimeout: 30000, 12 | }, 13 | resolve: { 14 | conditions: ['development', 'default'], 15 | }, 16 | })); 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "noLib": false, 5 | "target": "es2017", 6 | "sourceMap": true, 7 | "module": "NodeNext", 8 | "moduleResolution": "NodeNext", 9 | "lib": ["esnext"], 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "allowSyntheticDefaultImports": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "jsx": "react-jsx", 17 | "downlevelIteration": true, 18 | "experimentalDecorators": true, 19 | "isolatedModules": true, 20 | "customConditions": ["development"], 21 | "declaration": true 22 | }, 23 | "exclude": ["node_modules", "**/*/dist"] 24 | } 25 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": [".next/**", "dist/**"] 7 | }, 8 | "ci:test:unit": { 9 | "outputs": [], 10 | "inputs": ["**/*.tsx", "**/*.ts", "**/*.test.tsx", "**/*.test.ts"] 11 | }, 12 | "ci:test:integration": { 13 | "outputs": [], 14 | "inputs": ["**/*.tsx", "**/*.ts", "**/*.test.tsx", "**/*.test.ts"], 15 | "cache": false 16 | }, 17 | "dev": { 18 | "cache": false 19 | }, 20 | "gen": { 21 | "cache": false, 22 | "outputs": ["**/.generated/*.ts"] 23 | }, 24 | "preview": { 25 | "cache": false 26 | }, 27 | "link": { 28 | "cache": false 29 | }, 30 | "typecheck": { 31 | "outputs": [], 32 | "inputs": ["**/*.tsx", "**/*.ts", "tsconfig.json"] 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from 'vitest/config'; 2 | 3 | export default defineWorkspace(['packages/*', 'test']); 4 | --------------------------------------------------------------------------------