├── .env.defaults ├── .eslintrc.js ├── .github ├── actions │ ├── generate-hash-keys │ │ └── action.yml │ ├── install │ │ └── action.yml │ ├── setup-aws │ │ └── action.yml │ ├── setup-node │ │ └── action.yml │ └── tests │ │ ├── build-cli │ │ └── action.yml │ │ └── test-database │ │ └── action.yml ├── changelog.config.js ├── pull_request_template.md └── workflows │ ├── build-shared-cache.yml │ ├── draft-release.yml │ ├── publish-postgres-container-with-data.yml │ ├── release-cli.yml │ └── tests.yml ├── .gitignore ├── .node_version ├── .vscode ├── extensions.json ├── settings.json └── tasks.json ├── .yarn ├── patches │ ├── @monaco-editor-loader-npm-1.3.3-f252d6bf1e.patch │ ├── @redwoodjs-cli-npm-1.0.0-rc.3-6200380c11.patch │ ├── aws-cdk-lib-npm-2.84.0-f3099fd809.patch │ ├── pg-protocol-npm-1.6.0-089a4b1d3c.patch │ ├── react-hook-form-npm-6.13.0-30cf3402c4.patch │ ├── siphash-npm-1.1.0-da47c7a043.patch │ └── yargs-npm-17.7.1-0758ec0e50.patch └── plugins │ └── @yarnpkg │ └── plugin-interactive-tools.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── ambient.d.ts ├── cli ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── __fixtures__ │ ├── .snaplet │ │ ├── config.json │ │ └── snapshots │ │ │ ├── 1659909667799-ella-mountains-system │ │ │ └── summary.json │ │ │ └── 1659946195116-pixel-bypass │ │ │ └── summary.json │ ├── badSchema │ │ ├── schema-with-missing-dependency.sql │ │ └── schema-with-syntax-error.sql │ ├── bugs │ │ ├── S-1084.sql │ │ └── S-801.sql │ ├── config.json │ ├── configWithConnectionString.json │ ├── configWithDbCredentials.json │ ├── configWithoutDb.json │ ├── dbWithNonUniformsRelations.sql │ ├── indexSize │ │ ├── schema.sql │ │ └── tables │ │ │ └── public-companies.csv.br │ ├── noUniqueConstraint.sql │ ├── non-trivial-database-2.sql │ ├── non-trivial-database.sql │ ├── northwind.sql │ ├── public-example-table.csv │ ├── summary │ │ ├── array-index │ │ │ └── summary.json │ │ ├── firewall-input │ │ │ └── summary.json │ │ └── port-index │ │ │ └── summary.json │ └── videolet.sql ├── ambient.d.ts ├── docs │ └── snapshot-capture.excalidraw ├── e2e │ ├── ERD-2_fk_to_same_table.png │ ├── ERD-2_fks_to_same_table_with_parent.png │ ├── ERD-basic_book.png │ ├── ERD-diff_paths.png │ ├── ERD-many_to_many.png │ ├── ERD-many_to_many_smae_grandp.png │ ├── ERD-same_table_circular_ref.png │ ├── __fixtures__ │ │ ├── configs │ │ │ ├── project │ │ │ │ ├── invalid │ │ │ │ │ └── .snaplet │ │ │ │ │ │ └── config.json │ │ │ │ └── valid │ │ │ │ │ ├── .snaplet │ │ │ │ │ └── config.json │ │ │ │ │ └── config.js │ │ │ └── system │ │ │ │ └── valid │ │ │ │ └── .snaplet │ │ │ │ └── config.json │ │ └── snaplet_schema.sql │ ├── bugs │ │ └── e2e.S-798.test.ts │ ├── capture │ │ ├── e2e.capture-advanced.test.ts │ │ ├── e2e.capture.test.ts │ │ └── subset │ │ │ ├── bugs │ │ │ ├── e2e.S-1084.test.ts │ │ │ └── e2e.S-1093.test.ts │ │ │ ├── e2e.capture-living-data.test.ts │ │ │ ├── e2e.constraints-advance.test.ts │ │ │ ├── e2e.constraints-basic.test.ts │ │ │ ├── e2e.constraints-composite.test.ts │ │ │ ├── e2e.constraints-cycles.test.ts │ │ │ ├── e2e.data-types.test.ts │ │ │ ├── e2e.missing-pk.test.ts │ │ │ ├── e2e.non-trivial-eager.test.ts │ │ │ ├── e2e.non-trivial.test.ts │ │ │ ├── e2e.northwind.test.ts │ │ │ ├── e2e.seed-reduce-advanced-excluded.test.ts │ │ │ ├── e2e.seed-reduce-advanced-row-limit.test.ts │ │ │ ├── e2e.seed-reduce-advanced.test.ts │ │ │ ├── e2e.seed-reduce-basic.test.ts │ │ │ ├── e2e.subset-reduce-traversal.test.ts │ │ │ ├── e2e.subset-virtualkeys.test.ts │ │ │ └── e2e.videolet.test.ts │ ├── config │ │ ├── e2e-config-capture-restore-v3.test.ts │ │ └── e2e-config-errors.test.ts │ ├── e2e-capture-restore.test.ts │ ├── e2e.binary-smoke.test.ts │ ├── e2e.test.ts │ └── restore │ │ ├── e2e.partial-restore.test.ts │ │ ├── e2e.restore-advanced.test.ts │ │ └── e2e.restore.test.ts ├── package.json ├── scripts │ ├── assertNewRelease.js │ ├── createReleaseVersion.js │ ├── dev.ts │ ├── displayCLIVersion.js │ ├── downloadAssetsFromRelease.js │ ├── postInstall.js │ ├── predictions │ │ ├── package.json │ │ ├── predictionsStoreUpdate.js │ │ └── yarn.lock │ ├── publishToNPM.js │ ├── uploadCLI.js │ └── uploadContainer.js ├── src │ ├── bootstrap.ts │ ├── commands │ │ ├── catchAll │ │ │ ├── catchAllCommand.handler.ts │ │ │ └── catchAllCommand.ts │ │ ├── config │ │ │ ├── actions │ │ │ │ └── generate │ │ │ │ │ ├── generateAction.handler.ts │ │ │ │ │ ├── generateAction.ts │ │ │ │ │ └── generateAction.types.ts │ │ │ ├── configCommand.ts │ │ │ └── debugConfig.ts │ │ ├── debug │ │ │ └── debugCommand.ts │ │ ├── discord │ │ │ ├── discordCommand.handler.ts │ │ │ └── discordCommand.ts │ │ ├── docs │ │ │ ├── docsCommand.handler.ts │ │ │ └── docsCommand.ts │ │ ├── lib │ │ │ └── inputTargetDatabaseUrl.ts │ │ ├── setup │ │ │ ├── setupCommand.handler.ts │ │ │ └── setupCommand.ts │ │ ├── snapshot │ │ │ ├── actions │ │ │ │ ├── capture │ │ │ │ │ ├── captureAction.handler.ts │ │ │ │ │ ├── captureAction.ts │ │ │ │ │ ├── captureAction.types.ts │ │ │ │ │ ├── lib │ │ │ │ │ │ ├── addCaptureErrorContext.ts │ │ │ │ │ │ ├── debugCapture.ts │ │ │ │ │ │ ├── displayProgress.ts │ │ │ │ │ │ ├── paths.ts │ │ │ │ │ │ ├── pgDump.test.ts │ │ │ │ │ │ ├── pgDump.ts │ │ │ │ │ │ ├── pgDumpProgress.ts │ │ │ │ │ │ └── subsetV3 │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── lib │ │ │ │ │ │ │ ├── addAndIntersect.test.ts │ │ │ │ │ │ │ ├── addAndIntersect.ts │ │ │ │ │ │ │ ├── configToSQL.ts │ │ │ │ │ │ │ ├── debug.ts │ │ │ │ │ │ │ ├── findDisconnectedTables.ts │ │ │ │ │ │ │ ├── getRelationshipOption.test.ts │ │ │ │ │ │ │ ├── getRelationshipOption.ts │ │ │ │ │ │ │ ├── pgSnapshot.ts │ │ │ │ │ │ │ ├── queryBuilders.ts │ │ │ │ │ │ │ ├── queryStreamUtils.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ ├── steps │ │ │ │ │ │ │ ├── dumpTablesToCSV.ts │ │ │ │ │ │ │ ├── emitterToOnUpdate.ts │ │ │ │ │ │ │ ├── events.ts │ │ │ │ │ │ │ ├── pgCopyTable.test.ts │ │ │ │ │ │ │ ├── pgCopyTable.ts │ │ │ │ │ │ │ ├── queryTableStream.ts │ │ │ │ │ │ │ └── runCaptureV3.ts │ │ │ │ │ │ │ └── storage │ │ │ │ │ │ │ ├── sqlite │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ └── steps │ │ │ │ │ │ ├── captureSnapshot.ts │ │ │ │ │ │ └── errors.ts │ │ │ │ ├── list │ │ │ │ │ ├── listAction.handler.ts │ │ │ │ │ ├── listAction.ts │ │ │ │ │ └── listAction.types.ts │ │ │ │ ├── restore │ │ │ │ │ ├── lib │ │ │ │ │ │ ├── SnapshotCache.ts │ │ │ │ │ │ ├── SnapshotImporter │ │ │ │ │ │ │ ├── SnapshotImporter.test.ts │ │ │ │ │ │ │ ├── SnapshotImporter.ts │ │ │ │ │ │ │ ├── createConstraints.ts │ │ │ │ │ │ │ ├── dropConstraints.ts │ │ │ │ │ │ │ ├── fetchSideEffectStatements.ts │ │ │ │ │ │ │ ├── filterTables.ts │ │ │ │ │ │ │ ├── fixSequences.ts │ │ │ │ │ │ │ ├── fixViews.ts │ │ │ │ │ │ │ ├── importSchema.ts │ │ │ │ │ │ │ ├── importTablesData.ts │ │ │ │ │ │ │ ├── truncateTables.ts │ │ │ │ │ │ │ └── vaccumTables.ts │ │ │ │ │ │ ├── pgSchemaTools.test.ts │ │ │ │ │ │ ├── pgSchemaTools.ts │ │ │ │ │ │ ├── readNthLine.ts │ │ │ │ │ │ ├── restoreError.test.ts │ │ │ │ │ │ └── restoreError.ts │ │ │ │ │ ├── restoreAction.handler.ts │ │ │ │ │ ├── restoreAction.ts │ │ │ │ │ ├── restoreAction.types.ts │ │ │ │ │ └── steps │ │ │ │ │ │ ├── createConstraints.ts │ │ │ │ │ │ ├── displayInfo.ts │ │ │ │ │ │ ├── dropConstraints.ts │ │ │ │ │ │ ├── filterTables.ts │ │ │ │ │ │ ├── fixSequences.ts │ │ │ │ │ │ ├── fixViews.ts │ │ │ │ │ │ ├── importSchema.ts │ │ │ │ │ │ ├── importTableData.ts │ │ │ │ │ │ ├── resetDatabase.ts │ │ │ │ │ │ ├── truncateTables.ts │ │ │ │ │ │ └── vacuumTables.ts │ │ │ │ └── update │ │ │ │ │ ├── updateAction.handler.ts │ │ │ │ │ ├── updateAction.ts │ │ │ │ │ └── updateAction.types.ts │ │ │ └── snapshotCommand.ts │ │ └── upgrade │ │ │ ├── upgradeCommand.handler.ts │ │ │ └── upgradeCommand.ts │ ├── components │ │ ├── README.md │ │ ├── findSnapshotSummary.ts │ │ ├── generate.ts │ │ ├── needs │ │ │ ├── brotliCommand.ts │ │ │ ├── connectionUrl.ts │ │ │ ├── databaseConnection.ts │ │ │ ├── index.ts │ │ │ ├── logError.ts │ │ │ ├── pgDumpCliCommand.ts │ │ │ ├── privateKey.ts │ │ │ ├── projectPaths.ts │ │ │ ├── publicKey.ts │ │ │ ├── snapshot.ts │ │ │ └── validConnectionString.ts │ │ ├── readConfig.ts │ │ └── writeConfig.ts │ ├── index.ts │ ├── lib │ │ ├── config.ts │ │ ├── constants.ts │ │ ├── display.ts │ │ ├── exit.ts │ │ ├── format.test.ts │ │ ├── format.ts │ │ ├── generateClient.ts │ │ ├── getAliasedDataModel.ts │ │ ├── handleFail.ts │ │ ├── handleTeardown.ts │ │ ├── hosts │ │ │ ├── absPathSnapshotHost.test.ts │ │ │ ├── absPathSnapshotHost.ts │ │ │ ├── hosts.test.ts │ │ │ ├── hosts.ts │ │ │ ├── localSnapshotHost.test.ts │ │ │ └── localSnapshotHost.ts │ │ ├── initConfigOrExit.ts │ │ ├── links.ts │ │ ├── prettyBytes.ts │ │ ├── sentry.ts │ │ ├── server.ts │ │ ├── sleep.ts │ │ ├── spinner.ts │ │ ├── typed-emitter.ts │ │ ├── upgrade.ts │ │ └── weblinks.ts │ ├── middlewares │ │ ├── checkForUpdatesMiddleware.ts │ │ ├── index.ts │ │ ├── removePgEnvarMiddleware.ts │ │ ├── runAllMigrationsMiddleware.test.ts │ │ └── runAllMigrationsMiddleware.ts │ ├── testing │ │ ├── checkConstraints.ts │ │ ├── createTestCapturePath.ts │ │ ├── createTestDb.test.ts │ │ ├── createTestDb.ts │ │ ├── createTestProjectDirV2.ts │ │ ├── createTestRole.ts │ │ ├── debug.ts │ │ ├── getTestAccessToken.ts │ │ ├── globalSetup.ts │ │ ├── index.ts │ │ ├── runScript.ts │ │ ├── runSnapletCli.ts │ │ └── setup.ts │ └── vendor │ │ ├── boxen.d.ts │ │ └── boxen.js ├── tsconfig.build.json ├── tsconfig.json └── vite.config.mts ├── docs ├── README.md ├── components │ ├── Editor.tsx │ ├── Image.tsx │ ├── Logo.tsx │ ├── ReleaseNotes.tsx │ ├── Step.tsx │ ├── TutorialSchema.tsx │ ├── Zoom.tsx │ ├── recipes │ │ ├── OneToManySchema.tsx │ │ ├── SlackAppERD.tsx │ │ └── UniqueConstraintSchema.tsx │ ├── seed-tutorial.ts │ └── vs-dark.ts ├── copycat-releases.md ├── lib │ └── getReleases.ts ├── netlify.toml ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.mdx │ ├── _meta.json │ └── snapshot │ │ ├── _meta.json │ │ ├── core-concepts │ │ ├── _meta.json │ │ ├── capture.mdx │ │ └── restore.mdx │ │ ├── getting-started │ │ ├── _meta.json │ │ ├── installation.mdx │ │ ├── overview.mdx │ │ └── quick-start.mdx │ │ ├── guides │ │ ├── _meta.json │ │ ├── postgresql.mdx │ │ └── self-hosting.mdx │ │ ├── migrations.mdx │ │ ├── recipes │ │ ├── _meta.json │ │ ├── aws.mdx │ │ ├── github-action.mdx │ │ ├── neon.mdx │ │ ├── netlify.mdx │ │ ├── prisma.mdx │ │ ├── supabase.mdx │ │ └── vercel.mdx │ │ ├── reference │ │ ├── _meta.json │ │ ├── cli.mdx │ │ ├── configuration.mdx │ │ ├── connection-strings.mdx │ │ └── environment-variables.mdx │ │ ├── release-notes.mdx │ │ └── security.mdx ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── android-icon-192x192.png │ ├── android-icon-256x256.png │ ├── apple-touch-icon.png │ ├── core-concepts │ │ ├── capture │ │ │ └── snappy-flow.svg │ │ └── deploy │ │ │ ├── deploy-01.png │ │ │ └── deploy-02.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── getting-started │ │ └── quick-start │ │ │ ├── quick-start-01-5.png │ │ │ ├── quick-start-01.png │ │ │ ├── quick-start-01.webp │ │ │ ├── quick-start-02.png │ │ │ ├── quick-start-03.png │ │ │ ├── quick-start-04.png │ │ │ ├── quick-start-05.png │ │ │ ├── quick-start-06.png │ │ │ ├── quick-start-07.png │ │ │ ├── quick-start-08.png │ │ │ ├── quick-start-10.png │ │ │ ├── quick-start-11.png │ │ │ ├── quick-start-12.png │ │ │ ├── quick-start-13.png │ │ │ ├── quick-start-14.png │ │ │ ├── quick-start-15.png │ │ │ ├── quick-start-16.png │ │ │ └── quick-start-17.png │ ├── guides │ │ └── postgresql │ │ │ └── snappy-spilt-milk.svg │ ├── ms-icon-150x150.png │ ├── og.png │ ├── recipes │ │ ├── neon │ │ │ ├── 00-snaplet-snapshot.png │ │ │ ├── 01-snaplet-cli.png │ │ │ ├── 02-neon-conn.png │ │ │ ├── 03-restore-setup.png │ │ │ └── 04-restore-snapshot.png │ │ ├── netlify │ │ │ ├── netlify-access-token1.png │ │ │ ├── netlify-build-logs.png │ │ │ ├── netlify-db-url-new.png │ │ │ ├── netlify-github-token.png │ │ │ ├── netlify-personal-access-token1.png │ │ │ ├── netlify-preview-plugin-logo.png │ │ │ ├── netlify-project-id.png │ │ │ ├── netlify-snaplet-access-token1.png │ │ │ ├── netlify-snaplet-connect-database-success.png │ │ │ ├── netlify-snaplet-connect-database.png │ │ │ ├── netlify-snaplet-enable1.png │ │ │ ├── netlify-snaplet-enable2.png │ │ │ ├── netlify-snaplet-problem.png │ │ │ ├── netlify-snaplet-review-save.png │ │ │ ├── netlify-snaplet-snapshot-success.png │ │ │ └── netlify-snaplet-solution.png │ │ ├── supabase │ │ │ ├── 20_todos.webp │ │ │ ├── connect_to_supabase.webp │ │ │ ├── nextjs_todos.webp │ │ │ ├── onboarding_capture.webp │ │ │ ├── onboarding_start.webp │ │ │ ├── snaplet-supabase-schema-exclude.png │ │ │ ├── snappy-holding-supabase-logo.svg │ │ │ ├── snappy-with-supabase-ball.svg │ │ │ └── supabase_new_project.webp │ │ └── visual-studio-code │ │ │ ├── vscode-01.webp │ │ │ ├── vscode-02.webp │ │ │ ├── vscode-03.webp │ │ │ ├── vscode-04.webp │ │ │ ├── vscode-05.webp │ │ │ └── vscode-06.webp │ ├── security │ │ └── Snaplet-Security.png │ └── site.webmanifest ├── redirects │ ├── 09_Oct_2023.json │ └── 12_March_2024.json ├── releases │ ├── 20240506160000-v0.97.10.md │ ├── 20240506163727-v0.97.11.md │ ├── 20240509073336-v0.97.12.md │ ├── 20240513130937-v0.97.13.md │ ├── 20240514124032-v0.97.15.md │ ├── 20240516084229-v0.97.16.md │ ├── 20240516163618-v0.97.17.md │ ├── 20240521054140-v0.97.18.md │ ├── 20240521071914-v0.97.19.md │ ├── 20240527124203-v0.97.20.md │ └── 20240730093941-v0.98.0.md ├── remarkrc.js ├── styles │ └── global.css ├── tailwind.config.js ├── theme.config.tsx ├── tsconfig.json └── tutorials │ └── generate │ ├── .snaplet │ ├── snaplet-client.d.ts │ └── snaplet.d.ts │ └── snaplet.config.ts ├── knip.json ├── package.json ├── packages ├── cli │ ├── .gitignore │ ├── LICENSE.md │ ├── README.md │ ├── bin │ │ └── snaplet.js │ ├── package.json │ ├── scripts │ │ └── tasks.mjs │ ├── src │ │ ├── cli.ts │ │ └── index.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ └── yarn.lock └── sdk │ ├── README.md │ ├── __fixtures__ │ ├── configs │ │ ├── project │ │ │ ├── invalid │ │ │ │ └── .snaplet │ │ │ │ │ └── config.json │ │ │ └── valid │ │ │ │ ├── .snaplet │ │ │ │ └── config.json │ │ │ │ └── config.js │ │ ├── system │ │ │ └── valid │ │ │ │ └── .snaplet │ │ │ │ └── config.json │ │ └── v2 │ │ │ ├── .snaplet │ │ │ ├── snaplet-client.d.ts │ │ │ └── snaplet.d.ts │ │ │ ├── snaplet.config.ts │ │ │ └── tsconfig.json │ ├── paths │ │ └── project │ │ │ └── valid │ │ │ └── .snaplet │ │ │ └── .keep │ ├── snaplet_schema.sql │ └── sqlite │ │ └── chinook.db │ ├── ambient.d.ts │ ├── package.json │ ├── src │ ├── auth.ts │ ├── config │ │ ├── config.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── loadModule.test.ts │ │ ├── loadModule.ts │ │ ├── projectConfig │ │ │ ├── projectConfig.test.ts │ │ │ └── projectConfig.ts │ │ ├── snapletConfig │ │ │ ├── introspectConfig.test.ts │ │ │ ├── introspectConfig.ts │ │ │ ├── schemasConfig.test.ts │ │ │ ├── schemasConfig.ts │ │ │ ├── snapletConfig.test.ts │ │ │ ├── snapletConfig.ts │ │ │ ├── subsetConfig.test.ts │ │ │ ├── subsetConfig.ts │ │ │ ├── transformConfig.ts │ │ │ └── v2 │ │ │ │ ├── ast │ │ │ │ ├── babelDepsLoader.ts │ │ │ │ ├── mergeConfigWithOverride.test.ts │ │ │ │ └── mergeConfigWithOverride.ts │ │ │ │ ├── calculateIncludedTables.test.ts │ │ │ │ ├── calculateIncludedTables.ts │ │ │ │ ├── generateTypes │ │ │ │ ├── escapeKey.ts │ │ │ │ ├── generateIntrospectTypes.ts │ │ │ │ ├── generateSelectTypes.ts │ │ │ │ ├── generateStructureTypes.ts │ │ │ │ ├── generateSubsetTypes.ts │ │ │ │ ├── generateTransformTypes.ts │ │ │ │ └── generateTypes.ts │ │ │ │ ├── getConfig │ │ │ │ ├── errors.ts │ │ │ │ ├── findConfig.ts │ │ │ │ ├── getConfig.test.ts │ │ │ │ ├── getConfig.ts │ │ │ │ ├── getSource.ts │ │ │ │ ├── loadConfig.ts │ │ │ │ ├── parseConfig.test.ts │ │ │ │ └── parseConfig.ts │ │ │ │ ├── locateColumnConfig.test.ts │ │ │ │ └── locateColumnConfig.ts │ │ ├── systemConfig │ │ │ ├── systemConfig.test.ts │ │ │ └── systemConfig.ts │ │ └── v2 │ │ │ └── configv2.test.ts │ ├── constants.ts │ ├── createStructureObject.test.ts │ ├── createStructureObject.ts │ ├── csv.test.ts │ ├── csv.ts │ ├── db │ │ ├── client.test.ts │ │ ├── client.ts │ │ ├── connString │ │ │ ├── ConnectionString.ts │ │ │ ├── connString.test.ts │ │ │ ├── index.ts │ │ │ ├── isSupabaseUrl.test.ts │ │ │ ├── isSupabaseUrl.ts │ │ │ └── node.ts │ │ ├── index.ts │ │ ├── introspect │ │ │ ├── fixSequences.test.ts │ │ │ ├── fixSequences.ts │ │ │ ├── groupParentsChildrenRelations.test.ts │ │ │ ├── groupParentsChildrenRelations.ts │ │ │ ├── introspectDatabase.test.ts │ │ │ ├── introspectDatabase.ts │ │ │ ├── queries │ │ │ │ ├── fetchAuthorizedEnums.test.ts │ │ │ │ ├── fetchAuthorizedEnums.ts │ │ │ │ ├── fetchAuthorizedExtensions.test.ts │ │ │ │ ├── fetchAuthorizedExtensions.ts │ │ │ │ ├── fetchAuthorizedSchemas.test.ts │ │ │ │ ├── fetchAuthorizedSchemas.ts │ │ │ │ ├── fetchAuthorizedSequences.test.ts │ │ │ │ ├── fetchAuthorizedSequences.ts │ │ │ │ ├── fetchDatabaseRelationships.test.ts │ │ │ │ ├── fetchDatabaseRelationships.ts │ │ │ │ ├── fetchForbiddenSchemas.test.ts │ │ │ │ ├── fetchForbiddenSchemas.ts │ │ │ │ ├── fetchForbiddenTablesIds.test.ts │ │ │ │ ├── fetchForbiddenTablesIds.ts │ │ │ │ ├── fetchIndexes.test.ts │ │ │ │ ├── fetchIndexes.ts │ │ │ │ ├── fetchPrimaryKeys.test.ts │ │ │ │ ├── fetchPrimaryKeys.ts │ │ │ │ ├── fetchSequences.test.ts │ │ │ │ ├── fetchSequences.ts │ │ │ │ ├── fetchServerVersion.test.ts │ │ │ │ ├── fetchServerVersion.ts │ │ │ │ ├── fetchTablesAndColumns.test.ts │ │ │ │ ├── fetchTablesAndColumns.ts │ │ │ │ ├── fetchUniqueConstraints.test.ts │ │ │ │ ├── fetchUniqueConstraints.ts │ │ │ │ └── utils.ts │ │ │ └── utils.ts │ │ ├── sqlite │ │ │ ├── client.ts │ │ │ ├── introspectSqliteDatabase.test.ts │ │ │ ├── introspectSqliteDatabase.ts │ │ │ ├── introspectedSqliteToDataModel.test.ts │ │ │ ├── introspectedSqliteToDataModel.ts │ │ │ ├── mergeIntrospectConfigRelationships.ts │ │ │ └── queries │ │ │ │ ├── fetchDatabaseRelationships.test.ts │ │ │ │ ├── fetchDatabaseRelationships.ts │ │ │ │ ├── fetchPrimaryKeys.test.ts │ │ │ │ ├── fetchPrimaryKeys.ts │ │ │ │ ├── fetchSequences.test.ts │ │ │ │ ├── fetchSequences.ts │ │ │ │ ├── fetchServerVersion.test.ts │ │ │ │ ├── fetchServerVersion.ts │ │ │ │ ├── fetchTablesAndColumns.test.ts │ │ │ │ ├── fetchTablesAndColumns.ts │ │ │ │ ├── fetchUniqueConstraints.test.ts │ │ │ │ └── fetchUniqueConstraints.ts │ │ ├── structure.ts │ │ ├── tools.test.ts │ │ └── tools.ts │ ├── errors.ts │ ├── exports │ │ ├── api.ts │ │ ├── cli.ts │ │ ├── seed.ts │ │ └── web.ts │ ├── formatDatabaseName.ts │ ├── formatTime.ts │ ├── fs.test.ts │ ├── fs.ts │ ├── generate │ │ ├── clearDb.ts │ │ ├── detectMigrationTool.test.ts │ │ ├── detectMigrationTool.ts │ │ ├── filterSelectedTable.test.ts │ │ ├── filterSelectedTables.ts │ │ ├── fixtures │ │ │ ├── monorepo-project │ │ │ │ ├── package.json │ │ │ │ └── packages │ │ │ │ │ ├── a │ │ │ │ │ └── package.json │ │ │ │ │ └── b │ │ │ │ │ ├── drizzle.config.ts │ │ │ │ │ └── package.json │ │ │ ├── non-monorepo-project │ │ │ │ ├── package.json │ │ │ │ └── schema.prisma │ │ │ └── shapeExamples.ts │ │ ├── formatInput.ts │ │ ├── generateClient.ts │ │ ├── generateExampleSeedScript.test.ts │ │ ├── generateExampleSeedScript.ts │ │ ├── generateModelDefaults │ │ │ ├── addOptionsToModelDefaultCode.test.ts │ │ │ ├── addOptionsToModelDefaultCode.ts │ │ │ ├── generateJsonField.test.ts │ │ │ ├── generateJsonField.ts │ │ │ ├── generateModelDefaults.test.ts │ │ │ └── generateModelDefaults.ts │ │ ├── generateTransform.test.ts │ │ ├── generateTransform.ts │ │ ├── getSelectedTables.test.ts │ │ ├── getSelectedTables.ts │ │ ├── index.ts │ │ ├── runStatements.ts │ │ └── testing.ts │ ├── generateOrm │ │ ├── adapters │ │ │ ├── in-memory.ts │ │ │ ├── pg.ts │ │ │ └── pg │ │ │ │ ├── pg.test.ts │ │ │ │ ├── pg.ts │ │ │ │ └── withClientDefault.ts │ │ ├── client.ts │ │ ├── constraints.ts │ │ ├── dataModel │ │ │ ├── aliases.test.ts │ │ │ ├── aliases.ts │ │ │ ├── dataModel.test.ts │ │ │ ├── dataModel.ts │ │ │ ├── fingerprint.test.ts │ │ │ ├── fingerprint.ts │ │ │ └── updateDataModelSequences.ts │ │ ├── fixtures │ │ │ ├── introspection.ts │ │ │ ├── introspectionWithUniqueConstraints.ts │ │ │ └── same-table-names-different-schemas.ts │ │ ├── generateTypes.test.ts │ │ ├── generateTypes.ts │ │ ├── index.ts │ │ ├── plan │ │ │ ├── plan.test.ts │ │ │ ├── plan.ts │ │ │ ├── serialize.ts │ │ │ └── types.ts │ │ ├── readFingerprint.ts │ │ ├── setupSeedClient.ts │ │ ├── sql.ts │ │ ├── store.test.ts │ │ ├── store.ts │ │ └── utils │ │ │ ├── dedupePreferLast.ts │ │ │ ├── extractBranch.ts │ │ │ ├── generateUserModelsSequences.ts │ │ │ ├── mergeUserModels.ts │ │ │ ├── seed.ts │ │ │ └── sequenceFactory.ts │ ├── generateTypeDef.test.ts │ ├── generateTypeDef.ts │ ├── generateUniqueName.test.ts │ ├── generateUniqueName.ts │ ├── getCopycat.ts │ ├── git │ │ └── onGitBranchChange.ts │ ├── lang.test.ts │ ├── lang.ts │ ├── neon.ts │ ├── paths.test.ts │ ├── paths.ts │ ├── pgTypes.test.ts │ ├── pgTypes.ts │ ├── pii.ts │ ├── pii │ │ ├── piiImpact.test.ts │ │ └── piiImpact.ts │ ├── previewDatabase.ts │ ├── proxy │ │ ├── Backend.ts │ │ ├── BufferReader.ts │ │ ├── DatabaseError.ts │ │ ├── Frontend.ts │ │ ├── PgSocket.ts │ │ ├── SafeEventEmitter.ts │ │ ├── SmartBuffer.ts │ │ ├── protocol.ts │ │ ├── proxy.test.ts │ │ ├── proxy.ts │ │ └── sasl.ts │ ├── repo │ │ ├── addDevDependencies.test.ts │ │ ├── addDevDependencies.ts │ │ ├── index.ts │ │ ├── introspectRepo.test.ts │ │ ├── introspectRepo.ts │ │ └── packageManagers.ts │ ├── result.ts │ ├── shapeExtra.ts │ ├── shapes.test.ts │ ├── shapes.ts │ ├── shapesGenerate.ts │ ├── snapshot │ │ ├── compress.ts │ │ ├── crypto.test.ts │ │ ├── crypto.ts │ │ ├── execTaskStatus.test.ts │ │ ├── execTaskStatus.ts │ │ ├── paths.test.ts │ │ ├── paths.ts │ │ ├── snapshot.ts │ │ ├── summary.ts │ │ └── types.ts │ ├── sort.ts │ ├── streams.ts │ ├── subset │ │ ├── startTable.test.ts │ │ └── startTable.ts │ ├── systemManifest.ts │ ├── templates │ │ ├── categories │ │ │ ├── bits.ts │ │ │ ├── floats.ts │ │ │ ├── geometry.ts │ │ │ ├── integers.ts │ │ │ ├── strings.test.ts │ │ │ └── strings.ts │ │ ├── copycat.test.ts │ │ ├── copycat.ts │ │ ├── sets │ │ │ ├── autoTransformStrings.ts │ │ │ ├── seed │ │ │ │ └── pg.ts │ │ │ ├── transformConfigExamples.test.ts │ │ │ └── transformConfigExamples.ts │ │ ├── testing.ts │ │ └── types.ts │ ├── testing.ts │ ├── testing │ │ ├── createSqliteDb.ts │ │ ├── createTestDb.test.ts │ │ ├── createTestDb.ts │ │ ├── createTestRole.ts │ │ ├── createTestTmpDirectory.ts │ │ ├── debug.ts │ │ ├── fakes.ts │ │ ├── globalSetup.ts │ │ ├── index.ts │ │ └── setup.ts │ ├── transform.ts │ ├── transform │ │ ├── fallbacks.ts │ │ ├── fillRows │ │ │ ├── fillRows.test.ts │ │ │ ├── fillRows.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── transform.test.ts │ │ ├── transform.ts │ │ └── utils.ts │ ├── transformError.test.ts │ ├── transformError.ts │ ├── types.ts │ ├── v2 │ │ ├── paths.test.ts │ │ ├── paths.ts │ │ ├── transform.test.ts │ │ └── transform.ts │ ├── validConnectionString.ts │ └── x │ │ └── xdebug.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── vitest.config.mts ├── prettier.config.js ├── scripts ├── checkHealth.js ├── execTaskLogs.js ├── hideSentryPreviewEnv.ts ├── readAccessToken.js ├── unpublishCLI.js └── upgradeSnapletDep.js ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.react.json ├── turbo.json └── yarn.lock /.env.defaults: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres@localhost/snaplet_development 2 | 3 | SNAPLET_SNAPSHOT_IPS=3.67.57.100 3.68.126.236 35.158.237.73 4 | 5 | CIPHER_KEY=<> 6 | 7 | AWS_DEFAULT_REGION=<> 8 | AWS_DEFAULT_ACCOUNT=<> 9 | AWS_ACCESS_KEY_ID=<> 10 | AWS_SECRET_ACCESS_KEY=<> 11 | AWS_S3_BUCKET=<> 12 | -------------------------------------------------------------------------------- /.github/actions/generate-hash-keys/action.yml: -------------------------------------------------------------------------------- 1 | name: Generate cache hash keys 2 | 3 | outputs: 4 | yarn-deps-hash: 5 | description: "hash keys used for yarn deps" 6 | value: ${{ steps.generate-hash-keys.outputs.yarn-deps }} 7 | build-cli-hash: 8 | description: "hash keys used for cli build" 9 | value: ${{ steps.generate-hash-keys.outputs.build-cli }} 10 | 11 | runs: 12 | using: "composite" 13 | steps: 14 | - name: Generate hash keys 15 | id: generate-hash-keys 16 | run: | 17 | echo "yarn-deps=${{ hashFiles('yarn.lock') }}-${{ hashFiles('./api/prisma/schema.prisma') }}" >> $GITHUB_OUTPUT 18 | echo "build-cli=${{ hashFiles('./cli/src/**', './packages/sdk/src/**', './packages/cli/**') }}-${{ hashFiles('yarn.lock', './api/prisma/schema.prisma') }}" >> $GITHUB_OUTPUT 19 | shell: bash 20 | -------------------------------------------------------------------------------- /.github/actions/install/action.yml: -------------------------------------------------------------------------------- 1 | name: Install dependencies 2 | 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Install dependencies 7 | run: yarn install 8 | shell: bash 9 | -------------------------------------------------------------------------------- /.github/actions/setup-aws/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup AWS credentials 2 | 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: aws-actions/configure-aws-credentials@v2 7 | with: 8 | aws-access-key-id: ${{ secrets.PREVIEW_AWS_ACCESS_KEY_ID }} 9 | aws-secret-access-key: ${{ secrets.PREVIEW_AWS_SECRET_ACCESS_KEY }} 10 | aws-region: eu-central-1 -------------------------------------------------------------------------------- /.github/actions/setup-node/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Node.js 2 | 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: buildjet/setup-node@v3 7 | with: 8 | node-version: 18.18.2 9 | registry-url: 'https://registry.npmjs.org' 10 | - run: corepack enable 11 | shell: bash -------------------------------------------------------------------------------- /.github/actions/tests/build-cli/action.yml: -------------------------------------------------------------------------------- 1 | name: Build snaplet binary 2 | 3 | inputs: 4 | lookup-only: 5 | description: 'Should only lookup for the cache' 6 | required: false 7 | default: "false" 8 | skip-on-miss: 9 | description: 'choose if you want to skip on lookup miss' 10 | required: false 11 | default: "false" 12 | outputs: 13 | cache-hit: 14 | description: "cache has been hit" 15 | value: ${{ steps.build-cli-cache.outputs.cache-hit }} 16 | # Assume your in a workflow with install action already ran 17 | runs: 18 | using: "composite" 19 | steps: 20 | - name: Build Snaplet CLI 21 | if: steps.build-cli-cache.outputs.cache-hit != 'true' && inputs.skip-on-miss != 'true' 22 | run: | 23 | yarn build:binary -- --targets node18-linux-x64 24 | ls -al ./cli/bin 25 | shell: bash -------------------------------------------------------------------------------- /.github/actions/tests/test-database/action.yml: -------------------------------------------------------------------------------- 1 | name: Create test database 2 | 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Create database 7 | env: 8 | PGPASSWORD: postgres 9 | run: | 10 | psql -U postgres -h localhost -c "CREATE DATABASE snaplet_development;" 11 | shell: bash -------------------------------------------------------------------------------- /.github/changelog.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | types: [ 3 | { types: ["feat", "feature"], label: "🎉 New Features" }, 4 | { types: ["fix", "bugfix"], label: "🐛 Bugfixes" }, 5 | { types: ["improvements", "enhancement"], label: "🔨 Improvements" }, 6 | { types: ["perf"], label: "🏎️ Performance Improvements" }, 7 | { types: ["build", "ci"], label: "🏗️ Build System" }, 8 | { types: ["refactor"], label: "🪚 Refactors" }, 9 | { types: ["doc", "docs"], label: "📚 Documentation Changes" }, 10 | { types: ["test", "tests"], label: "🔍 Tests" }, 11 | { types: ["style"], label: "💅 Code Style Changes" }, 12 | { types: ["chore"], label: "🧹 Chores" }, 13 | { types: ["other"], label: "Other Changes" }, 14 | ], 15 | }; -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Describe your changes 2 | 3 | 4 | 5 | 6 | Fixes S-XXX 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/release-cli.yml: -------------------------------------------------------------------------------- 1 | name: Release CLI and packages 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | env: 9 | NODE_ENV: production 10 | 11 | jobs: 12 | release: 13 | runs-on: buildjet-4vcpu-ubuntu-2204 14 | permissions: 15 | contents: write 16 | concurrency: 17 | group: release-cli 18 | cancel-in-progress: true 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ./.github/actions/setup-node 22 | - name: Install node_modules 23 | uses: ./.github/actions/install 24 | with: 25 | fail-on-cache-miss: false 26 | 27 | - name: Upload and release CLI 28 | working-directory: ./cli 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | ADMIN_ACCESS_TOKEN: ${{ secrets.ADMIN_ACCESS_TOKEN }} 33 | run: | 34 | ./scripts/publishToNPM.js || true 35 | 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .env.prd 4 | .netlify 5 | .redwood 6 | .snaplet/structure.d.ts 7 | snaplet.config.js 8 | .snaplet/snapshots 9 | .env 10 | 11 | bytes.log 12 | 13 | snapshot-worker/bin 14 | dev.db 15 | dist 16 | api/bin 17 | api/cloudSnapshots 18 | api/logs 19 | cdk.out 20 | cdk.outputs.json 21 | cdk.context.json 22 | cdk.outputs.json 23 | cli/bin 24 | cli/dist 25 | dist-babel 26 | node_modules 27 | yarn-error.log 28 | web/public/mockServiceWorker.js 29 | web/public/snapshots 30 | debug.log 31 | tsconfig.tsbuildinfo 32 | tsconfig.*.tsbuildinfo 33 | 34 | data/ 35 | !cli/src/commands/generate/actions/data 36 | test-results/ 37 | 38 | # https://next.yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 39 | .pnp.* 40 | .yarn/* 41 | !.yarn/patches 42 | !.yarn/plugins 43 | !.yarn/releases 44 | !.yarn/sdks 45 | !.yarn/versions 46 | .tmp-earthly-out 47 | 48 | tmp/ 49 | logs/ 50 | 51 | .turbo 52 | .dts 53 | snaplet.config.d.ts 54 | tsconfig.snaplet.tsbuildinfo 55 | out 56 | 57 | .parcel-cache 58 | .snaplet/snaplet.d.ts 59 | cli/scripts/predictions/.yarn/* 60 | 61 | docs/.next 62 | docs/node_modules 63 | -------------------------------------------------------------------------------- /.node_version: -------------------------------------------------------------------------------- 1 | 20.15.1 -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "eamodio.gitlens", 5 | "ofhumanbondage.react-proptypes-intellisense", 6 | "mgmcdermott.vscode-language-babel", 7 | "wix.vscode-import-cost", 8 | "pflannery.vscode-versionlens", 9 | "editorconfig.editorconfig", 10 | "prisma.prisma", 11 | "bradlc.vscode-tailwindcss" 12 | ], 13 | "unwantedRecommendations": [] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "files.trimTrailingWhitespace": true, 4 | "editor.formatOnSave": false, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | }, 8 | "[prisma]": { 9 | "editor.formatOnSave": true 10 | }, 11 | "files.watcherExclude": { 12 | "**/.git/objects/**": true, 13 | "**/.git/subtree-cache/**": true, 14 | "**/.hg/store/**": true 15 | }, 16 | "typescript.tsdk": "node_modules/typescript/lib", 17 | "javascript.preferences.importModuleSpecifierEnding": "js", 18 | "typescript.preferences.importModuleSpecifierEnding": "js", 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "dev:type-check", 6 | "type": "shell", 7 | "command": "yarn type-check --watch", 8 | "isBackground": true, 9 | "presentation": { 10 | "reveal": "always", 11 | "panel": "dedicated", 12 | "group": "dev" 13 | }, 14 | }, 15 | { 16 | "label": "dev", 17 | "dependsOn": [ 18 | "dev:type-check", 19 | ], 20 | "problemMatcher": [] 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /.yarn/patches/@redwoodjs-cli-npm-1.0.0-rc.3-6200380c11.patch: -------------------------------------------------------------------------------- 1 | diff --git a/dist/middleware/checkForBabelConfig.js b/dist/middleware/checkForBabelConfig.js 2 | index f522c5b7ecbeea1ecde56837113abd34ed2e15be..f386f9c3457bbc9a9b225cb044e7f41fceba93ce 100644 3 | --- a/dist/middleware/checkForBabelConfig.js 4 | +++ b/dist/middleware/checkForBabelConfig.js 5 | @@ -21,7 +21,7 @@ var _colors = _interopRequireDefault(require("../lib/colors")); 6 | const isUsingBabelRc = () => { 7 | return _fastGlob.default.sync('**/*/.babelrc(.*)?', { 8 | cwd: (0, _internal.getPaths)().base, 9 | - ignore: 'node_modules' 10 | + ignore: '**/node_modules' 11 | }).length > 0; 12 | }; 13 | 14 | -------------------------------------------------------------------------------- /.yarn/patches/react-hook-form-npm-6.13.0-30cf3402c4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/package.json b/package.json 2 | index a2843d7c3520c5fcd3ddabdc9520611c157d4298..f54f75f70b9b75b6067372e68821d197ef19ec11 100644 3 | --- a/package.json 4 | +++ b/package.json 5 | @@ -17,6 +17,7 @@ 6 | "exports": { 7 | "./package.json": "./package.json", 8 | ".": { 9 | + "types": "./dist/index.d.ts", 10 | "import": "./dist/index.esm.js", 11 | "require": "./dist/index.js" 12 | }, 13 | -------------------------------------------------------------------------------- /.yarn/patches/siphash-npm-1.1.0-da47c7a043.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/siphash.js b/lib/siphash.js 2 | index 0f4a6f71e4accab536ccd2786551a255bc211b14..cd29f890266bee184a42d0e0fce7ede1eff02113 100644 3 | --- a/lib/siphash.js 4 | +++ b/lib/siphash.js 5 | @@ -164,4 +164,4 @@ var SipHash = (function () { 6 | string_to_u8: string_to_u8 7 | }; 8 | })(); 9 | -var module = module || {}, exports = module.exports = SipHash; 10 | +module.exports = SipHash; 11 | -------------------------------------------------------------------------------- /.yarn/patches/yargs-npm-17.7.1-0758ec0e50.patch: -------------------------------------------------------------------------------- 1 | diff --git a/index.mjs b/index.mjs 2 | index c6440b9edca315488f1976061edf88803ecac954..76926d256070b8c90f354f4ecb993bff50e6eb9d 100644 3 | --- a/index.mjs 4 | +++ b/index.mjs 5 | @@ -2,7 +2,9 @@ 6 | 7 | // Bootstraps yargs for ESM: 8 | import esmPlatformShim from './lib/platform-shims/esm.mjs'; 9 | -import {YargsFactory} from './build/lib/yargs-factory.js'; 10 | +import { YargsFactory } from './build/lib/yargs-factory.js'; 11 | +import { hideBin } from './build/lib/utils/process-argv.js'; 12 | 13 | const Yargs = YargsFactory(esmPlatformShim); 14 | -export default Yargs; 15 | +const Argv = Yargs(hideBin(process.argv)); 16 | +export default Argv; 17 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | defaultSemverRangePrefix: "" 4 | 5 | plugins: 6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 7 | spec: "@yarnpkg/plugin-interactive-tools" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Snaplet, Inc. 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 | -------------------------------------------------------------------------------- /cli/.gitignore: -------------------------------------------------------------------------------- 1 | .snaplet/snapshots 2 | .snaplet/snaplet.d.ts 3 | -------------------------------------------------------------------------------- /cli/README.md: -------------------------------------------------------------------------------- 1 | # Snaplet CLI 2 | 3 | Seed your development database with accurate data. [Read the docs](https://docs.snaplet.dev) 4 | 5 | # Running e2e tests locally 6 | 7 | We use `snaplet dev` during development, but for e2e tests the latency is a bit high. 8 | In order to run the tests against your local database, do the following: 9 | 10 | ```terminal 11 | SNAPLET_TARGET_DATABASE_URL=postgresql://postgres@localhost/snaplet_development yarn snaplet ss r --latest 12 | DATABASE_URL=postgresql://postgres@localhost/snaplet_development yarn dev 13 | ``` 14 | 15 | Then in another terminal: 16 | ``` 17 | yarn workspace cli test:debug ./cli/e2e/ 18 | ``` 19 | 20 | 21 | -------------------------------------------------------------------------------- /cli/__fixtures__/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "databaseId": "1234-1234-1234-1234" 3 | } -------------------------------------------------------------------------------- /cli/__fixtures__/.snaplet/snapshots/1659909667799-ella-mountains-system/summary.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": "2022-08-07T22:01:07.799Z", 3 | "name": "ella-mountains-system", 4 | "tags": ["dog"] 5 | } -------------------------------------------------------------------------------- /cli/__fixtures__/.snaplet/snapshots/1659946195116-pixel-bypass/summary.json: -------------------------------------------------------------------------------- 1 | { 2 | "date": "2022-08-08T08:09:55.116Z", 3 | "name": "pixel-bypass", 4 | "tags": ["dog", "cat"] 5 | } -------------------------------------------------------------------------------- /cli/__fixtures__/badSchema/schema-with-missing-dependency.sql: -------------------------------------------------------------------------------- 1 | DROP EXTENSION IF EXISTS isn; 2 | -- Create table should fail because the extension providing isbn type is missing 3 | CREATE TABLE IF NOT EXISTS movie ( 4 | id isbn, 5 | uid uuid DEFAULT public.uuid_generate_v4() 6 | ); -------------------------------------------------------------------------------- /cli/__fixtures__/badSchema/schema-with-syntax-error.sql: -------------------------------------------------------------------------------- 1 | -- Schema with syntax error making it unparseable 2 | CREATE TABLE public.companies ( 3 | id integer NOT NULL, 4 | "fromEmailsBlacklist" character varying(255)[] 5 | ); 6 | 7 | XXX 8 | 9 | CREATE SEQUENCE public.companies_id_seq 10 | AS integer 11 | START WITH 1 12 | INCREMENT BY 1 13 | NO MINVALUE 14 | NO MAXVALUE 15 | CACHE 1; -------------------------------------------------------------------------------- /cli/__fixtures__/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "fixture-access-token", 3 | "db": { 4 | "host": "localhost" 5 | } 6 | } -------------------------------------------------------------------------------- /cli/__fixtures__/configWithConnectionString.json: -------------------------------------------------------------------------------- 1 | { 2 | "databaseId": "xxx", 3 | "db": { 4 | "connectionString": "postgresql://cs_username:cs_password@cs_hostname:1000/cs_database", 5 | "user": "postgres", 6 | "password": "postgres", 7 | "host": "localhost", 8 | "port": 5432, 9 | "database": "snaplet_development" 10 | } 11 | } -------------------------------------------------------------------------------- /cli/__fixtures__/configWithDbCredentials.json: -------------------------------------------------------------------------------- 1 | { 2 | "databaseId": "xxx", 3 | "db": { 4 | "user": "db_user", 5 | "password": "db_password", 6 | "host": "db_host", 7 | "port": 5432, 8 | "database": "db_database" 9 | } 10 | } -------------------------------------------------------------------------------- /cli/__fixtures__/configWithoutDb.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "fixture-access-token" 3 | } -------------------------------------------------------------------------------- /cli/__fixtures__/indexSize/tables/public-companies.csv.br: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/__fixtures__/indexSize/tables/public-companies.csv.br -------------------------------------------------------------------------------- /cli/__fixtures__/public-example-table.csv: -------------------------------------------------------------------------------- 1 | country,country_id,last_update 2 | South Africa,1,2020-01-01 -------------------------------------------------------------------------------- /cli/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | // eslint-disable-next-line no-var 3 | var fetch: typeof import('undici').fetch 4 | } 5 | 6 | export {} 7 | -------------------------------------------------------------------------------- /cli/e2e/ERD-2_fk_to_same_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-2_fk_to_same_table.png -------------------------------------------------------------------------------- /cli/e2e/ERD-2_fks_to_same_table_with_parent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-2_fks_to_same_table_with_parent.png -------------------------------------------------------------------------------- /cli/e2e/ERD-basic_book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-basic_book.png -------------------------------------------------------------------------------- /cli/e2e/ERD-diff_paths.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-diff_paths.png -------------------------------------------------------------------------------- /cli/e2e/ERD-many_to_many.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-many_to_many.png -------------------------------------------------------------------------------- /cli/e2e/ERD-many_to_many_smae_grandp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-many_to_many_smae_grandp.png -------------------------------------------------------------------------------- /cli/e2e/ERD-same_table_circular_ref.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/e2e/ERD-same_table_circular_ref.png -------------------------------------------------------------------------------- /cli/e2e/__fixtures__/configs/project/invalid/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | malformed, json. 3 | } -------------------------------------------------------------------------------- /cli/e2e/__fixtures__/configs/project/valid/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "targetDatabaseUrl": "pg://localhost:5432/two" 3 | 4 | } -------------------------------------------------------------------------------- /cli/e2e/__fixtures__/configs/project/valid/config.js: -------------------------------------------------------------------------------- 1 | const x = { 2 | targetDatabaseUrl: "pg://localhost:5432/two" 3 | } 4 | 5 | module.exports = x 6 | -------------------------------------------------------------------------------- /cli/e2e/__fixtures__/configs/system/valid/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "asdf" 3 | } -------------------------------------------------------------------------------- /cli/scripts/assertNewRelease.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-env node */ 3 | const axios = require('axios') 4 | const { version: newVersion } = require('../package.json') 5 | 6 | const main = async () => { 7 | console.log(`Checking that Snaplet CLI ${newVersion} exists...`) 8 | if (await versionAlreadyExists()) { 9 | console.log(`... stopping release, version ${newVersion} already exists`) 10 | process.exitCode = 1 11 | } else { 12 | console.log(`... releasing, version ${newVersion} does not yet exist`) 13 | } 14 | } 15 | 16 | const versionAlreadyExists = async () => { 17 | const res = await axios.get( 18 | 'https://api.snaplet.dev/admin/releaseVersion.findByVersion', 19 | { 20 | responseType: 'json', 21 | params: { input: { version: newVersion } }, 22 | headers: { 23 | authorization: `Bearer ${process.env.ADMIN_ACCESS_TOKEN}`, 24 | }, 25 | } 26 | ) 27 | return res?.data?.result?.data?.version === newVersion 28 | } 29 | 30 | if (require.main === module) { 31 | main() 32 | } 33 | -------------------------------------------------------------------------------- /cli/scripts/createReleaseVersion.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-env node */ 4 | const axios = require('axios') 5 | const { version: newVersion } = require('../package.json') 6 | 7 | async function createReleaseVersion() { 8 | console.log(`Creating version ${newVersion}...`) 9 | 10 | const { data } = await axios.post( 11 | 'https://api.snaplet.dev/admin/releaseVersion.create', 12 | { newVersion }, 13 | { 14 | responseType: 'json', 15 | headers: { 16 | authorization: `Bearer ${process.env.ADMIN_ACCESS_TOKEN}`, 17 | }, 18 | } 19 | ) 20 | 21 | console.log(`Version ${newVersion} created`) 22 | console.log(data) 23 | } 24 | 25 | createReleaseVersion().catch((e) => { 26 | // eslint-disable-next-line no-console 27 | console.error(e.message) 28 | process.exit(1) 29 | }) 30 | -------------------------------------------------------------------------------- /cli/scripts/displayCLIVersion.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | console.log(require('../package.json').version) 3 | -------------------------------------------------------------------------------- /cli/scripts/predictions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "axios": "1.5.1", 4 | "minimist": "1.2.8" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /cli/src/bootstrap.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/cli/src/bootstrap.ts -------------------------------------------------------------------------------- /cli/src/commands/catchAll/catchAllCommand.handler.ts: -------------------------------------------------------------------------------- 1 | import c from 'ansi-colors' 2 | import type { Argv } from 'yargs' 3 | import yargs from 'yargs' 4 | 5 | export async function handler(argv: Awaited) { 6 | const deprecated: Record = { 7 | restore: ['snapshot restore', 'ss r'], 8 | list: ['snapshot list', 'ss ls'], 9 | ls: ['snapshot list', 'ss ls'], 10 | login: ['auth login'], 11 | } 12 | const [old] = argv._ 13 | if (deprecated[old]) { 14 | const oldCommand = c.magenta('"snaplet ' + old + '"') 15 | let newCommand = c.magenta('"snaplet ' + deprecated[old][0] + '"') 16 | if (deprecated[old][1]) { 17 | newCommand += c.magenta(` (snaplet ${deprecated[old][1]})`) 18 | } 19 | console.log() 20 | console.log( 21 | c.yellow( 22 | `ERROR: ${oldCommand} is deprecated.\nPlease use ${newCommand} instead.` 23 | ) 24 | ) 25 | console.log() 26 | } else { 27 | yargs.showHelp() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cli/src/commands/catchAll/catchAllCommand.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | 3 | export const catchAllCommand: CommandModule = { 4 | command: '*', 5 | async handler(options) { 6 | const { handler } = await import('./catchAllCommand.handler.js') 7 | await handler(options) 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /cli/src/commands/config/actions/generate/generateAction.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | 3 | import { CommandOptions } from './generateAction.types.js' 4 | 5 | export const generateAction: CommandModule = { 6 | command: 'generate', 7 | describe: 'generate configuration files', 8 | // @ts-expect-error 9 | builder(y) { 10 | return y 11 | .option('type', { 12 | alias: 't', 13 | choices: ['typedefs', 'transform', 'keys'], 14 | default: ['typedefs'], 15 | }) 16 | .option('dry-run', { 17 | type: 'boolean', 18 | default: false, 19 | }) 20 | .option('connection-string', { 21 | type: 'string', 22 | describe: 'The connection string to use for introspecting the database', 23 | }) 24 | }, 25 | async handler(args) { 26 | const { handler } = await import('./generateAction.handler.js') 27 | await handler(args) 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /cli/src/commands/config/actions/generate/generateAction.types.ts: -------------------------------------------------------------------------------- 1 | export type CommandOptions = { 2 | type: ('typedefs' | 'transform' | 'keys')[] 3 | dryRun: boolean 4 | connectionString?: string 5 | } 6 | -------------------------------------------------------------------------------- /cli/src/commands/config/configCommand.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | import yargs from 'yargs' 3 | 4 | import { generateAction } from './actions/generate/generateAction.js' 5 | 6 | export const configCommand: CommandModule = { 7 | command: 'config [action]', 8 | describe: 'manage configuration', 9 | builder(yargs) { 10 | return yargs.command(generateAction).showHelpOnFail(false) 11 | }, 12 | handler() { 13 | yargs.showHelp() 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /cli/src/commands/config/debugConfig.ts: -------------------------------------------------------------------------------- 1 | import { xdebug } from '@snaplet/sdk/cli' 2 | 3 | const xdebugConfig = xdebug.extend('config') // snaplet:config 4 | export const xdebugConfigGenerate = xdebugConfig.extend('generate') // snaplet:config:generate 5 | -------------------------------------------------------------------------------- /cli/src/commands/debug/debugCommand.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from 'yargs' 2 | 3 | export const debugCommand: CommandModule = { 4 | command: 'debug ', 5 | describe: false, 6 | builder(yargs) { 7 | return yargs 8 | .command({ 9 | command: 'throw', 10 | describe: 'throw an unhandled exception', 11 | async handler() { 12 | throw new Error('I am a test exception.') 13 | }, 14 | }) 15 | .showHelpOnFail(true) 16 | }, 17 | handler() {}, 18 | } 19 | -------------------------------------------------------------------------------- /cli/src/commands/discord/discordCommand.handler.ts: -------------------------------------------------------------------------------- 1 | import open from 'open' 2 | 3 | const SNAPLET_DISCORD_CHAT_URL = 'https://app.snaplet.dev/chat' 4 | 5 | export async function handler() { 6 | await open(SNAPLET_DISCORD_CHAT_URL) 7 | console.log( 8 | `Attempting to open browser window to: ${SNAPLET_DISCORD_CHAT_URL}` 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /cli/src/commands/discord/discordCommand.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | 3 | export const discordCommand: CommandModule = { 4 | command: 'discord', 5 | aliases: ['chat'], 6 | describe: 'opens the Snaplet Discord chat window in your browser', 7 | async handler() { 8 | const { handler } = await import('./discordCommand.handler.js') 9 | await handler() 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /cli/src/commands/docs/docsCommand.handler.ts: -------------------------------------------------------------------------------- 1 | import { openSnapletDevelopmentDocumentation } from '~/lib/weblinks.js' 2 | 3 | export async function handler() { 4 | await openSnapletDevelopmentDocumentation() 5 | } 6 | -------------------------------------------------------------------------------- /cli/src/commands/docs/docsCommand.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | 3 | export const docsCommand: CommandModule = { 4 | command: 'documentation', 5 | aliases: ['docs'], 6 | describe: 'opens the Snaplet Documentation in your browser', 7 | async handler() { 8 | const { handler } = await import('./docsCommand.handler.js') 9 | await handler() 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /cli/src/commands/setup/setupCommand.handler.ts: -------------------------------------------------------------------------------- 1 | import { needs } from "~/components/needs/index.js" 2 | import { inputTargetDatabaseCloudUrl } from "../lib/inputTargetDatabaseUrl.js" 3 | 4 | import c from 'ansi-colors' 5 | 6 | export const handler = async () => { 7 | await inputTargetDatabaseCloudUrl() 8 | 9 | const paths = await needs.projectPathsV2() 10 | console.log(`Generated config: ${c.bold(paths.config)}`) 11 | } -------------------------------------------------------------------------------- /cli/src/commands/setup/setupCommand.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from 'yargs' 2 | 3 | export const setupCommand: CommandModule = { 4 | command: 'setup', 5 | describe: 'Initialize or connect an existing Snaplet project', 6 | async handler() { 7 | const { handler } = await import('./setupCommand.handler.js') 8 | await handler() 9 | }, 10 | } -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/captureAction.types.ts: -------------------------------------------------------------------------------- 1 | import type { TransformModes } from '@snaplet/sdk/cli' 2 | 3 | export interface CommandOptions { 4 | destinationPath?: string 5 | message?: string 6 | subsetPath?: string 7 | tags: string[] 8 | transformMode?: TransformModes 9 | uniqueName?: string 10 | } 11 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/lib/debugCapture.ts: -------------------------------------------------------------------------------- 1 | import { xdebug } from '@snaplet/sdk/cli' 2 | 3 | export const xdebugCapture = xdebug.extend('capture') // snaplet:capture 4 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/lib/paths.ts: -------------------------------------------------------------------------------- 1 | import { getSnapshotFilePaths } from '@snaplet/sdk/cli' 2 | import fs from 'fs-extra' 3 | 4 | export const getSnapshotPaths = async (destinationPath: string) => { 5 | if ( 6 | fs.pathExistsSync(destinationPath) && 7 | !fs.existsSync(destinationPath + '/subset.sqlite') 8 | ) { 9 | throw new Error( 10 | 'The specified path already exists, either delete it or specify a new location.' 11 | ) 12 | } 13 | const paths = getSnapshotFilePaths(destinationPath) 14 | await fs.mkdirs(paths.tables) 15 | return paths 16 | } 17 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/lib/subsetV3/lib/addAndIntersect.test.ts: -------------------------------------------------------------------------------- 1 | import { addAndIntersect } from './addAndIntersect.js' 2 | 3 | test('should return the diff with the new elements', () => { 4 | const set1 = new Set([1, 2, 3, 4]) 5 | const set2 = new Set([1, 2, 3, 4, 5, 6]) 6 | const newSet = addAndIntersect(set1, set2) 7 | expect(set1.size).toBe(6) 8 | expect(newSet.size).toBe(2) 9 | expect(newSet.has(5)).toBe(true) 10 | expect(newSet.has(6)).toBe(true) 11 | }) 12 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/lib/subsetV3/lib/addAndIntersect.ts: -------------------------------------------------------------------------------- 1 | // Take two sets, try to add the second to the first, and return a new set of the added values who wasn't already present 2 | export function addAndIntersect(mutated: Set, toAdd: Set) { 3 | const added = new Set() 4 | // TODO: (avallete) find a more efficient way to do this 5 | for (const elem of toAdd.values()) { 6 | if (mutated.size < mutated.add(elem).size) { 7 | added.add(elem) 8 | } 9 | } 10 | return added 11 | } 12 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/lib/subsetV3/lib/debug.ts: -------------------------------------------------------------------------------- 1 | import { xdebug } from '@snaplet/sdk/cli' 2 | 3 | export const debugSubset = xdebug.extend('capture:subset') 4 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/capture/lib/subsetV3/steps/pgCopyTable.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'csv-parse/sync' 2 | import { stringify } from 'csv-stringify/sync' 3 | import { csvParseOptions, csvStringifyOptions } from './pgCopyTable.js' 4 | 5 | describe('pgCopyTable', () => { 6 | test('csv-parse and csv-serialize options are working as expected against PostgreSQL csv', () => { 7 | // arrange 8 | const csv = `id,content 9 | 1,John 10 | 2, 11 | 3,"" 12 | ` 13 | // act 14 | const parsed = parse(csv, csvParseOptions) 15 | const serialized = stringify(parsed, csvStringifyOptions) 16 | // assert 17 | expect(parsed).toEqual([ 18 | { 19 | content: 'John', 20 | id: '1', 21 | }, 22 | { 23 | content: null, 24 | id: '2', 25 | }, 26 | { 27 | content: '', 28 | id: '3', 29 | }, 30 | ]) 31 | expect(serialized).toEqual(csv) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/list/listAction.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from 'yargs' 2 | 3 | import { CommandOptions } from './listAction.types.js' 4 | 5 | export const listAction: CommandModule = { 6 | command: 'list', 7 | aliases: ['ls'], 8 | describe: 'list all snapshots', 9 | //@ts-expect-error 10 | builder: (y) => { 11 | return y 12 | .option('tags', { 13 | type: 'array', 14 | coerce: (values: string[]) => { 15 | return values.flatMap((v) => v.split(',')) 16 | }, 17 | default: [], 18 | }) 19 | .option('latest', { 20 | type: 'boolean', 21 | default: false, 22 | describe: 'show the most recent snapshot', 23 | }) 24 | .option('name-only', { 25 | type: 'boolean', 26 | default: false, 27 | describe: 'show only the snapshot names', 28 | }) 29 | }, 30 | async handler(options) { 31 | const { handler } = await import('./listAction.handler.js') 32 | await handler(options) 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/list/listAction.types.ts: -------------------------------------------------------------------------------- 1 | export interface CommandOptions { 2 | tags: string[] 3 | latest: boolean 4 | nameOnly: boolean 5 | } 6 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/lib/SnapshotCache.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloudSnapshot, 3 | generateSnapshotBasePath, 4 | getSnapshotFilePaths, 5 | } from '@snaplet/sdk/cli' 6 | import terminalLink from 'terminal-link' 7 | 8 | export default class SnapshotCache { 9 | cachePath?: string 10 | summary: CloudSnapshot 11 | 12 | constructor(summary: CloudSnapshot) { 13 | this.summary = summary 14 | } 15 | 16 | get paths() { 17 | if (!this.cachePath) { 18 | if (this.summary.cachePath) { 19 | this.cachePath = this.summary.cachePath 20 | } else { 21 | const { name, date } = this.summary.summary 22 | this.cachePath = generateSnapshotBasePath({ name, date }) 23 | } 24 | } 25 | 26 | return getSnapshotFilePaths(this.cachePath) 27 | } 28 | 29 | get restoreLogTerminalLink() { 30 | return terminalLink('restore.log', 'file://' + this.paths.restoreLog) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/lib/pgSchemaTools.ts: -------------------------------------------------------------------------------- 1 | export function isComment(line: string) { 2 | return line.trim().startsWith('--') 3 | } 4 | 5 | export function splitSchema(schema: string) { 6 | const lines = schema.split('\n').map((line) => line.trim()) 7 | const statements: string[][] = [] 8 | let groupIndex = 0 9 | for (const line of lines) { 10 | if (line && !isComment(line) && line !== 'CREATE SCHEMA public;') { 11 | statements[groupIndex] = [...(statements[groupIndex] ?? []), line] 12 | } 13 | if (line.startsWith('-- Name:')) { 14 | groupIndex += 1 15 | } 16 | } 17 | return statements 18 | .filter((group) => group !== null) 19 | .map((group) => group.join('\n')) 20 | } 21 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/restoreAction.types.ts: -------------------------------------------------------------------------------- 1 | export interface CommandOptions { 2 | latest: boolean 3 | tags: string[] 4 | snapshotName?: string 5 | data: boolean 6 | schema: boolean 7 | reset: boolean 8 | yes: boolean 9 | tables: string[] 10 | excludeTables: string[] 11 | progress: boolean 12 | isResetExplicitlySet: boolean 13 | } 14 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/createConstraints.ts: -------------------------------------------------------------------------------- 1 | import { activity } from '~/lib/spinner.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | export async function createConstraints(importer: SnapshotImporter) { 6 | const errors: string[] = [] 7 | 8 | const act = activity('Constraints', 'Creating...') 9 | 10 | importer.on('createConstraints:error', (payload) => { 11 | errors.push( 12 | `[Create constraints] Warning: ${payload.error.message} (${payload.schema}.${payload.table})` 13 | ) 14 | }) 15 | 16 | await importer.createConstraints() 17 | 18 | if (errors.length) { 19 | act.fail('Created with errors (See restore.log)') 20 | } else { 21 | act.pass('Created') 22 | } 23 | 24 | return errors 25 | } 26 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/displayInfo.ts: -------------------------------------------------------------------------------- 1 | import { CloudSnapshot, formatTime } from '@snaplet/sdk/cli' 2 | import wordwrap from 'word-wrap' 3 | 4 | import { fmt } from '~/lib/format.js' 5 | 6 | import c from 'ansi-colors' 7 | 8 | export const displaySnapshotSummary = (summary: CloudSnapshot) => { 9 | console.log() 10 | if (summary.summary.isSupabaseConnection) { 11 | console.log(' ', c.green('⚡ Restoring from a Supabase database')) 12 | } 13 | console.log() 14 | console.log( 15 | ' ', 16 | fmt(` 17 | **Name:** ${summary?.summary?.name} 18 | **Created:** ${formatTime(summary?.summary?.date)} 19 | **Size:** ${summary?.summary?.totalSize} 20 | **Host:** ${summary?.summary?.origin} 21 | **Tables:** 22 | `) 23 | ) 24 | console.log( 25 | wordwrap( 26 | summary?.summary?.tables 27 | ?.map(({ schema, table }) => `${schema}.${table}`) 28 | .sort() 29 | .join(', ') ?? '', 30 | { indent: ' ' } 31 | ) 32 | ) 33 | console.log() 34 | } 35 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/dropConstraints.ts: -------------------------------------------------------------------------------- 1 | import { activity } from '~/lib/spinner.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | export const dropConstraints = async (importer: SnapshotImporter) => { 6 | const act = activity('Drop constraints', 'Dropping...') 7 | const errors: string[] = [] 8 | 9 | importer.on('dropConstraints:error', (payload) => { 10 | errors.push( 11 | `[Drop constraints] Warning: ${payload.error.message} (${payload.schema}.${payload.table})` 12 | ) 13 | }) 14 | 15 | await importer.dropConstraints() 16 | 17 | if (errors.length) { 18 | act.fail('Dropped with errors (See restore.log)') 19 | } else { 20 | act.pass('Dropped') 21 | } 22 | 23 | return errors 24 | } 25 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/filterTables.ts: -------------------------------------------------------------------------------- 1 | import { activity } from '~/lib/spinner.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | export const filterTables = async ( 6 | importer: SnapshotImporter, 7 | options: { 8 | tables: string[] 9 | excludeTables: string[] 10 | } 11 | ) => { 12 | const act = activity('Filter tables', 'Filter...') 13 | const errors: string[] = [] 14 | 15 | importer.on('filterTables:error', (payload) => { 16 | errors.push( 17 | `[Filter Tables] Warning: ${payload.error.message} (${payload.schema}.${payload.table})` 18 | ) 19 | }) 20 | 21 | await importer.filterTables(options.tables, options.excludeTables) 22 | 23 | if (errors.length) { 24 | act.fail('Truncated with errors (See restore.log)') 25 | } else { 26 | act.pass('Truncated') 27 | } 28 | 29 | return errors 30 | } 31 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/fixSequences.ts: -------------------------------------------------------------------------------- 1 | import { activity } from '~/lib/spinner.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | export const fixSequences = async (importer: SnapshotImporter) => { 6 | const errors: string[] = [] 7 | 8 | const act = activity('Database sequences', 'Resetting...') 9 | 10 | importer 11 | .on('fixSequences:complete', () => { 12 | if (errors.length) { 13 | act.fail('Reset with warnings (See restore.log)') 14 | } else { 15 | act.pass('Reset') 16 | } 17 | }) 18 | .on('fixSequences:error', (payload) => { 19 | errors.push(`[Sequences] Warning: ${payload.error.message}`) 20 | }) 21 | 22 | await importer.fixSequences() 23 | 24 | return errors 25 | } 26 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/fixViews.ts: -------------------------------------------------------------------------------- 1 | import { getSentry } from '~/lib/sentry.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | // This step is needed because dropping constraints with cascade can result in views being dropped as well. 6 | export const fixViews = async (importer: SnapshotImporter) => { 7 | const errors: string[] = [] 8 | 9 | try { 10 | await importer.fixViews() 11 | } catch (e) { 12 | if (e instanceof Error) { 13 | errors.push(e.message) 14 | } 15 | const Sentry = await getSentry() 16 | Sentry.captureException(e) 17 | } 18 | 19 | return errors 20 | } 21 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/resetDatabase.ts: -------------------------------------------------------------------------------- 1 | import { resetDb } from '@snaplet/sdk/cli' 2 | 3 | import { spinner } from '~/lib/spinner.js' 4 | 5 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 6 | 7 | export const resetDatabase = async (importer: SnapshotImporter) => { 8 | const act = spinner('Database: Dropping schemas...').start() 9 | const errors = await resetDb(importer.connString) 10 | 11 | if (!errors.length) { 12 | act.succeed('Database: Schemas dropped') 13 | } else { 14 | act.warn('Database: Not all schemas dropped') 15 | } 16 | 17 | return errors 18 | } 19 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/truncateTables.ts: -------------------------------------------------------------------------------- 1 | import { activity } from '~/lib/spinner.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | export const truncateTables = async (importer: SnapshotImporter) => { 6 | const act = activity('Truncate tables', 'Truncating...') 7 | const errors: string[] = [] 8 | 9 | importer.on('truncateTables:error', (payload) => { 10 | errors.push( 11 | `[Data] Warning: ${payload.error.message} (${payload.schema}.${payload.table})` 12 | ) 13 | }) 14 | 15 | await importer.truncateTables() 16 | 17 | if (errors.length) { 18 | act.fail('Truncated with errors (See restore.log)') 19 | } else { 20 | act.pass('Truncated') 21 | } 22 | 23 | return errors 24 | } 25 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/restore/steps/vacuumTables.ts: -------------------------------------------------------------------------------- 1 | import { activity } from '~/lib/spinner.js' 2 | 3 | import SnapshotImporter from '../lib/SnapshotImporter/SnapshotImporter.js' 4 | 5 | export async function vacuumTables(importer: SnapshotImporter) { 6 | const errors: string[] = [] 7 | 8 | const act = activity('Vacuum', 'Working...') 9 | 10 | importer 11 | .on('vacuumTables:complete', () => { 12 | if (errors.length) { 13 | act.fail('Complete with warnings (See restore.log)') 14 | } else { 15 | act.pass('Done') 16 | } 17 | }) 18 | .on('vacuumTables:error', (payload) => { 19 | errors.push(`[Vacuum] Warning: ${payload.error.message}`) 20 | }) 21 | 22 | await importer.vacuumTables() 23 | 24 | return errors 25 | } 26 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/update/updateAction.handler.ts: -------------------------------------------------------------------------------- 1 | import { getHosts } from '~/lib/hosts/hosts.js' 2 | 3 | import { CommandOptions } from './updateAction.types.js' 4 | import { findSnapshotSummary } from '~/components/findSnapshotSummary.js' 5 | 6 | export async function handler({ snapshotName }: CommandOptions) { 7 | const hosts = await getHosts({ only: ["abspath", "local"]}) 8 | 9 | const snapshot = await findSnapshotSummary( 10 | { 11 | startsWith: snapshotName, 12 | }, 13 | hosts 14 | ) 15 | if (!snapshot.summary || !snapshot.summary.snapshotId) { 16 | throw new Error('Summary not found for snapshot. This should not happen.') 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/update/updateAction.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from 'yargs' 2 | 3 | import { CommandOptions } from './updateAction.types.js' 4 | 5 | export const updateAction: CommandModule = { 6 | command: 'update ', 7 | aliases: [], 8 | describe: 'update metadata of a snapshot', 9 | //@ts-expect-error 10 | builder: (y) => { 11 | return y 12 | .positional('snapshot-name', { 13 | describe: 'the unique name of the snapshot', 14 | type: 'string', 15 | }) 16 | }, 17 | async handler(options) { 18 | const { handler } = await import('./updateAction.handler.js') 19 | await handler(options) 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/actions/update/updateAction.types.ts: -------------------------------------------------------------------------------- 1 | export interface CommandOptions { 2 | snapshotName: string 3 | } 4 | -------------------------------------------------------------------------------- /cli/src/commands/snapshot/snapshotCommand.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | import yargs from 'yargs' 3 | 4 | import { captureAction } from './actions/capture/captureAction.js' 5 | import { listAction } from './actions/list/listAction.js' 6 | import { restoreAction } from './actions/restore/restoreAction.js' 7 | import { updateAction } from './actions/update/updateAction.js' 8 | 9 | export const snapshotCommand: CommandModule = { 10 | command: 'snapshot [action]', 11 | aliases: ['ss'], 12 | describe: 'manage snapshots', 13 | builder(yargs) { 14 | return yargs 15 | .command(captureAction) 16 | .command(listAction) 17 | .command(restoreAction) 18 | .command(updateAction) 19 | .showHelpOnFail(true) 20 | }, 21 | handler() { 22 | yargs.showHelp() 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /cli/src/commands/upgrade/upgradeCommand.handler.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | import semver from 'semver' 3 | 4 | import { RUNTIME_INSTALL_DETAILS, CLI_VERSION } from '~/lib/constants.js' 5 | import { refreshState, makeInstallCommand } from '~/lib/upgrade.js' 6 | 7 | export async function handler() { 8 | const state = await refreshState() 9 | const needUpdate = semver.gt(state.latestVersion, CLI_VERSION) 10 | 11 | if (!needUpdate) { 12 | return console.log('No available update') 13 | } 14 | 15 | if (RUNTIME_INSTALL_DETAILS.type === 'bash') { 16 | execa.sync('curl -sL https://app.snaplet.dev/get-cli/ | bash', { 17 | stdio: 'inherit', 18 | shell: true, 19 | }) 20 | } else { 21 | const command = makeInstallCommand(state.latestVersion) 22 | console.log(`Please upgrade using your package manager: ${command}`) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cli/src/commands/upgrade/upgradeCommand.ts: -------------------------------------------------------------------------------- 1 | import type { CommandModule } from 'yargs' 2 | 3 | export const upgradeCommand: CommandModule = { 4 | command: 'upgrade', 5 | describe: 'upgrade this binary', 6 | async handler() { 7 | const { handler } = await import('./upgradeCommand.handler.js') 8 | await handler() 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /cli/src/components/README.md: -------------------------------------------------------------------------------- 1 | # Steps 2 | 3 | Components display output or take input and are re-used in the CLI. 4 | 5 | We have 3 types: 6 | 1. Needs: Validate requirements 7 | 2. Activities: Display side-effect state 8 | 3. Inputs: User level interaction 9 | 10 | ## Needs 11 | 12 | "Needs" are a way to validate and gather requirements for an action, 13 | when the requirement is not met they provides a helpful and consistently formatted 14 | error message designed for the user. 15 | 16 | A need only tests a single requirement, and the requirements should be composed. 17 | As an example: If you need a connection to a database, you also need a valid 18 | connection string, so you'll have to specify both of those as "needs." 19 | 20 | Some needs will also return the requirement that they're testing, so you can use 21 | validating the requirement as a way to get things. 22 | 23 | ```js 24 | import { needs } from '~/lib/needs.js' 25 | 26 | // Example: I need a connection to the database 27 | const connString = needs.databaseURL() 28 | await needs.databaseConnection(connString) 29 | 30 | // Example: I need to save project configuration 31 | const paths = needs.projectPaths() 32 | ``` 33 | 34 | ## Activities 35 | 36 | Activities can take an indeterminate amount of time. They generally have 3 states: 37 | Loading, Success and Failure. 38 | -------------------------------------------------------------------------------- /cli/src/components/needs/brotliCommand.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | 3 | import { exitWithError } from '~/lib/exit.js' 4 | 5 | import { logError } from './logError.js' 6 | 7 | export const brotliCliCommand = async () => { 8 | try { 9 | execa.sync('brotli', ['--version']) 10 | } catch (error) { 11 | logError([ 12 | 'The **brotli** binary could not be located and is required. Please install **brotli** on your system.', 13 | ]) 14 | await exitWithError('CLI_BIN_REQUIRE_BROTLI') 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cli/src/components/needs/index.ts: -------------------------------------------------------------------------------- 1 | import { brotliCliCommand } from './brotliCommand.js' 2 | import { sourceDatabaseUrl, targetDatabaseUrl } from './connectionUrl.js' 3 | import { databaseConnection } from './databaseConnection.js' 4 | import { pgDumpCliCommand } from './pgDumpCliCommand.js' 5 | import { privateKey } from './privateKey.js' 6 | import { publicKey } from './publicKey.js' 7 | import { projectPathsV2 } from './projectPaths.js' 8 | import { snapshot } from './snapshot.js' 9 | import { validConnectionString } from './validConnectionString.js' 10 | 11 | export const needs = { 12 | brotliCliCommand, 13 | sourceDatabaseUrl, 14 | targetDatabaseUrl, 15 | databaseConnection, 16 | pgDumpCliCommand, 17 | privateKey, 18 | publicKey, 19 | snapshot, 20 | validConnectionString, 21 | projectPathsV2, 22 | } 23 | -------------------------------------------------------------------------------- /cli/src/components/needs/logError.ts: -------------------------------------------------------------------------------- 1 | import c from 'ansi-colors' 2 | import wordwrap from 'word-wrap' 3 | import { display } from '~/lib/display.js' 4 | 5 | export const logError = (message: string[], example?: string) => { 6 | const m = wordwrap(`${c.red('✖')} ${message.join('\n')}`, { width: 80 }) 7 | const e = example 8 | ? wordwrap(`${c.blue('i')} ${example}`, { width: 80 }) 9 | : undefined 10 | 11 | display(m) 12 | 13 | if (e) { 14 | display(e) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cli/src/components/needs/pgDumpCliCommand.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | 3 | import { exitWithError } from '~/lib/exit.js' 4 | 5 | import { logError } from './logError.js' 6 | 7 | export const pgDumpCliCommand = async () => { 8 | try { 9 | await execa('pg_dump', ['--version']) 10 | } catch (error) { 11 | logError([ 12 | 'The **pg_dump** binary could not be located and is required. Please install **pg_dump** on your system.', 13 | ]) 14 | await exitWithError('CLI_BIN_REQUIRE_PGDUMP') 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cli/src/components/needs/privateKey.ts: -------------------------------------------------------------------------------- 1 | import { readPrivateKey } from '@snaplet/sdk/cli' 2 | 3 | import { exitWithError } from '~/lib/exit.js' 4 | 5 | import { logError } from './logError.js' 6 | 7 | export const privateKey = async () => { 8 | const privateKey = await readPrivateKey() 9 | if (!privateKey) { 10 | logError([ 11 | 'A private key is required:', 12 | 'Run **snaplet setup** or create one in **.snaplet/id_rsa**', 13 | ]) 14 | return await exitWithError('CONFIG_PK_NOT_FOUND') 15 | } 16 | return privateKey 17 | } 18 | -------------------------------------------------------------------------------- /cli/src/components/needs/projectPaths.ts: -------------------------------------------------------------------------------- 1 | import { findProjectPath, getPathsV2 } from '@snaplet/sdk/cli' 2 | import { exitWithError } from '~/lib/exit.js' 3 | 4 | import { logError } from './logError.js' 5 | import { ensureDir } from 'fs-extra' 6 | import path from 'path' 7 | 8 | export const projectPathsV2 = async (options?: { create?: boolean }) => { 9 | if (options?.create) { 10 | const projectPath = findProjectPath() 11 | if (!projectPath) { 12 | const snapletPath = process.env.SNAPLET_CWD 13 | ? path.join(process.env.SNAPLET_CWD, '.snaplet') 14 | : '.snaplet' 15 | await ensureDir(snapletPath) 16 | } 17 | } 18 | const paths = getPathsV2() 19 | if (!paths.project) { 20 | logError( 21 | [ 22 | 'A **.snaplet** project directory is required:', 23 | 'Run **snaplet project setup** in your git repo or use the **SNAPLET_CWD** environment variable.', 24 | ], 25 | 'SNAPLET_CWD=~/path/to/gh/code snaplet snapshot restore' 26 | ) 27 | return await exitWithError('CONFIG_NOT_FOUND') 28 | } else { 29 | return paths.project 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cli/src/components/needs/publicKey.ts: -------------------------------------------------------------------------------- 1 | import { exitWithError } from '~/lib/exit.js' 2 | 3 | import { logError } from './logError.js' 4 | import { config } from '~/lib/config.js' 5 | 6 | export const publicKey = async () => { 7 | const publicKey = (await config.getProject()).publicKey 8 | 9 | if (!publicKey) { 10 | logError([ 11 | 'A private key is required:', 12 | 'Run **snaplet config generate -t keys** to create one', 13 | ]) 14 | return await exitWithError('CONFIG_PUBLIC_KEY_NOT_FOUND') 15 | } 16 | 17 | return publicKey 18 | } 19 | -------------------------------------------------------------------------------- /cli/src/components/needs/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { findSnapshotSummary } from '~/components/findSnapshotSummary.js' 2 | import { exitWithError } from '~/lib/exit.js' 3 | import { getHosts, HostType } from '~/lib/hosts/hosts.js' 4 | import { logError } from './logError.js' 5 | 6 | export const snapshot = async (props: { 7 | hosts: HostType[] 8 | tags: string[] 9 | latest: boolean 10 | }) => { 11 | const hosts = await getHosts({ only: props.hosts }) 12 | 13 | const snapshot = await findSnapshotSummary( 14 | { 15 | latest: props.latest, 16 | tags: props.tags, 17 | }, 18 | hosts 19 | ) 20 | 21 | if (!snapshot) { 22 | logError([ 23 | `Could not find ${ 24 | props.latest && 'latest' 25 | }} snapshot with tags "${props.tags.join(', ')}" in ${props.hosts.join( 26 | ', ' 27 | )}`, 28 | ]) 29 | return exitWithError('SNAPSHOT_NONE_AVAILABLE') 30 | } 31 | 32 | return snapshot 33 | } 34 | -------------------------------------------------------------------------------- /cli/src/components/needs/validConnectionString.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConnectionString, 3 | CONNECTION_STRING_PROTOCOLS, 4 | encodeConnectionString, 5 | } from '@snaplet/sdk/cli' 6 | 7 | import { exitWithError } from '~/lib/exit.js' 8 | 9 | import { logError } from './logError.js' 10 | 11 | export const validConnectionString = async (cs: string) => { 12 | let connString = new ConnectionString(cs) 13 | if (connString.validationErrors === null) { 14 | return connString 15 | } 16 | 17 | // attempt to auto-encode the connection string 18 | if (connString.validationErrors !== 'INVALID') { 19 | connString = new ConnectionString( 20 | encodeConnectionString(connString).toString() 21 | ) 22 | 23 | if (connString.validationErrors === null) { 24 | return connString 25 | } 26 | } 27 | 28 | let reason 29 | if (connString.validationErrors === 'UNRECOGNIZED_PROTOCOL') { 30 | reason = `The protocol is not recognized. Use one of ${CONNECTION_STRING_PROTOCOLS.join( 31 | ', ' 32 | )}` 33 | } else { 34 | reason = `Unable to parse connection string. Learn more: https://docs.snaplet.dev/guides/postgresql#connection-strings` 35 | } 36 | logError([ 37 | `Could not validate connection string: "${connString.toScrubbedString()}"`, 38 | reason, 39 | ]) 40 | return await exitWithError('CONNECTION_URL_INVALID') 41 | } 42 | -------------------------------------------------------------------------------- /cli/src/components/readConfig.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IntrospectedStructure, 3 | getSnapshotFilePaths, 4 | introspectedStructureSchema, 5 | } from '@snaplet/sdk/cli' 6 | import fs from 'fs-extra' 7 | 8 | export async function writeSnapshotStructure( 9 | paths: ReturnType, 10 | structure: IntrospectedStructure 11 | ) { 12 | await fs.writeJSON(paths.structure, structure, { spaces: 2 }) 13 | } 14 | 15 | export async function readSnapshotStructure( 16 | paths: ReturnType 17 | ) { 18 | const structure = await fs.readJSON(paths.structure, { encoding: 'utf-8' }) 19 | return introspectedStructureSchema.parse(structure) 20 | } 21 | 22 | export const readSnapshotConfig = async (filePath: string) => { 23 | try { 24 | if (!(await fs.pathExists(filePath))) { 25 | return undefined 26 | } 27 | const x = await fs.readFile(filePath, { encoding: 'utf-8' }) 28 | return x 29 | } catch (e: any) { 30 | console.log(`Could not read ${filePath}: ${e.message}`) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /cli/src/lib/config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '@snaplet/sdk/cli' 2 | 3 | const config = new Configuration() 4 | 5 | function getConfig() { 6 | return config 7 | } 8 | 9 | export { config, getConfig } 10 | -------------------------------------------------------------------------------- /cli/src/lib/display.ts: -------------------------------------------------------------------------------- 1 | import { format as utilFormat } from 'util' 2 | import { fmt } from './format.js' 3 | 4 | // context(justinvdm, 5 Sep 2023): The idea is to use this wherever we want to display messages to the user in the 5 | // console. We log to stderr so that we can use stdout for piping (e.g. snaplet generate --sql) 6 | export const display = (...args: unknown[]) => 7 | process.stderr.write(['\n', fmt(utilFormat(...args)), '\n', '\n'].join('')) 8 | -------------------------------------------------------------------------------- /cli/src/lib/exit.ts: -------------------------------------------------------------------------------- 1 | import { ERROR_CODES } from '@snaplet/sdk/cli' 2 | 3 | import { teardownAndExit } from './handleTeardown.js' 4 | 5 | export async function exitWithError(code: keyof typeof ERROR_CODES) { 6 | const exitCode = ERROR_CODES[code] || 1 7 | // TODO: Display link to help docs for these error codes. 8 | return await teardownAndExit(exitCode) 9 | } 10 | -------------------------------------------------------------------------------- /cli/src/lib/format.test.ts: -------------------------------------------------------------------------------- 1 | import { fmt } from './format.js' 2 | 3 | test('`fmt` transforms markdown to ansi colors', function () { 4 | expect( 5 | fmt('this *bold* and this *bold* are different') 6 | ).toMatchInlineSnapshot(`"this *bold* and this *bold* are different"`) 7 | 8 | expect(fmt('# heading')).toMatchInlineSnapshot(`"heading"`) 9 | 10 | expect( 11 | fmt('this __italic__ and this __italic__ are different') 12 | ).toMatchInlineSnapshot( 13 | `"this italic and this italic are different"` 14 | ) 15 | }) 16 | -------------------------------------------------------------------------------- /cli/src/lib/format.ts: -------------------------------------------------------------------------------- 1 | import c from 'ansi-colors' 2 | import dedent from 'dedent' 3 | 4 | export function fmt( 5 | ...message: string[] | TemplateStringsArray | [TemplateStringsArray] 6 | ) { 7 | return dedent(message.join(' ').trim()) 8 | .replace(/^# (.*$)/gim, c.bold('$1')) 9 | .replace(/\*\*(.+?)\*\*/gim, c.bold('$1')) 10 | .replace(/\__(.+?)\__/gim, c.italic('$1')) 11 | } 12 | -------------------------------------------------------------------------------- /cli/src/lib/handleFail.ts: -------------------------------------------------------------------------------- 1 | import c from 'ansi-colors' 2 | import type { Argv } from 'yargs' 3 | 4 | import { IS_PRODUCTION } from './constants.js' 5 | import { exitWithError } from './exit.js' 6 | import { fmt } from './format.js' 7 | import { discordLink } from './links.js' 8 | import { getSentry } from './sentry.js' 9 | 10 | export async function handleFail(msg: string, e: Error, _: Argv) { 11 | if (msg) { 12 | console.log(c.red(msg)) 13 | } else { 14 | if (IS_PRODUCTION) { 15 | const Sentry = await getSentry() 16 | Sentry.captureException(e) 17 | } 18 | 19 | if (!IS_PRODUCTION || process.env.DEBUG) { 20 | console.log('*'.repeat(80)) 21 | console.log(e) 22 | console.log('*'.repeat(80)) 23 | } 24 | 25 | console.log( 26 | fmt(` 27 | Unhandled error: ${e?.message} 28 | 29 | We have been notified, but if you need help now please contact us on ${discordLink} 30 | `) 31 | ) 32 | } 33 | return await exitWithError('UNHANDLED_ERROR') 34 | } 35 | -------------------------------------------------------------------------------- /cli/src/lib/handleTeardown.ts: -------------------------------------------------------------------------------- 1 | import { endAllPools } from '@snaplet/sdk/cli' 2 | import { xdebug } from '@snaplet/sdk/cli' 3 | 4 | const teardownDebug = xdebug.extend('teardown') 5 | const MAX_TEARDOWN_TIME = 5000 6 | 7 | // Should always be executed, might the command exit with success, or with an error 8 | async function handleTeardown() { 9 | const teardownActions = [endAllPools()] 10 | // Execute all teardown actions concurrently 11 | await Promise.allSettled(teardownActions) 12 | } 13 | 14 | // Should only be called for non error, 0 code exit 15 | // otherwise, call the exitWithError method instead 16 | export async function teardownAndExit(exitCode: number): Promise { 17 | // A guard to make sure our teardown will never taker longer than 18 | // specified amount of time before exiting the process 19 | const timeout = setTimeout(() => { 20 | teardownDebug('teardown did not finished in time, cleanup might be altered') 21 | process.exit(exitCode) 22 | }, MAX_TEARDOWN_TIME) 23 | await handleTeardown() 24 | clearTimeout(timeout) 25 | process.exit(exitCode) 26 | } 27 | -------------------------------------------------------------------------------- /cli/src/lib/hosts/absPathSnapshotHost.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | import { AbsPathSnapshotHost } from './absPathSnapshotHost.js' 4 | 5 | const storagePath = path.resolve( 6 | __dirname, 7 | '../../../__fixtures__/.snaplet/snapshots' 8 | ) 9 | 10 | describe('find snapshots', () => { 11 | test('gets snapshot by path', async () => { 12 | const l = new AbsPathSnapshotHost() 13 | const s = await l.filterSnapshots({ 14 | startsWith: path.join(storagePath, '1659946195116-pixel-bypass'), 15 | }) 16 | expect(s?.[0]?.summary?.name).toEqual('pixel-bypass') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /cli/src/lib/hosts/absPathSnapshotHost.ts: -------------------------------------------------------------------------------- 1 | import { readSnapshotSummary, CloudSnapshot } from '@snaplet/sdk/cli' 2 | import fs from 'fs-extra' 3 | import path from 'path' 4 | 5 | import { FilterSnapshotRules, Host } from './hosts.js' 6 | 7 | export class AbsPathSnapshotHost implements Host { 8 | public type = 'abspath' as const 9 | 10 | public getLatestSnapshot = async () => { 11 | return undefined 12 | } 13 | 14 | public getAllSnapshots = async () => { 15 | return [] 16 | } 17 | 18 | public filterSnapshots = async (rules: FilterSnapshotRules) => { 19 | // if the string includes a path separator, it's probably a path 20 | if (rules.startsWith?.includes(path.sep)) { 21 | const summaryPath = path.join(rules.startsWith, 'summary.json') 22 | if (fs.existsSync(summaryPath)) { 23 | const s = await this.getSnapshotSummary(summaryPath) 24 | return [s] 25 | } 26 | } 27 | return [] 28 | } 29 | 30 | private getSnapshotSummary = async ( 31 | summaryPath: string 32 | ): Promise => { 33 | const summary = await readSnapshotSummary(summaryPath) 34 | return { 35 | summary, 36 | totalSize: summary?.totalSize, 37 | origin: summary.origin!, 38 | // TODO: This cannot always be SUCCESS! 39 | status: 'SUCCESS', 40 | cachePath: path.dirname(summaryPath), 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /cli/src/lib/initConfigOrExit.ts: -------------------------------------------------------------------------------- 1 | import { exitWithError } from './exit.js' 2 | import { config } from './config.js' 3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 4 | type Parameters = T extends (...args: infer T) => any ? T : never 5 | 6 | 7 | export async function initConfigOrExit( 8 | ...args: Parameters 9 | ): Promise<{ parsed: any; config: typeof config }> { 10 | const init = await config.init(...args) 11 | if (init.ok === true) { 12 | return { parsed: init.value, config } 13 | } 14 | // Those are handled expected errors if the user config is invalid or have syntax errors in it 15 | // in that case we show where the error is and exit with a non-zero code 16 | if ( 17 | init.error._tag === 'SnapletExecuteConfigError' || 18 | init.error._tag === 'SnapletCompileConfigError' 19 | ) { 20 | console.log(init.error.message) 21 | console.log(init.error.stack) 22 | return await exitWithError(init.error.code) 23 | } 24 | if (init.error._tag === 'SnapletParseConfigError') { 25 | console.log(init.error.message) 26 | return await exitWithError(init.error.code) 27 | } 28 | // Otherwise that's an unexpected error and we bubble it up 29 | throw init.error 30 | } 31 | -------------------------------------------------------------------------------- /cli/src/lib/links.ts: -------------------------------------------------------------------------------- 1 | import terminalLink from 'terminal-link' 2 | 3 | import { fmt } from './format.js' 4 | 5 | export const discordLink = terminalLink( 6 | fmt`**Discord**`, 7 | 'https://discord.com/invite/aNSMaWtjKx' 8 | ) 9 | -------------------------------------------------------------------------------- /cli/src/lib/prettyBytes.ts: -------------------------------------------------------------------------------- 1 | import format from 'pretty-bytes' 2 | 3 | export const prettyBytes = (bytes: any) => { 4 | bytes = Number(bytes) 5 | 6 | if (isNaN(bytes)) { 7 | return '0 B' 8 | } else { 9 | return format(bytes, {}) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /cli/src/lib/sentry.ts: -------------------------------------------------------------------------------- 1 | import { memoize, kebabCase } from 'lodash' 2 | 3 | import { CLI_VERSION } from './constants.js' 4 | 5 | const MAX_CLOSING_TIMEOUT = 5000 6 | 7 | async function _getSentry(): Promise { 8 | const Sentry = await import('@sentry/node') 9 | const { CaptureConsole, Dedupe } = await import('@sentry/integrations') 10 | Sentry.init({ 11 | dsn: 'https://2fba2a090e8543889606914e0230ad1c@o503558.ingest.sentry.io/5588827', 12 | tracesSampleRate: 1.0, 13 | environment: process.env.STAGE 14 | ? kebabCase(process.env.STAGE).slice(0, 63) 15 | : 'production', 16 | integrations: [ 17 | new Dedupe(), 18 | new CaptureConsole({ 19 | levels: ['error', 'assert'], 20 | }), 21 | ], 22 | release: CLI_VERSION, 23 | }) 24 | Sentry.setTag('side', 'cli') 25 | Sentry.setTag('cli-safe-mode', process.env.SNAPLET_SAFE_MODE) 26 | 27 | return Sentry 28 | } 29 | 30 | export const getSentry = memoize(_getSentry) 31 | 32 | export const closeSentry = async () => { 33 | const sentry = await getSentry() 34 | // We leave 5s for sentry to send all his errors in his queue 35 | await sentry.close(MAX_CLOSING_TIMEOUT) 36 | } 37 | -------------------------------------------------------------------------------- /cli/src/lib/server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http' 2 | import { memoize } from 'lodash' 3 | 4 | let server: Server 5 | 6 | async function _getHttpServer() { 7 | const { createServer } = await import('http') 8 | if (!server) { 9 | server = createServer((req, res) => { 10 | // Set CORS headers 11 | res.setHeader('Access-Control-Allow-Origin', '*') // Allow any origin 12 | res.setHeader('Access-Control-Allow-Headers', '*') 13 | res.setHeader( 14 | 'Access-Control-Allow-Methods', 15 | 'OPTIONS, GET, POST, PUT, DELETE' 16 | ) 17 | 18 | // Handle preflight requests 19 | if (req.method === 'OPTIONS') { 20 | res.writeHead(204) 21 | res.end() 22 | return 23 | } 24 | }) 25 | } 26 | return server 27 | } 28 | export const getHttpServer = memoize(_getHttpServer) 29 | 30 | export const closeHttpServer = async () => { 31 | const server = await getHttpServer() 32 | await server.close() 33 | } 34 | -------------------------------------------------------------------------------- /cli/src/lib/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => 2 | new Promise((resolve) => setTimeout(resolve, ms)) 3 | -------------------------------------------------------------------------------- /cli/src/lib/weblinks.ts: -------------------------------------------------------------------------------- 1 | import open from 'open' 2 | 3 | const SNAPLET_DOCUMENTATION_URL = 'https://docs.snaplet.dev/?utm_source=cli' 4 | 5 | const openUrl = async (url: string, quiet = false) => { 6 | if (!quiet) { 7 | console.log(`Attempting to open browser window to: ${url}`) 8 | } 9 | await open(SNAPLET_DOCUMENTATION_URL) 10 | } 11 | 12 | export const openSnapletDevelopmentDocumentation = async () => { 13 | await openUrl(SNAPLET_DOCUMENTATION_URL) 14 | } 15 | -------------------------------------------------------------------------------- /cli/src/middlewares/checkForUpdatesMiddleware.ts: -------------------------------------------------------------------------------- 1 | import c from 'ansi-colors' 2 | import semver from 'semver' 3 | 4 | import { config } from '~/lib/config.js' 5 | import { CLI_VERSION } from '~/lib/constants.js' 6 | import { getState, refreshState, makeInstallCommand } from '~/lib/upgrade.js' 7 | 8 | const ONE_DAY = 24 * 60 * 60 * 1000 9 | 10 | async function checkForUpdates() { 11 | let state = await getState() 12 | if ( 13 | !state || 14 | Date.now() - new Date(state.lastCheckedAt).getTime() >= ONE_DAY 15 | ) { 16 | state = await refreshState({ timeout: 1000 }) 17 | } 18 | if (semver.gt(state.latestVersion, CLI_VERSION)) { 19 | const command = makeInstallCommand(state.latestVersion) 20 | return c.yellow( 21 | `\nUpdate available ${CLI_VERSION} -> ${ 22 | state!.latestVersion 23 | }, run "${command}" to upgrade.` 24 | ) 25 | } 26 | } 27 | 28 | export const checkForUpdatesMiddleware = async () => { 29 | const systemConfig = await config.getSystem() 30 | if (!process.env.CI && !systemConfig.silenceUpgradeNotifications) { 31 | return checkForUpdates() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /cli/src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './checkForUpdatesMiddleware.js' 2 | export * from './removePgEnvarMiddleware.js' 3 | export * from './runAllMigrationsMiddleware.js' -------------------------------------------------------------------------------- /cli/src/middlewares/removePgEnvarMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionString } from '@snaplet/sdk/cli' 2 | import { MiddlewareFunction } from 'yargs' 3 | 4 | export const removePgEnvarMiddleware: MiddlewareFunction = () => { 5 | const { PGUSER, PGPASSWORD, PGHOST, PGDATABASE, PGPORT } = process.env 6 | 7 | process.env.PGENV_CONNECTION_URL = ConnectionString.fromObject({ 8 | username: PGUSER, 9 | password: PGPASSWORD, 10 | hostname: PGHOST, 11 | port: PGPORT !== '' && PGPORT != null ? +PGPORT : null, 12 | database: PGDATABASE, 13 | }).toString() 14 | 15 | // We do not want these defined because node-postgres uses them, 16 | // and we want the user to be explicit about the credentials. 17 | delete process.env.PGUSER 18 | delete process.env.PGPASSWORD 19 | delete process.env.PGHOST 20 | delete process.env.PGDATABASE 21 | delete process.env.PGPORT 22 | } 23 | -------------------------------------------------------------------------------- /cli/src/middlewares/runAllMigrationsMiddleware.test.ts: -------------------------------------------------------------------------------- 1 | import { computeApplicableMigrations } from './runAllMigrationsMiddleware.js' 2 | 3 | describe('computeApplicableMigrations', () => { 4 | test('returns migrations in ascending semver order', () => { 5 | const migrations = { 6 | '1.0.0'() {}, 7 | '0.2.0'() {}, 8 | } 9 | 10 | const results = computeApplicableMigrations(migrations, '0.1.0') 11 | 12 | expect(results).toEqual([migrations['0.2.0'], migrations['1.0.0']]) 13 | }) 14 | 15 | test('only returns the migrations needed to bring the cli up to date', () => { 16 | const migrations = { 17 | '0.1.0'() {}, 18 | '1.0.0'() {}, 19 | } 20 | 21 | const results = computeApplicableMigrations(migrations, '0.2.0') 22 | 23 | expect(results).toEqual([migrations['1.0.0']]) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /cli/src/testing/checkConstraints.ts: -------------------------------------------------------------------------------- 1 | import { execQueryNext } from '@snaplet/sdk/cli' 2 | 3 | export async function checkConstraints( 4 | sourceConnectionString: string, 5 | targetConnectionString: string, 6 | expectedMissingContraints: string[] = [] 7 | ) { 8 | const sourceConstraintsResult = await execQueryNext( 9 | `SELECT con.conname 10 | FROM pg_catalog.pg_constraint con 11 | INNER JOIN pg_catalog.pg_class rel 12 | ON rel.oid = con.conrelid 13 | INNER JOIN pg_catalog.pg_namespace nsp 14 | ON nsp.oid = connamespace 15 | WHERE nsp.nspname = 'public';`, 16 | sourceConnectionString 17 | ) 18 | 19 | const targetConstraintsResult = await execQueryNext( 20 | `SELECT con.conname 21 | FROM pg_catalog.pg_constraint con 22 | INNER JOIN pg_catalog.pg_class rel 23 | ON rel.oid = con.conrelid 24 | INNER JOIN pg_catalog.pg_namespace nsp 25 | ON nsp.oid = connamespace 26 | WHERE nsp.nspname = 'public';`, 27 | targetConnectionString 28 | ) 29 | 30 | const sourceConstraints = sourceConstraintsResult.rows.map((c) => c.conname) 31 | const targetConstraints = targetConstraintsResult.rows.map((c) => c.conname) 32 | const missingTargetConstraints = sourceConstraints.filter( 33 | (c) => !targetConstraints.includes(c) 34 | ) 35 | expect(missingTargetConstraints).toEqual(expectedMissingContraints) 36 | } 37 | -------------------------------------------------------------------------------- /cli/src/testing/createTestCapturePath.ts: -------------------------------------------------------------------------------- 1 | import { removeSync } from 'fs-extra' 2 | import { dirSync } from 'tmp-promise' 3 | 4 | import { afterDebug } from './debug.js' 5 | 6 | interface State { 7 | capturePaths: Array<{ 8 | keep: boolean 9 | name: string 10 | }> 11 | } 12 | 13 | const defineCreateTestCapturePath = (state: State) => { 14 | const createTestCapturePath = ( 15 | keep = false 16 | ): State['capturePaths'][number] => { 17 | const x = dirSync() 18 | removeSync(x.name) 19 | state.capturePaths.push({ keep, name: x.name }) 20 | return x 21 | } 22 | 23 | createTestCapturePath.afterEach = () => { 24 | const capturePaths = state.capturePaths 25 | state.capturePaths = [] 26 | afterDebug( 27 | `createTestCapturePath afterEach cleanup: ${capturePaths.length}` 28 | ) 29 | 30 | for (const capturePath of capturePaths) { 31 | if (capturePath.keep === false) { 32 | removeSync(capturePath.name) 33 | } 34 | } 35 | } 36 | 37 | return createTestCapturePath 38 | } 39 | 40 | export const createTestCapturePath = defineCreateTestCapturePath({ 41 | capturePaths: [], 42 | }) 43 | 44 | afterEach(createTestCapturePath.afterEach) 45 | -------------------------------------------------------------------------------- /cli/src/testing/createTestDb.test.ts: -------------------------------------------------------------------------------- 1 | import { dbExistsNext } from '@snaplet/sdk/cli' 2 | 3 | import { defineCreateTestDb } from './createTestDb.js' 4 | 5 | describe('createTestDb', () => { 6 | test('creates a test db', async () => { 7 | const state = { dbNames: [] } 8 | const createTestDb = defineCreateTestDb(state) 9 | const connString = await createTestDb() 10 | expect(await dbExistsNext(connString)).toBe(true) 11 | await createTestDb.afterEach() 12 | }) 13 | 14 | test('drops db after each test run', async () => { 15 | const state = { dbNames: [] } 16 | const createTestDb = defineCreateTestDb(state) 17 | 18 | const connString = await createTestDb() 19 | await createTestDb.afterEach() 20 | 21 | expect(await dbExistsNext(connString)).toBe(false) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /cli/src/testing/debug.ts: -------------------------------------------------------------------------------- 1 | import { xdebugRaw } from '@snaplet/sdk/cli' 2 | 3 | const testDebug = xdebugRaw.extend('testing') 4 | // Useful to log into afterEach/after/afterAll cleanup 5 | const afterDebug = testDebug.extend('afterDebug') 6 | 7 | export { testDebug, afterDebug } 8 | -------------------------------------------------------------------------------- /cli/src/testing/index.ts: -------------------------------------------------------------------------------- 1 | export { createTestCapturePath } from './createTestCapturePath.js' 2 | export { createTestDb, populateTestDb } from './createTestDb.js' 3 | export { createTestRole } from './createTestRole.js' 4 | export { createTestProjectDir as createTestProjectDirV2 } from './createTestProjectDirV2.js' 5 | export { runSnapletCLI } from './runSnapletCli.js' 6 | export { getTestAccessToken } from './getTestAccessToken.js' 7 | export { checkConstraints } from './checkConstraints.js' 8 | 9 | export const VIDEOLET_PROJECT_ID = 'ckq46skzy358532hraos3ptc7p' 10 | -------------------------------------------------------------------------------- /cli/src/testing/setup.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv-defaults' 2 | import path from 'path' 3 | 4 | const CLI_BASE_DIR = path.resolve(__dirname, '../../../../') 5 | 6 | dotenv.config({ 7 | defaults: path.resolve(CLI_BASE_DIR, '../.env.defaults'), 8 | }) 9 | -------------------------------------------------------------------------------- /cli/src/vendor/boxen.d.ts: -------------------------------------------------------------------------------- 1 | import boxen from 'boxen' 2 | 3 | export * from 'boxen' 4 | 5 | export default boxen 6 | -------------------------------------------------------------------------------- /cli/src/vendor/boxen.js: -------------------------------------------------------------------------------- 1 | import boxen from 'boxen' 2 | export * from 'boxen' 3 | export default boxen 4 | -------------------------------------------------------------------------------- /cli/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | 6 | "outDir": "dist", 7 | "paths": { 8 | "~/*": ["./src/*"], 9 | }, 10 | "emitDeclarationOnly": false, 11 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 12 | "plugins": [ 13 | { "transform": "typescript-transform-paths" }, 14 | { "transform": "typescript-transform-paths", "afterDeclarations": true }, 15 | ], 16 | }, 17 | "include": ["src", "./ambient.d.ts"], 18 | "exclude": ["src/**/*.test.ts", "src/testing"], 19 | "references": [ 20 | { "path": "../packages/sdk/tsconfig.build.json" }, 21 | ] 22 | } -------------------------------------------------------------------------------- /cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": ".dts", 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": ["./src/*"] 8 | }, 9 | "customConditions": ["snaplet_development"], 10 | "types": ["vitest/globals"] 11 | }, 12 | "include": ["*.json", "*.mts", "*.ts", "src", "scripts", "e2e", "../packages/proxy/src/proxy"], 13 | "references": [ 14 | { "path": "../packages/sdk" }, 15 | ] 16 | } -------------------------------------------------------------------------------- /cli/vite.config.mts: -------------------------------------------------------------------------------- 1 | import tsconfigPaths from 'vite-tsconfig-paths' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | tsconfigPaths({ 7 | ignoreConfigErrors: true, 8 | }), 9 | ], 10 | resolve: { 11 | conditions: ['snaplet_development'], 12 | }, 13 | test: { 14 | reporters: ['basic'], 15 | globals: true, 16 | globalSetup: ['./src/testing/globalSetup.ts'], 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ### Prerequisites 2 | 3 | Install `brew`, `git`, `pnpm` and `Node.js` : 4 | 5 | #### Brew 6 | 7 | ```bash 8 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 9 | ``` 10 | 11 | #### Git 12 | 13 | ```bash 14 | brew install git 15 | ``` 16 | 17 | #### Pnpm and Node.js 18 | 19 | ```bash 20 | curl -fsSL https://get.pnpm.io/install.sh | sh - 21 | pnpm env use --global lts 22 | corepack enable 23 | ``` 24 | 25 | ### Installation 26 | 27 | ```bash 28 | git clone git@github.com:snaplet/docs.git 29 | cd docs 30 | pnpm install 31 | ``` 32 | 33 | ### Run the project 34 | 35 | ```bash 36 | pnpm next 37 | # Go to http://localhost:3000 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import NextImage, { type ImageProps } from "next/image"; 2 | import Zoom from "./Zoom"; 3 | 4 | export function Image(props: { src: string; alt: string; zoom?: boolean }) { 5 | const ImageComponent = props.zoom ? Zoom : NextImage; 6 | 7 | return ( 8 |
9 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /docs/components/ReleaseNotes.tsx: -------------------------------------------------------------------------------- 1 | import { useData } from "nextra/data" 2 | import dynamic from "next/dynamic" 3 | 4 | function ReleaseNote(props: { filename: string }) { 5 | const Content = dynamic(() => import(`../releases/${props.filename}`)) 6 | 7 | return ( 8 | 9 | ) 10 | } 11 | 12 | export function ReleaseNotes() { 13 | const { releases } = useData() as { releases: Array<{ filename: string }>} 14 | 15 | return ( 16 | <> 17 | { releases.map((release, index) => ) } 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /docs/components/Step.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Zoom } from "./Zoom"; 3 | 4 | export function Step(props: { 5 | children: React.ReactNode; 6 | image?: { src: string; alt: string }; 7 | }) { 8 | if (!props.image) { 9 | return props.children; 10 | } 11 | 12 | return ( 13 |
14 |
{props.children}
15 |
16 | 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /docs/components/recipes/SlackAppERD.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from "react"; 2 | import { useTheme } from "nextra-theme-docs"; 3 | 4 | export const SlackAppERD = () => { 5 | const { resolvedTheme } = useTheme(); 6 | const [iframeSrc, setIframeSrc] = useState(''); 7 | 8 | useEffect(() => { 9 | // This code now runs only on the client side 10 | if (resolvedTheme === "dark") { 11 | setIframeSrc("https://dbdiagram.io/e/66460034f84ecd1d225b7d68/66460063f84ecd1d225b82f2"); 12 | } else { 13 | setIframeSrc("https://dbdiagram.io/e/6644dfeff84ecd1d2244fbde/6644e883f84ecd1d2245ac3c"); 14 | } 15 | }, [resolvedTheme]); 16 | 17 | return ( 18 | 24 | ); 25 | }; -------------------------------------------------------------------------------- /docs/lib/getReleases.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import fs from "fs/promises" 3 | 4 | export async function getReleases() { 5 | const releasesDirectory = path.join(process.cwd(), 'releases') 6 | const files = await fs.readdir(releasesDirectory) 7 | 8 | const releases = files.map(async (file) => { 9 | const filePath = path.join(releasesDirectory, file) 10 | const filePathParsed = path.parse(filePath) 11 | return { 12 | filename: filePathParsed.base 13 | } 14 | }) 15 | 16 | return (await Promise.all(releases)).sort(((a, b) => a.filename.localeCompare(b.filename))).reverse() 17 | } -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "docs" 3 | publish = ".next" 4 | command = "pnpm --filter @snaplet/documentation build" 5 | [build.environment] 6 | NODE_VERSION = "20.12.2" 7 | [[plugins]] 8 | package = "@netlify/plugin-nextjs" -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@snaplet/documentation", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "format": "remark . --output", 8 | "dev": "next dev", 9 | "build": "next build", 10 | "start": "next start" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "packageManager": "pnpm@8.9.0", 16 | "dependencies": { 17 | "@code-hike/lighter": "0.9.1", 18 | "@code-hike/mdx": "0.9.0", 19 | "@monaco-editor/react": "4.6.0", 20 | "@snaplet/copycat": "5.0.0", 21 | "monaco-editor": "0.47.0", 22 | "monaco-editor-auto-typings": "0.4.5", 23 | "next": "14.1.4", 24 | "nextra": "2.13.4", 25 | "nextra-theme-docs": "2.13.4", 26 | "react": "18.2.0", 27 | "react-dom": "18.2.0" 28 | }, 29 | "devDependencies": { 30 | "@netlify/plugin-nextjs": "^5.6.0", 31 | "@types/node": "20.12.3", 32 | "@types/react": "18.2.74", 33 | "autoprefixer": "10.4.19", 34 | "clsx": "2.1.0", 35 | "postcss": "8.4.38", 36 | "prettier": "3.2.5", 37 | "remark": "15.0.1", 38 | "remark-cli": "12.0.0", 39 | "remark-mdx": "3.0.1", 40 | "tailwindcss": "3.4.3", 41 | "typescript": "5.4.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import "@code-hike/mdx/dist/index.css"; 2 | import "../styles/global.css"; 3 | 4 | export default function App({ Component, pageProps }) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "seed": { 3 | "display": "hidden", 4 | "type": "page" 5 | }, 6 | "snapshot": { 7 | "display": "hidden", 8 | "type": "page" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/pages/snapshot/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "getting-started": "Getting Started", 3 | "core-concepts": "Core Concepts", 4 | "guides": "Guides", 5 | "recipes": "Recipes", 6 | "reference": "Reference", 7 | "release-notes": "Release Notes", 8 | "security": "Security" 9 | } 10 | -------------------------------------------------------------------------------- /docs/pages/snapshot/core-concepts/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "capture": "Capture", 3 | "restore": "Restore" 4 | } 5 | -------------------------------------------------------------------------------- /docs/pages/snapshot/getting-started/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "overview": "Overview", 3 | "installation": "Installation", 4 | "quick-start": "Quick Start" 5 | } -------------------------------------------------------------------------------- /docs/pages/snapshot/getting-started/installation.mdx: -------------------------------------------------------------------------------- 1 | import { Tabs, Tab } from "nextra/components"; 2 | 3 | # Installation 4 | 5 | Snaplet can be run using `npx` from [npm](https://www.npmjs.com/), or installed and run as a standalone binary: 6 | 7 | 8 | 9 | ```bash >_ terminal 10 | # Set up Snaplet with npx 11 | npx snaplet setup 12 | ``` 13 | 14 | 15 | ```bash >_ terminal 16 | # Set up Snaplet as a static binary 17 | curl -sL https://app.snaplet.dev/get-cli/ | bash 18 | snaplet setup 19 | ``` 20 | 21 | 22 | 23 | From there, Snaplet can either be self-hosted or accessed as a web app via our cloud service, Snaplet cloud. The choice is yours! 24 | 25 | We recommend beginning with [Snaplet cloud](https://snaplet.dev). Sign up for free and start utilizing your data in mere minutes! 26 | 27 | For those looking to delve deeper or self-host Snaplet, check out our [self-hosting guide](/snapshot/guides/self-hosting). -------------------------------------------------------------------------------- /docs/pages/snapshot/guides/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgresql": "PostgreSQL", 3 | "self-hosting": "Self-hosting" 4 | } 5 | -------------------------------------------------------------------------------- /docs/pages/snapshot/guides/self-hosting.mdx: -------------------------------------------------------------------------------- 1 | # Self-hosting 2 | 3 | Snaplet can be self-hosted within your own trusted infrastructure. 4 | 5 | ## Generate your configuration file 6 | 7 | In order to tell Snaplet how to select, transform and subset your data you need to generate a `snaplet.config.ts` file along with its types based on your source database schema. 8 | 9 | ```bash >_ terminal 10 | SNAPLET_SOURCE_DATABASE_URL='postgres://username:password@acme.com:5432/production_database' npx snaplet config generate --type typedefs --type transform 11 | ``` 12 | 13 | ## Capture snapshots 14 | 15 | Once you're happy with your configuration, you can capture a snapshot of your source database. 16 | 17 | ```bash >_ terminal 18 | SNAPLET_SOURCE_DATABASE_URL='postgres://username:password@acme.com:5432/production_database' npx snaplet snapshot capture /tmp/my-snapshot 19 | ``` 20 | 21 | 22 | ## Restore snapshots 23 | 24 | You can restore a snapshot to a target database. 25 | 26 | ```bash >_ terminal 27 | SNAPLET_TARGET_DATABASE_URL='postgres://username:password@localhost:5432/development_database' npx snaplet snapshot restore /tmp/my-snapshot 28 | ``` -------------------------------------------------------------------------------- /docs/pages/snapshot/migrations.mdx: -------------------------------------------------------------------------------- 1 | # Snapshot migration 2 | 3 | ## From `0.71.0` and above 4 | 5 | We fixed a long-standing bug in the snapshot capture and restore process about `NULL` values. 6 | 7 | If you captured a snapshot with a version of Snaplet CLI below `0.71.0`, you can either: 8 | 9 | - Capture a new snapshot, which will save it to the new fixed format (recommended): 10 | ```bash >_ terminal 11 | npx snaplet@latest snapshot capture 12 | ``` 13 | 14 | - Or run the restore command with the last version of Snaplet CLI before the fix: 15 | ```bash >_ terminal 16 | npx snaplet@0.70.1 snapshot restore 17 | ``` -------------------------------------------------------------------------------- /docs/pages/snapshot/recipes/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "aws": "AWS", 3 | "github-action": "GitHub Action", 4 | "neon": "Neon", 5 | "netlify": "Netlify", 6 | "prisma": "Prisma", 7 | "supabase": "Supabase", 8 | "vercel": "Vercel" 9 | } 10 | -------------------------------------------------------------------------------- /docs/pages/snapshot/reference/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "configuration": "Configuration", 3 | "environment-variables": "Environment Variables", 4 | "cli": "Snaplet CLI" 5 | } 6 | -------------------------------------------------------------------------------- /docs/pages/snapshot/reference/environment-variables.mdx: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | 3 | You can use these environment variables to customize the Snaplet CLI behavior. 4 | 5 | | Name | Description | 6 | | :---------------------------- | :---------------------------------------------------------------------------- | 7 | | `SNAPLET_SOURCE_DATABASE_URL` | The connection string used to introspect and capture snapshots | 8 | | `SNAPLET_TARGET_DATABASE_URL` | The connection string used to restore snapshots | 9 | | `SNAPLET_CWD` | The current working directory | 10 | | `SNAPLET_NO_UPDATE_NOTIFIER` | Disable the upgrade notice | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /docs/public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/android-icon-192x192.png -------------------------------------------------------------------------------- /docs/public/android-icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/android-icon-256x256.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/core-concepts/deploy/deploy-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/core-concepts/deploy/deploy-01.png -------------------------------------------------------------------------------- /docs/public/core-concepts/deploy/deploy-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/core-concepts/deploy/deploy-02.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-01-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-01-5.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-01.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-01.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-01.webp -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-02.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-03.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-04.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-05.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-06.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-06.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-07.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-08.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-10.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-11.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-12.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-13.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-14.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-15.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-16.png -------------------------------------------------------------------------------- /docs/public/getting-started/quick-start/quick-start-17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/getting-started/quick-start/quick-start-17.png -------------------------------------------------------------------------------- /docs/public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /docs/public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/og.png -------------------------------------------------------------------------------- /docs/public/recipes/neon/00-snaplet-snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/neon/00-snaplet-snapshot.png -------------------------------------------------------------------------------- /docs/public/recipes/neon/01-snaplet-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/neon/01-snaplet-cli.png -------------------------------------------------------------------------------- /docs/public/recipes/neon/02-neon-conn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/neon/02-neon-conn.png -------------------------------------------------------------------------------- /docs/public/recipes/neon/03-restore-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/neon/03-restore-setup.png -------------------------------------------------------------------------------- /docs/public/recipes/neon/04-restore-snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/neon/04-restore-snapshot.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-access-token1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-access-token1.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-build-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-build-logs.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-db-url-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-db-url-new.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-github-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-github-token.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-personal-access-token1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-personal-access-token1.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-preview-plugin-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-preview-plugin-logo.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-project-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-project-id.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-access-token1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-access-token1.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-connect-database-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-connect-database-success.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-connect-database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-connect-database.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-enable1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-enable1.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-enable2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-enable2.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-problem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-problem.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-review-save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-review-save.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-snapshot-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-snapshot-success.png -------------------------------------------------------------------------------- /docs/public/recipes/netlify/netlify-snaplet-solution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/netlify/netlify-snaplet-solution.png -------------------------------------------------------------------------------- /docs/public/recipes/supabase/20_todos.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/20_todos.webp -------------------------------------------------------------------------------- /docs/public/recipes/supabase/connect_to_supabase.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/connect_to_supabase.webp -------------------------------------------------------------------------------- /docs/public/recipes/supabase/nextjs_todos.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/nextjs_todos.webp -------------------------------------------------------------------------------- /docs/public/recipes/supabase/onboarding_capture.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/onboarding_capture.webp -------------------------------------------------------------------------------- /docs/public/recipes/supabase/onboarding_start.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/onboarding_start.webp -------------------------------------------------------------------------------- /docs/public/recipes/supabase/snaplet-supabase-schema-exclude.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/snaplet-supabase-schema-exclude.png -------------------------------------------------------------------------------- /docs/public/recipes/supabase/supabase_new_project.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/supabase/supabase_new_project.webp -------------------------------------------------------------------------------- /docs/public/recipes/visual-studio-code/vscode-01.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/visual-studio-code/vscode-01.webp -------------------------------------------------------------------------------- /docs/public/recipes/visual-studio-code/vscode-02.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/visual-studio-code/vscode-02.webp -------------------------------------------------------------------------------- /docs/public/recipes/visual-studio-code/vscode-03.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/visual-studio-code/vscode-03.webp -------------------------------------------------------------------------------- /docs/public/recipes/visual-studio-code/vscode-04.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/visual-studio-code/vscode-04.webp -------------------------------------------------------------------------------- /docs/public/recipes/visual-studio-code/vscode-05.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/visual-studio-code/vscode-05.webp -------------------------------------------------------------------------------- /docs/public/recipes/visual-studio-code/vscode-06.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/recipes/visual-studio-code/vscode-06.webp -------------------------------------------------------------------------------- /docs/public/security/Snaplet-Security.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/docs/public/security/Snaplet-Security.png -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Snaplet", 3 | "short_name": "Snaplet", 4 | "icons": [ 5 | { 6 | "src": "/android-icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-icon-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#b5bdf6", 17 | "background_color": "#b5bdf6", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/releases/20240506160000-v0.97.10.md: -------------------------------------------------------------------------------- 1 | ## v0.97.10 - 6 May 2024 2 | 3 | ### Bugfixes 🐛 4 | * For databases with no fields that can be used with Snaplet AI (e.g. if there were no text fields in the db, or if all the fields had non-composite unique constraints), users would be left waiting indefinitely for Snaplet AI to complete. 5 | * Fixes to how we identify unique users in our analytics -------------------------------------------------------------------------------- /docs/releases/20240506163727-v0.97.11.md: -------------------------------------------------------------------------------- 1 | ## v0.97.11 - 6 May 2024 2 | 3 | ### Bugfix 🐛 4 | * Correctly escape the configuration path on Windows when generating assets -------------------------------------------------------------------------------- /docs/releases/20240509073336-v0.97.12.md: -------------------------------------------------------------------------------- 1 | ## v0.97.12 - 9 May 2024 2 | 3 | ### Improvements 🛠️ 4 | * In our `init` and `sync` commands, we now show total progress for all Snaplet AI jobs (we previously were only able to show progress some kinds of Snaplet AI jobs). We can pick up when the jobs have started sooner and let you opt to skip waiting for all jobs rather than only some of them. This way you have a more complete picture of what has happened, and have more control from start to end. -------------------------------------------------------------------------------- /docs/releases/20240513130937-v0.97.13.md: -------------------------------------------------------------------------------- 1 | ## v0.97.13 - 13 May 2024 2 | 3 | ### Improvements 🛠️ 4 | * The `sync` workflow skips the AI task when the data model has no changes -------------------------------------------------------------------------------- /docs/releases/20240514124032-v0.97.15.md: -------------------------------------------------------------------------------- 1 | ## v0.97.15 - 14 May 2024 2 | 3 | ### Bugfixes 🐛 4 | * Fix an aliasing bug for child relationships on prisma projects 5 | -------------------------------------------------------------------------------- /docs/releases/20240516084229-v0.97.16.md: -------------------------------------------------------------------------------- 1 | ## v0.97.16 - 16 May 2024 2 | 3 | ### Bugfixes 🐛 4 | * Bug where global connect and plan connect option weren't properly merged togethers -------------------------------------------------------------------------------- /docs/releases/20240516163618-v0.97.17.md: -------------------------------------------------------------------------------- 1 | ## v0.97.17 - 16 May 2024 2 | 3 | ### New Features 🎉 4 | * If you use a custom configuration path, for example: `prisma/seed/seed.config.ts`, you can save it to your `package.json` to avoid using the `--config` option for each CLI command. You can read more in the [Configuration Reference](https://docs.snaplet.dev/seed/reference/configuration#config-location). -------------------------------------------------------------------------------- /docs/releases/20240521054140-v0.97.18.md: -------------------------------------------------------------------------------- 1 | ## v0.97.18 - 21 May 2024 2 | 3 | ### Bugfixes 🐛 4 | * Added compatibility for Vitess based MySQL databases (Planetscale) 5 | -------------------------------------------------------------------------------- /docs/releases/20240521071914-v0.97.19.md: -------------------------------------------------------------------------------- 1 | ## v0.97.19 - 21 May 2024 2 | 3 | ### Bugfixes 🐛 4 | * Compatibility with `vitest` - `@snaplet/seed` was not import-able in vitest -------------------------------------------------------------------------------- /docs/releases/20240527124203-v0.97.20.md: -------------------------------------------------------------------------------- 1 | ## v0.97.20 - 27 May 2024 2 | 3 | ### Bugfixes 🐛 4 | * Only consider unique indexes when introspecting SQLite databases 5 | -------------------------------------------------------------------------------- /docs/releases/20240730093941-v0.98.0.md: -------------------------------------------------------------------------------- 1 | ## v0.98.0 - 30 Jul 2024 2 | 3 | ### Breaking changes 🚨 4 | There is no more backend when using Snaplet seed. This means that some features will also be affected 5 | 1. To make use of the AI data generated feature you will have to add your own OpenAI or Groq key see [README](https://github.com/snaplet/seed/blob/main/packages/seed/README.md#L55-L84) 6 | 2. For fields with unique constraints (example email) will currently default to `copycat.sentence` since we don't have our custom model to identify the shape anymore. For these cases please define it in manually as in this example in the docs: 7 | ``` 8 | await seed.posts([ 9 | { 10 | title: 'Hello World!', 11 | author: { 12 | email: (ctx) => 13 | copycat.email(ctx.seed, { 14 | domain: 'acme.org', 15 | }), 16 | }, 17 | comments: (x) => x(3), 18 | }, 19 | ]); 20 | ``` 21 | 3. Older CLI clients will stop working on the 31st Aug 2024 since it relies on the backend API. 22 | 23 | ### New Features 🎉 24 | * Snaplet can be used fully offline and is completely open source so bring your suggestion and make contributions. 25 | -------------------------------------------------------------------------------- /docs/remarkrc.js: -------------------------------------------------------------------------------- 1 | import remarkMdx from "remark-mdx"; 2 | 3 | const remarkConfig = { 4 | plugins: [remarkMdx], 5 | }; 6 | 7 | export default remarkConfig; 8 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./app/**/*.{js,ts,jsx,tsx,md,mdx}", 5 | "./pages/**/*.{js,ts,jsx,tsx,md,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,md,mdx}", 7 | 8 | // Or if using `src` directory: 9 | "./src/**/*.{js,ts,jsx,tsx,md,mdx}", 10 | ], 11 | theme: { 12 | extend: {}, 13 | }, 14 | plugins: [], 15 | }; 16 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": false, 7 | "forceConsistentCasingInFileNames": true, 8 | "noEmit": true, 9 | "incremental": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules", "tutorials"] 19 | } 20 | -------------------------------------------------------------------------------- /docs/tutorials/generate/snaplet.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "snaplet"; 2 | import { copycat } from "@snaplet/copycat"; 3 | 4 | export default defineConfig({ 5 | generate: { 6 | plan({ snaplet }) { 7 | return snaplet.Post({ 8 | data: { 9 | title: "There is a lot of snow around here!", 10 | User: { 11 | data: { 12 | email: ({ seed }) => copycat.email(seed, { domain: "acme.org" }), 13 | }, 14 | }, 15 | Comment: { 16 | count: 3, 17 | }, 18 | }, 19 | }); 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@2/schema.json", 3 | "workspaces": { 4 | "packages/sdk": { 5 | "entry": "src/index.ts", 6 | "project": "src/**/*.ts" 7 | }, 8 | "cli": { 9 | "entry": ["src/index.ts", "scripts/**/*.{js,mjs}"], 10 | "project": "src/**/*.ts" 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /packages/cli/.gitignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | -------------------------------------------------------------------------------- /packages/cli/bin/snaplet.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable */ 3 | //export * from '../dist/cli.mjs' 4 | module.exports = require('../dist/cli.cjs') 5 | -------------------------------------------------------------------------------- /packages/cli/src/cli.ts: -------------------------------------------------------------------------------- 1 | export * from '@snaplet/cli' 2 | -------------------------------------------------------------------------------- /packages/cli/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Transform, SubsetConfig } from '@snaplet/sdk/cli' 2 | 3 | export type { Transform as TransformConfig, SubsetConfig } 4 | -------------------------------------------------------------------------------- /packages/cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "emitDeclarationOnly": true, 7 | "declaration": true, 8 | "rootDirs": [".", "../sdk"], 9 | "paths": { 10 | "@snaplet/cli": ["../../cli"], 11 | "@snaplet/sdk": ["../sdk"] 12 | } 13 | }, 14 | "include": ["src"], 15 | } 16 | -------------------------------------------------------------------------------- /packages/cli/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { Options, defineConfig } from 'tsup' 2 | import { replace } from 'esbuild-plugin-replace' 3 | 4 | const base: Options = { 5 | outDir: 'dist', 6 | bundle: true, 7 | platform: 'node', 8 | clean: true, 9 | dts: true, 10 | sourcemap: true, 11 | env: { 12 | NODE_ENV: process.env.NODE_ENV ?? 'development', 13 | }, 14 | esbuildPlugins: [ 15 | // context(justinvdm, 21 June 2023): Ideally we could use esbuild's alias feature, 16 | // but this won't work for commonjs outputs 17 | replace({ 18 | 'pg-protocol/': 'pg-protocol/dist/', 19 | }), 20 | ], 21 | outExtension({ format }) { 22 | return { 23 | js: { 24 | cjs: '.cjs', 25 | esm: '.mjs', 26 | }[format], 27 | } 28 | }, 29 | } 30 | 31 | export default defineConfig([ 32 | { 33 | ...base, 34 | // NOTE(justinvdm, 20 June 2023): The esm output will be broken for any cli and sdk re-exports 35 | // until we have esm outputs for those packages. 36 | format: ['cjs', 'esm'], 37 | entry: ['./src/index.ts'], 38 | }, 39 | { 40 | ...base, 41 | format: ['cjs'], 42 | entry: ['./src/cli.ts'], 43 | }, 44 | ]) 45 | -------------------------------------------------------------------------------- /packages/sdk/README.md: -------------------------------------------------------------------------------- 1 | # `@snaplet/sdk` 2 | 3 | A collection of shared code used across @snaplet packages. 4 | 5 | You might instead be looking for: 6 | * https://www.npmjs.com/package/snaplet 7 | * https://www.npmjs.com/package/@snaplet/seed -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/configs/project/invalid/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | malformed, json. 3 | } -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/configs/project/valid/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "targetDatabaseUrl": "pg://localhost:5432/two" 3 | } -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/configs/project/valid/config.js: -------------------------------------------------------------------------------- 1 | const x = { 2 | projectId: '123', 3 | } 4 | 5 | module.exports = x 6 | -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/configs/system/valid/.snaplet/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "accessToken": "asdf" 3 | } -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/configs/v2/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": ["snaplet.config.ts"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "skipLibCheck": true, 6 | "noImplicitAny": true, 7 | "baseUrl": ".", // This must be specified if "paths" is. 8 | "paths": { 9 | "@snaplet/copycat": ["../../node_modules/@snaplet/copycat"], 10 | "@types/node": ["../../node_modules/@types/node"], 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/paths/project/valid/.snaplet/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/packages/sdk/__fixtures__/paths/project/valid/.snaplet/.keep -------------------------------------------------------------------------------- /packages/sdk/__fixtures__/sqlite/chinook.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supabase-community/snapshot/b735a148e1d4e3037330d706086a25205664abd6/packages/sdk/__fixtures__/sqlite/chinook.db -------------------------------------------------------------------------------- /packages/sdk/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | // eslint-disable-next-line no-var 3 | var fetch: typeof import('undici').fetch 4 | } 5 | 6 | export {} 7 | -------------------------------------------------------------------------------- /packages/sdk/src/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs' 2 | 3 | // context(peterp, 16th May 2023): Yes. I'm using a static SALT value instead of a dynamic one per password. 4 | // That's because the CLI does not send the userId along with the authorization header, 5 | // so I cannot compare the hash associated to the user in the database 6 | // with the cleartext password. 7 | // 8 | // We can resolve this, but I need to invalidate every token, and that's going to require a bit of 9 | // external communcation. 10 | 11 | const SALT = '$2b$10$imwkqhwFtzPmD67TePkJXu' 12 | 13 | export const hashPassword = async (password: string) => { 14 | return await bcrypt.hash(password, SALT) 15 | } 16 | -------------------------------------------------------------------------------- /packages/sdk/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './config.js' 2 | export * from './projectConfig/projectConfig.js' 3 | export * from './snapletConfig/schemasConfig.js' 4 | export * from './snapletConfig/snapletConfig.js' 5 | export * from './snapletConfig/subsetConfig.js' 6 | export * from './snapletConfig/introspectConfig.js' 7 | export * from './snapletConfig/transformConfig.js' 8 | export * from './systemConfig/systemConfig.js' 9 | export * from './snapletConfig/v2/ast/mergeConfigWithOverride.js' 10 | export * from './snapletConfig/v2/calculateIncludedTables.js' 11 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/introspectConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { parseIntrospectConfig } from './introspectConfig.js' 2 | 3 | describe('introspect config parsing', () => { 4 | it('happy case', () => { 5 | const exit = vi 6 | .spyOn(process, 'exit') 7 | // @ts-expect-error 8 | .mockImplementation((code) => code) 9 | 10 | const res = parseIntrospectConfig({ 11 | virtualForeignKeys: [ 12 | { 13 | fkTable: 'Table1', 14 | targetTable: 'Table2', 15 | keys: [{ fkColumn: 'column1', targetColumn: 'column2' }], 16 | }, 17 | // Test with composite virtual key 18 | { 19 | fkTable: 'Table1', 20 | targetTable: 'Table2', 21 | keys: [ 22 | { fkColumn: 'column1', targetColumn: 'column2' }, 23 | { fkColumn: 'column3', targetColumn: 'column4' }, 24 | ], 25 | }, 26 | ], 27 | }) 28 | expect(exit).not.toHaveBeenCalled() 29 | expect(res).toBeDefined() 30 | exit.mockRestore() 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/schemasConfig.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import { z, ZodError } from 'zod' 3 | 4 | import { getPaths } from '../../paths.js' 5 | 6 | export const schemasConfigSchema = z.record( 7 | z.union([z.boolean(), z.object({ extensions: z.record(z.boolean()) })]) 8 | ) 9 | 10 | export type SchemasConfig = z.infer 11 | 12 | export function parseSchemasConfig(config: Record) { 13 | try { 14 | return schemasConfigSchema.parse(config) 15 | } catch (e) { 16 | if (e instanceof ZodError) { 17 | throw new Error(`Could not parse schemas config: ${e.message}`) 18 | } 19 | throw e 20 | } 21 | } 22 | 23 | export function getSchemasConfig(configPath = getPaths().project.schemas) { 24 | let config = {} 25 | if (configPath && fs.existsSync(configPath)) { 26 | config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) 27 | } 28 | return parseSchemasConfig(config) 29 | } 30 | 31 | /** 32 | * We want to determine if we should dump the entire schema, or a subset of it via 33 | * the --schema flag. 34 | * The --schema flag prevents all extensions from being exported, and they have 35 | * to be manually specified. 36 | */ 37 | export const schemaIsModified = (schemasConfig?: object) => { 38 | return Object.keys(schemasConfig ?? {})?.length > 0 39 | } 40 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/transformConfig.ts: -------------------------------------------------------------------------------- 1 | import { Json } from '../../types.js' 2 | import type { Transform } from './v2/getConfig/parseConfig.js' 3 | 4 | type RowTransformFn = (params: { 5 | row: Row 6 | rowIndex: number 7 | }) => Partial | Promise> 8 | 9 | type ColumnTransformFn = (params: { row: Row; value: Json }) => Json 10 | 11 | export type RowTransformObject = Partial< 12 | Record> 13 | > 14 | 15 | export type RowTransform = RowTransformObject | RowTransformFn 16 | 17 | export type ColumnTransform = Json | ColumnTransformFn 18 | 19 | export type { Transform } 20 | 21 | export type TransformConfigContext = { 22 | structure: Record 23 | } 24 | 25 | export type TransformConfigFn = ( 26 | context: TransformConfigContext 27 | ) => Transform | Promise 28 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/v2/generateTypes/escapeKey.ts: -------------------------------------------------------------------------------- 1 | export function escapeKey(key: string): string { 2 | // This regex checks for a valid JavaScript identifier. 3 | // It should start with a letter, underscore or dollar, followed by zero or more letters, underscores, dollars or digits. 4 | const isValidIdentifier = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/.test(key) 5 | 6 | if (isValidIdentifier) { 7 | return key 8 | } else { 9 | return `"${key}"` 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/v2/generateTypes/generateIntrospectTypes.ts: -------------------------------------------------------------------------------- 1 | export function generateIntrospectTypes() { 2 | return ` 3 | //#region introspect 4 | type VirtualForeignKey< 5 | TTFkTable extends SelectedTable, 6 | TTargetTable extends SelectedTable 7 | > = 8 | { 9 | fkTable: TTFkTable['id']; 10 | targetTable: TTargetTable['id']; 11 | keys: NonEmptyArray< 12 | { 13 | // TODO: Find a way to strongly type this to provide autocomplete when writing the config 14 | /** 15 | * The column name present in the fkTable that is a foreign key to the targetTable 16 | */ 17 | fkColumn: string; 18 | /** 19 | * The column name present in the targetTable that is a foreign key to the fkTable 20 | */ 21 | targetColumn: string; 22 | } 23 | > 24 | } 25 | 26 | type IntrospectConfig = { 27 | /** 28 | * Allows you to declare virtual foreign keys that are not present as foreign keys in the database. 29 | * But are still used and enforced by the application. 30 | */ 31 | virtualForeignKeys?: Array>; 32 | } 33 | //#endregion` 34 | } 35 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/v2/getConfig/findConfig.ts: -------------------------------------------------------------------------------- 1 | import { getPaths } from '../../../../v2/paths.js' 2 | 3 | export function findConfig() { 4 | return getPaths()?.project?.snapletConfig 5 | } 6 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/v2/getConfig/getConfig.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | import { findConfig } from './findConfig.js' 5 | import { loadConfig } from './loadConfig.js' 6 | import { parseConfig, SnapletConfig } from './parseConfig.js' 7 | 8 | const DEFAULT_CONFIG: SnapletConfig = {} 9 | 10 | export async function getConfig(configPath = findConfig()) { 11 | if (!configPath) { 12 | return DEFAULT_CONFIG 13 | } 14 | 15 | const rawConfig = { 16 | filepath: configPath, 17 | filename: path.basename(configPath), 18 | source: fs.readFileSync(configPath, 'utf8'), 19 | } 20 | 21 | return await getConfigFromSource(rawConfig) 22 | } 23 | 24 | export async function getConfigFromSource( 25 | rawConfig: { 26 | filepath: string 27 | filename: string 28 | source: string 29 | } | null 30 | ) { 31 | if (!rawConfig) { 32 | return DEFAULT_CONFIG 33 | } 34 | 35 | const loadedConfig = await loadConfig(rawConfig?.filepath, rawConfig.source) 36 | 37 | const parsedConfig = parseConfig(loadedConfig, rawConfig.filepath) 38 | 39 | return parsedConfig 40 | } 41 | -------------------------------------------------------------------------------- /packages/sdk/src/config/snapletConfig/v2/getConfig/getSource.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | 4 | import { findConfig } from './findConfig.js' 5 | 6 | export function getSource(configPath = findConfig()) { 7 | if (!configPath) { 8 | return null 9 | } 10 | const fileExist = fs.existsSync(configPath) 11 | if (!fileExist) { 12 | return null 13 | } 14 | 15 | return { 16 | filepath: configPath, 17 | filename: path.basename(configPath), 18 | source: fs.readFileSync(configPath, 'utf8'), 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/sdk/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SNAPSHOT_STEPS = [ 2 | 'booting', 3 | 'introspection', 4 | 'schemas', 5 | 'subset', 6 | 'data', 7 | 'uploading', 8 | ] as const 9 | -------------------------------------------------------------------------------- /packages/sdk/src/createStructureObject.test.ts: -------------------------------------------------------------------------------- 1 | import { createStructureObject } from './createStructureObject.js' 2 | import { IntrospectedStructure } from './db/introspect/introspectDatabase.js' 3 | 4 | test('generate the structure object', () => { 5 | const structure = { 6 | schemas: ['public'], 7 | tables: [ 8 | { 9 | schema: 'public', 10 | name: 'User', 11 | columns: [ 12 | { 13 | name: 'id', 14 | type: 'integer', 15 | }, 16 | { 17 | name: 'status', 18 | type: 'Status', 19 | }, 20 | ], 21 | }, 22 | ], 23 | enums: [ 24 | { 25 | schema: 'public', 26 | name: 'Status', 27 | values: ['YES', 'NO'], 28 | }, 29 | ], 30 | } as IntrospectedStructure 31 | 32 | const g = createStructureObject(structure) 33 | 34 | expect(g).toMatchObject({ 35 | $schemas: ['public'], 36 | public: { 37 | $tables: ['User'], 38 | User: { 39 | $columns: ['id', 'status'], 40 | id: { 41 | default: undefined, 42 | nullable: undefined, 43 | type: 'integer', 44 | }, 45 | status: { 46 | default: undefined, 47 | nullable: undefined, 48 | type: 'Status', 49 | }, 50 | }, 51 | }, 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /packages/sdk/src/db/connString/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ConnectionString.js' 2 | export * from './node.js' 3 | -------------------------------------------------------------------------------- /packages/sdk/src/db/connString/isSupabaseUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { isSupabaseUrl } from './isSupabaseUrl.js' 2 | 3 | describe('isSupabaseUrl', () => { 4 | test('returns whether url is supabase url or not', () => { 5 | expect( 6 | isSupabaseUrl( 7 | 'postgres://postgres.ckpxcqqstnvzmhyziibg:password@aws-0-us-west-1.pooler.supabase.com:5432/postgres' 8 | ) 9 | ).toBe(true) 10 | 11 | expect( 12 | isSupabaseUrl( 13 | 'postgresql://postgres:password@db.ckpxcqqstnvzmhyziibg.supabase.co:5432/postgres' 14 | ) 15 | ).toBe(true) 16 | 17 | expect(isSupabaseUrl('foo')).toBe(false) 18 | 19 | expect(isSupabaseUrl('postgresql://postgres@localhost:5432/postgres')).toBe( 20 | false 21 | ) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/sdk/src/db/connString/isSupabaseUrl.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionString, ConnectionStringShape } from './ConnectionString.js' 2 | 3 | export const isSupabaseUrl = (url: ConnectionStringShape): boolean => { 4 | try { 5 | const domain = new ConnectionString(url).domain 6 | return ( 7 | domain.includes('supabase.co') || domain.includes('pooler.supabase.com') 8 | ) 9 | } catch { 10 | return false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/sdk/src/db/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connString/index.js' 2 | export * from './client.js' 3 | export * from './structure.js' 4 | export * from './tools.js' 5 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/groupParentsChildrenRelations.ts: -------------------------------------------------------------------------------- 1 | import { groupBy } from 'lodash' 2 | 3 | import type { AsyncFunctionSuccessType } from '~/types' 4 | 5 | import type { fetchDatabaseRelationships } from './queries/fetchDatabaseRelationships' 6 | 7 | type fetchRelationshipsResultsType = AsyncFunctionSuccessType< 8 | typeof fetchDatabaseRelationships 9 | > 10 | 11 | export const groupParentsChildrenRelations = ( 12 | databaseRelationships: fetchRelationshipsResultsType, 13 | tableIds: string[] 14 | ) => { 15 | const tablesRelationships = new Map< 16 | string, 17 | { 18 | parents: fetchRelationshipsResultsType 19 | children: fetchRelationshipsResultsType 20 | } 21 | >() 22 | const children = groupBy(databaseRelationships, (f) => f.targetTable) 23 | const parents = groupBy(databaseRelationships, (f) => f.fkTable) 24 | for (const tableId of tableIds) { 25 | tablesRelationships.set(tableId, { 26 | parents: parents[tableId] || [], 27 | children: children[tableId] || [], 28 | }) 29 | } 30 | return tablesRelationships 31 | } 32 | 33 | export type { fetchRelationshipsResultsType } 34 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchAuthorizedExtensions.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | import { buildSchemaExclusionClause } from './utils.js' 3 | 4 | type FetchAuthorizedExtensionsResult = { 5 | name: string 6 | version: string 7 | schema: string 8 | } 9 | 10 | const FETCH_AUTHORIZED_EXTENSIONS = ` 11 | WITH 12 | accessible_schemas AS ( 13 | SELECT 14 | schema_name 15 | FROM information_schema.schemata 16 | WHERE 17 | ${buildSchemaExclusionClause('schema_name')} AND 18 | pg_catalog.has_schema_privilege(current_user, schema_name, 'USAGE') 19 | ) 20 | SELECT 21 | e.extname AS "name", 22 | e.extversion AS "version", 23 | n.nspname AS "schema" 24 | FROM 25 | pg_catalog.pg_extension e 26 | INNER JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace 27 | INNER JOIN pg_catalog.pg_description c ON c.objoid = e.oid AND c.classoid = 'pg_catalog.pg_extension'::pg_catalog.regclass 28 | INNER JOIN accessible_schemas s ON s.schema_name = n.nspname 29 | WHERE ${buildSchemaExclusionClause('n.nspname')} 30 | ORDER BY schema_name 31 | ` 32 | 33 | export async function fetchAuthorizedExtensions(client: DatabaseClient) { 34 | const schemas = await client.query({ 35 | text: FETCH_AUTHORIZED_EXTENSIONS, 36 | }) 37 | return schemas.rows 38 | } 39 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchAuthorizedSchemas.test.ts: -------------------------------------------------------------------------------- 1 | import { createTestDb, createTestRole } from '../../../testing.js' 2 | import { execQueryNext, withDbClient } from '../../client.js' 3 | import { fetchAuthorizedSchemas } from './fetchAuthorizedSchemas.js' 4 | 5 | test('should fetch only the public schema', async () => { 6 | const connString = await createTestDb() 7 | const schemas = await withDbClient(fetchAuthorizedSchemas, { 8 | connString: connString.toString(), 9 | }) 10 | expect(schemas).toEqual(['public']) 11 | }) 12 | 13 | test('should fetch all schemas where the user can read', async () => { 14 | const structure = ` 15 | CREATE SCHEMA other; 16 | CREATE SCHEMA private; 17 | ` 18 | const connString = await createTestDb(structure) 19 | const testRoleConnString = await createTestRole(connString) 20 | await execQueryNext( 21 | `REVOKE ALL PRIVILEGES ON SCHEMA private FROM "${testRoleConnString.username}"; 22 | GRANT ALL PRIVILEGES ON SCHEMA other TO "${testRoleConnString.username}";`, 23 | connString 24 | ) 25 | const schemas = await withDbClient(fetchAuthorizedSchemas, { 26 | connString: testRoleConnString.toString(), 27 | }) 28 | expect(schemas.length).toBe(2) 29 | expect(schemas).toEqual(expect.arrayContaining(['other', 'public'])) 30 | }) 31 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchAuthorizedSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | import { buildSchemaExclusionClause } from './utils.js' 3 | 4 | type FetchAuthorizedSchemasResult = { 5 | schemaName: string 6 | } 7 | const FETCH_AUTHORIZED_SCHEMAS = ` 8 | SELECT 9 | schema_name as "schemaName" 10 | FROM 11 | information_schema.schemata 12 | WHERE 13 | ${buildSchemaExclusionClause('schema_name')} AND 14 | pg_catalog.has_schema_privilege(current_user, schema_name, 'USAGE') 15 | ORDER BY schema_name 16 | ` 17 | export async function fetchAuthorizedSchemas(client: DatabaseClient) { 18 | const schemas = await client.query({ 19 | text: FETCH_AUTHORIZED_SCHEMAS, 20 | }) 21 | return schemas.rows.map((r) => r.schemaName) 22 | } 23 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchAuthorizedSequences.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | import { buildSchemaExclusionClause } from './utils.js' 3 | 4 | type FetchAuthorizedSequencesResult = { 5 | schema: string 6 | table: string 7 | column: string 8 | sequence: string 9 | } 10 | 11 | const FETCH_AUTHORIZED_SEQUENCES = ` 12 | WITH 13 | accessible_schemas AS ( 14 | SELECT 15 | schema_name 16 | FROM information_schema.schemata 17 | WHERE 18 | ${buildSchemaExclusionClause('schema_name')} AND 19 | pg_catalog.has_schema_privilege(current_user, schema_name, 'USAGE') 20 | ) 21 | SELECT 22 | n.nspname AS schema, 23 | t.relname AS table, 24 | a.attname AS column, 25 | s.relname AS sequence 26 | FROM pg_class s 27 | JOIN pg_depend d ON d.objid = s.oid 28 | JOIN pg_class t ON d.objid = s.oid AND d.refobjid = t.oid 29 | JOIN pg_attribute a ON (d.refobjid, d.refobjsubid) = (a.attrelid, a.attnum) 30 | JOIN pg_namespace n ON n.oid = s.relnamespace 31 | INNER JOIN accessible_schemas acc ON acc.schema_name = n.nspname 32 | WHERE s.relkind = 'S' 33 | ORDER BY n.nspname, t.relname 34 | ` 35 | 36 | export async function fetchAuthorizedSequences(client: DatabaseClient) { 37 | const schemas = await client.query({ 38 | text: FETCH_AUTHORIZED_SEQUENCES, 39 | }) 40 | return schemas.rows 41 | } 42 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchForbiddenSchemas.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | import { buildSchemaExclusionClause } from './utils.js' 3 | 4 | type FetchForbiddenSchemasResult = { 5 | schemaName: string 6 | } 7 | 8 | const FETCH_FORBIDDEN_SCHEMAS = ` 9 | SELECT nspname AS "schemaName" 10 | FROM pg_catalog.pg_namespace 11 | WHERE 12 | ${buildSchemaExclusionClause('nspname')} AND 13 | NOT pg_catalog.has_schema_privilege(current_user, nspname, 'USAGE') 14 | ORDER BY nspname 15 | ` 16 | export async function fetchForbiddenSchemas(client: DatabaseClient) { 17 | const schemas = await client.query({ 18 | text: FETCH_FORBIDDEN_SCHEMAS, 19 | }) 20 | return schemas.rows.map((r) => r.schemaName) 21 | } 22 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchForbiddenTablesIds.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | import { buildSchemaExclusionClause } from './utils.js' 3 | 4 | type FetchFobiddenTablesIds = { 5 | id: string 6 | } 7 | 8 | const FETCH_FORBIDDEN_TABLES_IDS = ` 9 | SELECT 10 | concat(n.nspname, '.', c.relname) AS id 11 | FROM pg_class c 12 | INNER JOIN pg_namespace n ON n.oid = c.relnamespace 13 | WHERE 14 | -- table objects 15 | c.relkind IN ('p', 'r') AND c.relispartition IS FALSE AND 16 | -- Exclude all system tables 17 | ${buildSchemaExclusionClause('n.nspname')} AND 18 | ( 19 | NOT pg_catalog.has_schema_privilege(current_user, n.nspname, 'USAGE') OR 20 | NOT pg_catalog.has_table_privilege(current_user, concat(quote_ident(n.nspname), '.', quote_ident(c.relname)), 'SELECT') 21 | ) 22 | ORDER BY n.nspname, c.relname 23 | ` 24 | 25 | export async function fetchForbiddenTablesIds(client: DatabaseClient) { 26 | const results = await client.query( 27 | FETCH_FORBIDDEN_TABLES_IDS 28 | ) 29 | return results.rows.map((r) => r.id) 30 | } 31 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchIndexes.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | import { buildSchemaExclusionClause } from './utils.js' 3 | 4 | type FetchIndexesResult = { 5 | schema: string 6 | table: string 7 | index: string 8 | definition: string 9 | type: string 10 | indexColumns: string[] 11 | } 12 | 13 | const FETCH_INDEXES = ` 14 | SELECT n.nspname AS schema, 15 | tab.relname AS table, 16 | cls.relname AS index, 17 | pg_get_indexdef(idx.indexrelid) AS definition, 18 | am.amname AS type, 19 | json_agg(attname ORDER BY attname) AS "indexColumns" 20 | FROM pg_index idx 21 | JOIN pg_class cls ON cls.oid=idx.indexrelid 22 | JOIN pg_class tab ON tab.oid=idx.indrelid 23 | JOIN pg_am am ON am.oid=cls.relam 24 | JOIN pg_catalog.pg_namespace n ON n.oid = cls.relnamespace 25 | JOIN pg_attribute ON attrelid = idx.indrelid 26 | WHERE ${buildSchemaExclusionClause('n.nspname')} 27 | AND attnum = ANY(idx.indkey) 28 | GROUP BY n.nspname, tab.relname, cls.relname, idx.indexrelid, am.amname 29 | ORDER BY n.nspname 30 | ` 31 | export async function fetchIndexes(client: DatabaseClient) { 32 | const schemas = await client.query({ 33 | text: FETCH_INDEXES, 34 | }) 35 | return schemas.rows 36 | } 37 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchServerVersion.test.ts: -------------------------------------------------------------------------------- 1 | import { createTestDb } from '../../../testing.js' 2 | import { withDbClient } from '../../client.js' 3 | import { fetchServerVersion } from './fetchServerVersion.js' 4 | 5 | test('should retrieve server version', async () => { 6 | const connString = await createTestDb() 7 | const serverVersion = await withDbClient(fetchServerVersion, { 8 | connString: connString.toString(), 9 | }) 10 | expect(serverVersion).toMatch(/\d+\.\d+/) 11 | }) 12 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/fetchServerVersion.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseClient } from '../../../db/client.js' 2 | 3 | type FetchServerVersionResult = { 4 | server_version: string 5 | } 6 | 7 | const FETCH_SERVER_VERSION = `SHOW server_version` 8 | 9 | export async function fetchServerVersion(client: DatabaseClient) { 10 | const schemas = await client.query({ 11 | text: FETCH_SERVER_VERSION, 12 | }) 13 | return schemas.rows[0].server_version 14 | } 15 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/queries/utils.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'pg' 2 | 3 | const EXCLUDED_SCHEMAS = ['information_schema', 'pg\\_%'] 4 | 5 | const escapeLiteral = Client.prototype.escapeLiteral 6 | const escapeIdentifier = Client.prototype.escapeIdentifier 7 | 8 | function buildSchemaExclusionClause(escapedColumn: string) { 9 | return EXCLUDED_SCHEMAS.map( 10 | (s) => `${escapedColumn} NOT LIKE ${escapeLiteral(s)}` 11 | ).join(' AND ') 12 | } 13 | 14 | export { buildSchemaExclusionClause, escapeIdentifier, escapeLiteral } 15 | -------------------------------------------------------------------------------- /packages/sdk/src/db/introspect/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | introspectedStructureSchema, 3 | IntrospectedStructure, 4 | } from './introspectDatabase.js' 5 | 6 | export type ProjectIntrospectedStructure = { 7 | version: '20231002' 8 | data: IntrospectedStructure 9 | } 10 | 11 | export function isIntrospectedStructure( 12 | structure: any 13 | ): structure is IntrospectedStructure { 14 | try { 15 | introspectedStructureSchema.parse(structure) 16 | return true 17 | } catch (e) { 18 | return false 19 | } 20 | } 21 | 22 | export function getIntrospectedStructure( 23 | dbStructure: ProjectIntrospectedStructure | null 24 | ) { 25 | if (dbStructure && dbStructure.version === '20231002') { 26 | return dbStructure.data 27 | } 28 | return null 29 | } 30 | -------------------------------------------------------------------------------- /packages/sdk/src/db/sqlite/queries/fetchServerVersion.test.ts: -------------------------------------------------------------------------------- 1 | import { createSqliteTestDatabase } from '~/testing/createSqliteDb.js' 2 | import { withDbClient } from '../client.js' 3 | import { fetchServerVersion } from './fetchServerVersion.js' 4 | 5 | test('should retrieve server version', async () => { 6 | const structure = ` 7 | CREATE TABLE "Courses" ( 8 | "CourseID" VARCHAR(255) PRIMARY KEY, 9 | "CourseName" VARCHAR(255) NOT NULL 10 | ) WITHOUT ROWID; 11 | ` 12 | const connString = await createSqliteTestDatabase(structure) 13 | const serverVersion = await withDbClient(fetchServerVersion, { 14 | connString: connString.toString(), 15 | }) 16 | expect(serverVersion).toMatch(/\d+\.\d+/) 17 | }) 18 | -------------------------------------------------------------------------------- /packages/sdk/src/db/sqlite/queries/fetchServerVersion.ts: -------------------------------------------------------------------------------- 1 | import { queryNext, type SqliteClient } from '../client.js' 2 | 3 | type FetchServerVersionResult = { 4 | version: string 5 | } 6 | 7 | const FETCH_VERSION = ` 8 | SELECT 9 | sqlite_version() as version 10 | ` 11 | 12 | export async function fetchServerVersion(client: SqliteClient) { 13 | const results = await queryNext(FETCH_VERSION, { 14 | client, 15 | }) 16 | return results[0].version 17 | } 18 | -------------------------------------------------------------------------------- /packages/sdk/src/exports/seed.ts: -------------------------------------------------------------------------------- 1 | export { Configuration } from '../config/config.js' 2 | export { isError } from '../errors.js' 3 | -------------------------------------------------------------------------------- /packages/sdk/src/exports/web.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | BaseSnapletConfigV2, 3 | ColumnTransformFunction, 4 | ColumnTransformScalar, 5 | TableTransformFunction, 6 | TableTransformScalar, 7 | TransformConfigOptions, 8 | TransformConfig, 9 | } from '../config/snapletConfig/v2/getConfig/parseConfig.js' 10 | export type { PiiImpactLevel } from '../pii/piiImpact.js' 11 | export type { Shape, ShapeContext } from '../shapes.js' 12 | export type { FillRowsResult } from '../transform/fillRows/index.js' 13 | export type { Json } from '../types.js' 14 | -------------------------------------------------------------------------------- /packages/sdk/src/formatDatabaseName.ts: -------------------------------------------------------------------------------- 1 | import { kebabCase } from 'lodash' 2 | 3 | export function formatDatabaseName(databaseName: string) { 4 | // postgresql maximum identifier length is 63 characters 5 | return kebabCase(databaseName.trim()).slice(0, 63) 6 | } 7 | -------------------------------------------------------------------------------- /packages/sdk/src/formatTime.ts: -------------------------------------------------------------------------------- 1 | import * as timeago from 'timeago.js' 2 | 3 | import { getSystemConfig } from './config/index.js' 4 | 5 | export function formatTime( 6 | timeValue: timeago.TDate, 7 | desiredFormat = getSystemConfig()?.timeFormat 8 | ) { 9 | return desiredFormat === 'PRECISE' ? timeValue : timeago.format(timeValue) 10 | } 11 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/clearDb.ts: -------------------------------------------------------------------------------- 1 | import { execQueryNext } from '../db/client.js' 2 | import { ConnectionString } from '../db/connString/ConnectionString.js' 3 | import { SelectedTable } from './getSelectedTables.js' 4 | 5 | interface ClearDbOptions { 6 | connectionString: ConnectionString 7 | selectedTables: SelectedTable[] 8 | } 9 | 10 | export const clearDb = async ({ 11 | selectedTables, 12 | connectionString, 13 | }: ClearDbOptions) => { 14 | for (const table of selectedTables) { 15 | await execQueryNext( 16 | `TRUNCATE TABLE "${table.schema}"."${table.name}" CASCADE`, 17 | connectionString 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/detectMigrationTool.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { detectMigrationTool } from './detectMigrationTool.js' 3 | 4 | test('detect migration tool in non-monorepo project', async () => { 5 | const tool = await detectMigrationTool( 6 | path.join(__dirname, 'fixtures', 'non-monorepo-project') 7 | ) 8 | 9 | expect(tool).toMatchObject({ 10 | envVariable: 'DATABASE_URL', 11 | path: expect.stringContaining( 12 | 'packages/sdk/src/generate/fixtures/non-monorepo-project' 13 | ), 14 | provider: 'prisma', 15 | }) 16 | }) 17 | 18 | test('detect migration tool in monorepo project', async () => { 19 | const tool = await detectMigrationTool( 20 | path.join(__dirname, 'fixtures', 'monorepo-project') 21 | ) 22 | 23 | expect(tool).toMatchObject({ 24 | envVariable: 'DB_URL', 25 | path: expect.stringContaining( 26 | 'packages/sdk/src/generate/fixtures/monorepo-project' 27 | ), 28 | provider: 'drizzle', 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/filterSelectedTables.ts: -------------------------------------------------------------------------------- 1 | import { type IntrospectedStructure } from '../db/introspect/introspectDatabase.js' 2 | import { type SelectedTable } from './getSelectedTables.js' 3 | 4 | interface FilterSelectedTablesOptions { 5 | introspection: IntrospectedStructure 6 | selectedTables: SelectedTable[] 7 | } 8 | 9 | const filterSelectedTablesFromRelatives = ( 10 | table: IntrospectedStructure['tables'][number], 11 | selectedTableIds: Set 12 | ) => ({ 13 | ...table, 14 | parents: table.parents.filter((parent) => 15 | selectedTableIds.has(parent.targetTable) 16 | ), 17 | children: table.children.filter((child) => 18 | selectedTableIds.has(child.fkTable) 19 | ), 20 | }) 21 | 22 | export const filterSelectedTables = ({ 23 | introspection, 24 | selectedTables, 25 | }: FilterSelectedTablesOptions): IntrospectedStructure => { 26 | const selectedTableIds = new Set(selectedTables.map((table) => table.id)) 27 | 28 | return { 29 | ...introspection, 30 | indexes: introspection.indexes.filter((index) => 31 | selectedTableIds.has(`"${index.schema}"."${index.table}"`) 32 | ), 33 | tables: introspection.tables 34 | .filter((table) => selectedTableIds.has(table.id)) 35 | .map((table) => 36 | filterSelectedTablesFromRelatives(table, selectedTableIds) 37 | ), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/fixtures/monorepo-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspaces": [ 3 | "packages/*" 4 | ] 5 | } -------------------------------------------------------------------------------- /packages/sdk/src/generate/fixtures/monorepo-project/packages/a/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "a" 3 | } -------------------------------------------------------------------------------- /packages/sdk/src/generate/fixtures/monorepo-project/packages/b/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error - this is a fixture 2 | import type { Config } from 'drizzle-kit' 3 | 4 | export default { 5 | schema: './schema.ts', 6 | driver: 'pg', 7 | dbCredentials: { 8 | connectionString: process.env.DB_URL, 9 | }, 10 | } satisfies Config 11 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/fixtures/monorepo-project/packages/b/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "b", 3 | "devDependencies": { 4 | "drizzle-kit": "latest" 5 | } 6 | } -------------------------------------------------------------------------------- /packages/sdk/src/generate/fixtures/non-monorepo-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "prisma": "latest" 4 | } 5 | } -------------------------------------------------------------------------------- /packages/sdk/src/generate/fixtures/non-monorepo-project/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/formatInput.ts: -------------------------------------------------------------------------------- 1 | import { snakeCase } from 'lodash' 2 | 3 | export const formatInput = (values: string[]) => { 4 | return values.map((value) => `${snakeCase(value)}`).join(' ') 5 | } 6 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/generateModelDefaults/addOptionsToModelDefaultCode.test.ts: -------------------------------------------------------------------------------- 1 | import { addOptionsToModelDefaultCode } from './addOptionsToModelDefaultCode.js' 2 | 3 | describe('addOptionsToModelDefaultCode', () => { 4 | test('adding `options` argument', async () => { 5 | expect(await addOptionsToModelDefaultCode('copycat.email(seed)')).toEqual( 6 | 'copycat.email(seed, options);' 7 | ) 8 | }) 9 | 10 | test('spreading in `options` into existing object expression', async () => { 11 | expect( 12 | await addOptionsToModelDefaultCode('copycat.email(seed, { limit: 10 })') 13 | ).toEqual(`\ 14 | copycat.email(seed, { 15 | limit: 10, 16 | ...options 17 | });`) 18 | }) 19 | 20 | test('ignores irrelevant code', async () => { 21 | expect(await addOptionsToModelDefaultCode('null')).toEqual('null;') 22 | expect(await addOptionsToModelDefaultCode('faker.wassup()')).toEqual( 23 | 'faker.wassup();' 24 | ) 25 | }) 26 | 27 | test('works with object expressions', async () => { 28 | expect( 29 | await addOptionsToModelDefaultCode( 30 | '{ [copycat.email(seed)]: copycat.email(seed) }' 31 | ) 32 | ).toEqual(`\ 33 | ({ 34 | [copycat.email(seed, options)]: copycat.email(seed, options) 35 | });`) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/getSelectedTables.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from '../config/config.js' 2 | import { calculateIncludedTables } from '../config/snapletConfig/v2/calculateIncludedTables.js' 3 | import type { IntrospectedStructure } from '~/db/introspect/introspectDatabase.js' 4 | interface GetSelectedTablesOptions { 5 | introspection: IntrospectedStructure 6 | config: Configuration 7 | } 8 | 9 | export interface SelectedTable { 10 | id: string 11 | schema: string 12 | name: string 13 | } 14 | 15 | export const getSelectedTables = async ({ 16 | introspection, 17 | config, 18 | }: GetSelectedTablesOptions): Promise => { 19 | return calculateIncludedTables( 20 | introspection['tables'], 21 | await config.getSchemas(), 22 | true 23 | ).map((table) => ({ 24 | id: table.id ?? `${table.schema}.${table.name}`, 25 | name: table.name, 26 | schema: table.schema, 27 | })) 28 | } 29 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/index.ts: -------------------------------------------------------------------------------- 1 | export * from './generateTransform.js' 2 | -------------------------------------------------------------------------------- /packages/sdk/src/generate/runStatements.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionString } from '../db/connString/ConnectionString.js' 2 | import { execQueryNext } from '../db/client.js' 3 | import { DatabaseError } from 'pg-protocol' 4 | 5 | const createForeignKeyViolationError = (dbError: DatabaseError) => { 6 | const message = ` 7 | ${dbError.message} 8 | 9 | Details: ${dbError.detail} 10 | 11 | One cause of this could be schemas or tables excluded in the \`select\` config in your \`snaplet.config.ts\`. 12 | In these cases, Snaplet will try generate data for a table without considering a relation for that table if the 13 | related tables has been excluded. 14 | ` 15 | 16 | const error = new Error(message) 17 | 18 | Object.assign(error, { 19 | ...dbError, 20 | dbError, 21 | }) 22 | 23 | throw error 24 | } 25 | 26 | export const runStatements = async ( 27 | connString: ConnectionString, 28 | statements: Iterable, 29 | abortSignal?: AbortSignal 30 | ) => { 31 | for (const statement of statements) { 32 | try { 33 | await execQueryNext(statement, connString) 34 | } catch (e) { 35 | if (e instanceof DatabaseError) { 36 | if (e.code === '23503') { 37 | throw createForeignKeyViolationError(e) 38 | } else { 39 | throw e 40 | } 41 | } 42 | } 43 | abortSignal?.throwIfAborted() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/adapters/in-memory.ts: -------------------------------------------------------------------------------- 1 | import type { UserModels } from '../plan/types.js' 2 | import type { DataModel } from '../dataModel/dataModel.js' 3 | import { SeedClientBase, SeedClientBaseOptions } from '../client.js' 4 | import { setupSeedClient } from '../setupSeedClient.js' 5 | import { Configuration } from '~/config/config.js' 6 | 7 | export function getSeedClient( 8 | dataModel: DataModel, 9 | userModels: UserModels, 10 | config?: Configuration 11 | ) { 12 | class SeedClient extends SeedClientBase { 13 | constructor( 14 | public statementsStore: string[], 15 | public options?: SeedClientBaseOptions 16 | ) { 17 | super({ 18 | dataModel, 19 | userModels, 20 | runStatements: async (statements: string[]) => { 21 | statementsStore.push(...statements) 22 | }, 23 | options, 24 | }) 25 | } 26 | 27 | async $transaction(cb: (seed: SeedClient) => Promise) { 28 | await cb(await createSeedClient(this.statementsStore, this.options)) 29 | } 30 | } 31 | 32 | const createSeedClient = async ( 33 | statementsStore: string[], 34 | options?: SeedClientBaseOptions 35 | ) => 36 | setupSeedClient( 37 | (options) => new SeedClient(statementsStore, options), 38 | config, 39 | options 40 | ) 41 | 42 | return createSeedClient 43 | } 44 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/adapters/pg.ts: -------------------------------------------------------------------------------- 1 | export * from './pg/pg.js' 2 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/adapters/pg/withClientDefault.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'pg' 2 | import { type WithClient } from './pg.js' 3 | import { getProjectConfigAsync } from '~/config/projectConfig/projectConfig.js' 4 | 5 | export const withClientDefault: WithClient = async (fn) => { 6 | const connectionString = (await getProjectConfigAsync()).targetDatabaseUrl 7 | const client = new Client({ connectionString }) 8 | await client.connect() 9 | 10 | try { 11 | await fn(client) 12 | } finally { 13 | await client.end() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/index.ts: -------------------------------------------------------------------------------- 1 | export * from './dataModel/dataModel.js' 2 | export * from './plan/plan.js' 3 | export * from './plan/types.js' 4 | export * from './store.js' 5 | export * from './client.js' 6 | export * from './generateTypes.js' 7 | export * from './utils/seed.js' 8 | export * from './dataModel/fingerprint.js' 9 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/plan/serialize.ts: -------------------------------------------------------------------------------- 1 | import { isInstanceOf } from '~/lang.js' 2 | import { Serializable } from './types.js' 3 | import { Json } from '~/types.js' 4 | import { mapValues } from 'lodash' 5 | 6 | export const serializeValue = (value: Serializable): Json | undefined => { 7 | return isInstanceOf(value, Date) ? value.toISOString() : value 8 | } 9 | 10 | export const serializeModelValues = (model: { 11 | [field: string]: Serializable 12 | }): { 13 | [field: string]: Json | undefined 14 | } => mapValues(model, serializeValue) 15 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/readFingerprint.ts: -------------------------------------------------------------------------------- 1 | import { Fingerprint, getPathsV2 } from '~/exports/cli.js' 2 | 3 | export function readFingerprint(): Fingerprint { 4 | const fingerprintPath = getPathsV2().project?.fingerprint 5 | 6 | if (fingerprintPath) { 7 | try { 8 | return require(fingerprintPath) 9 | } catch { 10 | // noop 11 | } 12 | } 13 | 14 | return {} 15 | } 16 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/sql.ts: -------------------------------------------------------------------------------- 1 | import { isNestedArrayPgType } from '~/pgTypes.js' 2 | import type { Json } from '~/types.js' 3 | import { serializeArrayColumn } from '~/csv.js' 4 | 5 | export const serializeToSQL = (type: string, value: Json): Json => { 6 | if (isNestedArrayPgType(type)) { 7 | return serializeArrayColumn(value, type) 8 | } 9 | 10 | if (['json', 'jsonb'].includes(type)) { 11 | return JSON.stringify(value) 12 | } 13 | 14 | return value 15 | } 16 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/utils/dedupePreferLast.ts: -------------------------------------------------------------------------------- 1 | export const dedupePreferLast = (values: Value[]): Value[] => 2 | Array.from(new Set(values.reverse())).reverse() 3 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/utils/extractBranch.ts: -------------------------------------------------------------------------------- 1 | type GraphNode = { [key: string]: any } | any[] 2 | 3 | // In collaboration with ChatGPT :D 4 | export function extractBranch( 5 | graph: GraphNode, 6 | path: (string | number)[] 7 | ): GraphNode | null { 8 | if (path.length === 0) { 9 | return graph 10 | } 11 | 12 | const [head, ...tail] = path 13 | 14 | if (Array.isArray(graph)) { 15 | if (typeof head === 'number' && head < graph.length) { 16 | const pruned = extractBranch(graph[head], tail) 17 | return pruned !== null ? [pruned] : null 18 | } 19 | return null 20 | } 21 | 22 | if (graph && typeof graph === 'object') { 23 | const newGraph: { [key: string]: any } = {} 24 | 25 | for (const key in graph) { 26 | if (key === head) { 27 | const pruned = extractBranch(graph[key], tail) 28 | if (pruned !== null) { 29 | newGraph[key] = pruned 30 | } 31 | } else if (path.indexOf(key) === -1) { 32 | newGraph[key] = graph[key] // Preserve sibling keys 33 | } 34 | } 35 | 36 | for (const key in newGraph) { 37 | if (Array.isArray(newGraph[key]) && newGraph[key].length === 1) { 38 | newGraph[key] = newGraph[key][0] 39 | } 40 | } 41 | 42 | return Object.keys(newGraph).length > 0 ? newGraph : null 43 | } 44 | 45 | return null 46 | } 47 | -------------------------------------------------------------------------------- /packages/sdk/src/generateOrm/utils/sequenceFactory.ts: -------------------------------------------------------------------------------- 1 | import type { DataModelSequence } from '../index.js' 2 | 3 | export function sequenceGeneratorFactory(sequence: DataModelSequence) { 4 | return function* () { 5 | let current = sequence.current 6 | while (true) { 7 | yield current 8 | current += sequence.increment 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/sdk/src/generateUniqueName.test.ts: -------------------------------------------------------------------------------- 1 | import { generateUniqueName } from './generateUniqueName.js' 2 | 3 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 4 | 5 | describe('generateUniqueName', () => { 6 | test('should not collide once over 1000 runs', async () => { 7 | const names = new Set() 8 | for (let i = 0; i < 1000; i++) { 9 | const name = generateUniqueName('ss') 10 | expect(names.has(name)).toBe(false) 11 | names.add(name) 12 | // We need to wait for a bit to make sure the seed is different 13 | await sleep(2) 14 | } 15 | }) 16 | // Tested locally, but slowdown too much the tests to be ran everytime 17 | test.skip('should not collide once over 10k runs', async () => { 18 | const names = new Set() 19 | for (let i = 0; i < 10000; i++) { 20 | const name = generateUniqueName('ss') 21 | if (names.has(name)) { 22 | expect(i).toBe(5000) 23 | } 24 | expect(names.has(name)).toBe(false) 25 | names.add(name) 26 | // We need to wait for a bit to make sure the seed is different 27 | await sleep(2) 28 | } 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /packages/sdk/src/generateUniqueName.ts: -------------------------------------------------------------------------------- 1 | import { copycat } from '@snaplet/copycat' 2 | import { predicates, objects } from 'friendly-words' 3 | import { v4 as uuid } from 'uuid' 4 | 5 | type Prefix = 'pr' | 'ss' | 'pdb' 6 | 7 | export function generateUniqueName(prefix: Prefix) { 8 | const key = uuid() 9 | 10 | return [ 11 | prefix, 12 | copycat.oneOf(key, predicates), 13 | copycat.oneOf(key, objects), 14 | copycat.int(key, { min: 100000, max: 999999 }), 15 | ].join('-') 16 | } 17 | -------------------------------------------------------------------------------- /packages/sdk/src/lang.test.ts: -------------------------------------------------------------------------------- 1 | import { isInstanceOf } from './lang.js' 2 | 3 | describe('lang', () => { 4 | describe('isInstanceOf', () => { 5 | test('true when native instanceof is true', () => { 6 | class Foo {} 7 | class Bar {} 8 | expect(isInstanceOf(new Foo(), Foo)).toBe(true) 9 | expect(isInstanceOf(new Error(), Error)).toBe(true) 10 | expect(isInstanceOf(new Foo(), Bar)).toBe(false) 11 | expect(isInstanceOf(new Error(), Bar)).toBe(false) 12 | }) 13 | 14 | test('true when constructor name matches', () => { 15 | expect(isInstanceOf(new (class Foo {})(), class Foo {})).toBe(true) 16 | expect(isInstanceOf(new Error(), class Error {})).toBe(true) 17 | expect(isInstanceOf(new (class Foo {})(), class Bar {})).toBe(false) 18 | expect(isInstanceOf(new Error(), class Bar {})).toBe(false) 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /packages/sdk/src/lang.ts: -------------------------------------------------------------------------------- 1 | // context(justinvdm, 18 Jan 2024): In some cases, we cannot rely on native instanceof, since the constructor might 2 | // be an entirely different object. For example: 3 | // * Our code and libraries (e.g. @snaplet/seed) used inside of jest - jest overrides global objects 4 | // * Dual package hazard: (https://nodejs.org/api/packages.html#dual-package-hazard) - this can happen, for e.g, if 5 | // for some reason two versions of our packages or their dependencies end up in the same runtime for a user 6 | // * Comparing values created inside of a sandbox (e.g. an evaluated snaplet.config.ts file) with constructors created 7 | // outside of that sandbox 8 | export const isInstanceOf = < 9 | Constructor extends new (...args: any[]) => unknown, 10 | >( 11 | v: unknown, 12 | constructor: Constructor 13 | ): v is InstanceType => { 14 | if (v instanceof constructor) { 15 | return true 16 | } 17 | 18 | if (v?.constructor?.name === constructor?.name) { 19 | return true 20 | } 21 | 22 | return false 23 | } 24 | -------------------------------------------------------------------------------- /packages/sdk/src/neon.ts: -------------------------------------------------------------------------------- 1 | // from https://neon.tech/docs/introduction/regions#available-regions 2 | const regions = [ 3 | // note: this is the order displayed on the web app. 4 | { id: 'aws-us-east-2', name: 'US East (Ohio)' }, 5 | { id: 'aws-us-east-1', name: 'US East (N. Virginia)' }, 6 | { id: 'aws-us-west-2', name: 'US West (Oregon)' }, 7 | { id: 'aws-eu-central-1', name: 'Europe (Frankfurt)' }, 8 | { id: 'aws-ap-southeast-1', name: 'Asia Pacific (Singapore)' }, 9 | ] as const 10 | 11 | // from https://neon.tech/docs/reference/compatibility#postgresql-versions 12 | const pgVersions = [14, 15] as const 13 | 14 | const DEFAULT_REGION = 'aws-us-east-1' as const 15 | 16 | const DEFAULT_PG_VERSION = 15 as const 17 | 18 | const DEFAULT_MAIN_BRANCH = 'snappy_main' as const 19 | 20 | export const neon = { 21 | DEFAULT_MAIN_BRANCH, 22 | DEFAULT_REGION, 23 | DEFAULT_PG_VERSION, 24 | regions, 25 | pgVersions, 26 | } 27 | -------------------------------------------------------------------------------- /packages/sdk/src/pgTypes.test.ts: -------------------------------------------------------------------------------- 1 | import { fakeDbStructure } from './testing/index.js' 2 | import { createColumnTypeLookup, getPgTypeArrayDimensions } from './pgTypes.js' 3 | 4 | describe('createColumnTypeLookup', () => { 5 | it('same table name, different column', () => { 6 | const structure = fakeDbStructure() 7 | const correctTable = structure.tables[0] 8 | 9 | const decoyTable = { 10 | ...correctTable, 11 | schema: 'decoySchema', 12 | columns: correctTable.columns.map((column) => ({ 13 | ...column, 14 | type: 'tztrange', 15 | })), 16 | } 17 | 18 | expect(correctTable.columns[0]).not.toEqual(decoyTable.columns[0]) 19 | 20 | expect( 21 | createColumnTypeLookup(structure, correctTable.schema, correctTable.name) 22 | ).toMatchInlineSnapshot(` 23 | { 24 | "confirmed_at": "text", 25 | "email": "text", 26 | "id": "text", 27 | "name": "text", 28 | } 29 | `) 30 | }) 31 | }) 32 | 33 | describe('getPgTypeArrayDimensions', () => { 34 | it('returns 0 for non-array types', () => { 35 | expect(getPgTypeArrayDimensions('text')).toEqual(0) 36 | }) 37 | 38 | it('returns the number of [] for array types', () => { 39 | expect(getPgTypeArrayDimensions('text[][][]')).toEqual(3) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /packages/sdk/src/pii/piiImpact.test.ts: -------------------------------------------------------------------------------- 1 | import { PredictedShape } from '~/db/structure.js' 2 | import { piiImpact } from './piiImpact.js' 3 | import { Shape } from '~/shapes.js' 4 | 5 | describe(' Pii impact level', () => { 6 | test('predict pii impact based on shape and context', () => { 7 | const impact = piiImpact('PERSON', 'FULL_NAME') 8 | expect(impact).toEqual('HIGH') 9 | 10 | const impact2 = piiImpact('GENERAL', 'STATUS') 11 | expect(impact2).toEqual('LOW') 12 | }) 13 | 14 | test('predict pii impact using typical implementation', () => { 15 | const predictedShape: PredictedShape[] = [ 16 | { 17 | input: 'public users name character varying', 18 | column: 'name', 19 | shape: 'FIRST_NAME', 20 | confidence: 0.8, 21 | context: 'PERSON', 22 | confidenceContext: 0.5, 23 | }, 24 | { 25 | input: 'public medical meta_data character varying', 26 | column: 'meta_data', 27 | shape: 'META_DATA', 28 | confidence: 0.9, 29 | context: 'HEALTH', 30 | confidenceContext: 0.6, 31 | }, 32 | ] 33 | 34 | for (const entry of predictedShape) { 35 | const impact = piiImpact(entry.context!, entry.shape! as Shape) 36 | expect(impact).toEqual('HIGH') 37 | } 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /packages/sdk/src/proxy/DatabaseError.ts: -------------------------------------------------------------------------------- 1 | import { Protocol } from './protocol.js' 2 | 3 | export class DatabaseError extends Error { 4 | severity?: string 5 | code?: string 6 | detail?: string 7 | hint?: string 8 | position?: number 9 | internalPosition?: string 10 | internalQuery?: string 11 | where?: string 12 | schema?: string 13 | table?: string 14 | column?: string 15 | dataType?: string 16 | constraint?: string 17 | lineNr?: number 18 | colNr?: number 19 | line?: string 20 | 21 | constructor(msg: Protocol.ErrorResponseMessage) { 22 | super(msg.message) 23 | Object.assign(this, { 24 | ...msg, 25 | line: undefined, 26 | file: undefined, 27 | routine: undefined, 28 | }) 29 | if (msg.position) this.position = parseInt(msg.position, 10) || undefined 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/sdk/src/proxy/SafeEventEmitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | export class SafeEventEmitter extends EventEmitter { 4 | emit(event: string | symbol, ...args: any[]): boolean { 5 | try { 6 | if (event === 'error' && !this.listenerCount('error')) return false 7 | return super.emit(event, ...args) 8 | } catch (ignored) { 9 | return false 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/sdk/src/repo/addDevDependencies.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | import { RepoIntrospection } from './introspectRepo.js' 3 | import { SnapletError } from '~/errors.js' 4 | import { packageManagers } from './packageManagers.js' 5 | 6 | export type Exec = typeof execa 7 | 8 | export const addDevDependencies = async ( 9 | specs: string[], 10 | introspection: RepoIntrospection, 11 | options: Partial<{ exec: Exec }> = {} 12 | ) => { 13 | const { currentWorkspaceRoot, packageManager } = introspection 14 | 15 | if (!currentWorkspaceRoot) { 16 | throw new SnapletError('WORKSPACE_ROOT_NOT_FOUND') 17 | } 18 | 19 | if (!packageManager) { 20 | throw new SnapletError('PACKAGE_MANAGER_NOT_FOUND') 21 | } 22 | 23 | const { exec: baseExec = execa } = options 24 | 25 | const context = { 26 | ...options, 27 | specs, 28 | exec(cmd: string, args: string[]) { 29 | return baseExec(cmd, args, { 30 | cwd: currentWorkspaceRoot, 31 | }) 32 | }, 33 | } 34 | 35 | try { 36 | return await packageManagers[packageManager].addDev(context) 37 | } catch (error) { 38 | throw new SnapletError('PACKAGE_MANAGER_RUN_ERROR', { 39 | error, 40 | }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/sdk/src/repo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './introspectRepo.js' 2 | export * from './addDevDependencies.js' 3 | -------------------------------------------------------------------------------- /packages/sdk/src/result.ts: -------------------------------------------------------------------------------- 1 | import { ResultError, ResultSuccess } from './types.js' 2 | 3 | export function ok(value: T): ResultSuccess { 4 | return { ok: true, value } 5 | } 6 | 7 | export function err(error: E): ResultError { 8 | return { ok: false, error } 9 | } 10 | -------------------------------------------------------------------------------- /packages/sdk/src/shapes.test.ts: -------------------------------------------------------------------------------- 1 | import { findShape } from './shapes.js' 2 | 3 | describe('findShape', () => { 4 | test('finds the closest shape', () => { 5 | expect(findShape('first_name', 'string')).toMatchInlineSnapshot(` 6 | { 7 | "closest": "first name", 8 | "distance": 1, 9 | "shape": "FIRST_NAME", 10 | } 11 | `) 12 | }) 13 | 14 | test('returns null for matches that could not be found', () => { 15 | expect(findShape('walzaroundtheroom', 'string')).toBe(null) 16 | }) 17 | 18 | test('false positive matches', () => { 19 | expect(findShape('logs', 'string')?.shape).toEqual('LOGS') 20 | expect(findShape('status', 'string')?.shape).toEqual('STATUS') 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /packages/sdk/src/snapshot/compress.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | import fs from 'fs' 3 | import zlib from 'zlib' 4 | 5 | export async function compress( 6 | src: string, 7 | dst: string, 8 | keepOriginal = true, 9 | quality = '1' 10 | ) { 11 | const startTime = Date.now() 12 | const res = await execa('brotli', [ 13 | `--output=${dst}`, 14 | keepOriginal ? '--keep' : '--rm', 15 | '--quality=' + quality, 16 | '--verbose', 17 | '--force', 18 | src, 19 | ]) 20 | if (res.failed) { 21 | throw new Error(res.stderr) 22 | } 23 | const oldSize = fs.statSync(src).size 24 | const newSize = fs.statSync(dst).size 25 | return { 26 | oldSize, 27 | newSize, 28 | ms: Date.now() - startTime, 29 | } 30 | } 31 | 32 | export async function decompressTable(src: string, dst: string) { 33 | return new Promise((resolve, reject) => { 34 | const brotli = zlib.createBrotliDecompress() 35 | const reader = fs.createReadStream(src).pipe(brotli) 36 | reader.on('error', reject) 37 | 38 | const writer = fs.createWriteStream(dst) 39 | writer.on('finish', resolve).on('error', reject) 40 | 41 | reader.pipe(writer) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /packages/sdk/src/snapshot/execTaskStatus.test.ts: -------------------------------------------------------------------------------- 1 | import { isExecTaskTimeout } from './execTaskStatus.js' 2 | 3 | test('should timeout after 5 minutes of inactivity', () => { 4 | const pastMinute = new Date() 5 | pastMinute.setMinutes(pastMinute.getMinutes() - 1) 6 | expect(isExecTaskTimeout(pastMinute, 5)).toEqual(false) 7 | 8 | const pastHour = new Date() 9 | pastHour.setMinutes(pastHour.getMinutes() - 60) 10 | expect(isExecTaskTimeout(pastHour, 5)).toEqual(true) 11 | 12 | const pastFourMinutes = new Date() 13 | pastFourMinutes.setMinutes(pastFourMinutes.getMinutes() - 4) 14 | expect(isExecTaskTimeout(pastFourMinutes, 5)).toEqual(false) 15 | 16 | const pastFiveMinutes = new Date() 17 | pastFiveMinutes.setMinutes(pastFiveMinutes.getMinutes() - 5) 18 | expect(isExecTaskTimeout(pastFiveMinutes, 5)).toEqual(true) 19 | 20 | const pastSixMinutes = new Date() 21 | pastSixMinutes.setMinutes(pastSixMinutes.getMinutes() - 6) 22 | expect(isExecTaskTimeout(pastSixMinutes, 5)).toEqual(true) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/sdk/src/snapshot/paths.test.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@snaplet/copycat' 2 | 3 | import { getSnapshotFilePaths, generateSnapshotBasePath } from './paths.js' 4 | 5 | test('getSnapshotFilePaths', () => { 6 | expect(getSnapshotFilePaths('/tmp/x')).toMatchInlineSnapshot(` 7 | { 8 | "base": "/tmp/x", 9 | "config": "/tmp/x/snaplet.config.ts", 10 | "restoreLog": "/tmp/x/restore.log", 11 | "schemas": "/tmp/x/schemas.sql", 12 | "structure": "/tmp/x/structure.json", 13 | "summary": "/tmp/x/summary.json", 14 | "tables": "/tmp/x/tables", 15 | } 16 | `) 17 | }) 18 | 19 | test.skip('it generates a valid snapshot filename', async () => { 20 | const date = new Date('2022-08-15T20:09Z') 21 | faker.seed(1983) 22 | const name = faker.hacker.noun() + '-' + faker.hacker.verb() 23 | 24 | const x1 = generateSnapshotBasePath({ date, name }) 25 | expect( 26 | x1.endsWith('.snaplet/snapshots/1660594140000-microchip-hack') 27 | ).toBeTruthy() 28 | 29 | const x2 = generateSnapshotBasePath({ date, name }) 30 | expect(x2.endsWith('1660594140000-microchip-hack')).toBeTruthy() 31 | }) 32 | -------------------------------------------------------------------------------- /packages/sdk/src/snapshot/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { SnapshotOrigin, SnapshotSummary } from './summary.js' 2 | import { SnapshotStatus } from './types.js' 3 | 4 | export type CloudSnapshot = { 5 | /** @deprecated */ 6 | totalSize?: number 7 | /** @deprecated */ 8 | id?: string 9 | /** @deprecated */ 10 | createdAt?: Date 11 | /** @deprecated */ 12 | projectId?: string 13 | /** @deprecated */ 14 | uniqueName?: string 15 | /** @deprecated */ 16 | failureCount?: number 17 | /** @deprecated */ 18 | errors?: string[] 19 | //---------- 20 | origin: SnapshotOrigin 21 | status: keyof typeof SnapshotStatus 22 | summary: SnapshotSummary 23 | /** 24 | * Locally cached snapshots are stored on the disk. 25 | * This is the absolute path to the cached snapshot. 26 | * Populated by `LocalSnapshotHost` 27 | */ 28 | cachePath?: string 29 | encryptedSessionKey?: string 30 | } 31 | -------------------------------------------------------------------------------- /packages/sdk/src/snapshot/types.ts: -------------------------------------------------------------------------------- 1 | export const SnapshotStatus = { 2 | IN_PROGRESS: 'IN_PROGRESS', 3 | SUCCESS: 'SUCCESS', 4 | FAILURE: 'FAILURE', 5 | TIMEOUT: 'TIMEOUT', 6 | } as const 7 | -------------------------------------------------------------------------------- /packages/sdk/src/sort.ts: -------------------------------------------------------------------------------- 1 | import { TopologicalSort } from 'topological-sort' 2 | 3 | import { IntrospectedStructure } from './db/introspect/introspectDatabase.js' 4 | import { isError } from './errors.js' 5 | 6 | export const topologicalSort = (tables: IntrospectedStructure['tables']) => { 7 | const nodes = new Map() 8 | for (const table of tables) { 9 | nodes.set(table.id, table) 10 | } 11 | const sortOp = new TopologicalSort(nodes) 12 | for (const table of tables) { 13 | for (const parent of table.parents) { 14 | // If at least one of the keys is non nullable, the relation is non nullable 15 | const isNullable = parent.keys.every((key) => key.nullable === true) 16 | 17 | if (isNullable === false) { 18 | try { 19 | sortOp.addEdge(parent.fkTable, parent.targetTable) 20 | } catch (e) { 21 | const isDoubleEdgeError = 22 | isError(e) && 23 | e.message.includes('already has an adge to target node') 24 | if (!isDoubleEdgeError) { 25 | throw e 26 | } 27 | } 28 | } 29 | } 30 | } 31 | return sortOp.sort() 32 | } 33 | -------------------------------------------------------------------------------- /packages/sdk/src/streams.ts: -------------------------------------------------------------------------------- 1 | export const createEffectStream = ( 2 | effectFn: (data: Data) => unknown | Promise 3 | ) => { 4 | const gen = async function* createEffectStreamGen( 5 | source: AsyncIterable 6 | ) { 7 | for await (const chunk of source) { 8 | effectFn(chunk) 9 | yield chunk 10 | } 11 | } 12 | 13 | // context(justinvdm: 28 Sep 2022): @types/node do not have a typedef for the arity we have here 14 | // the includes async generators: 15 | // https://github.com/snaplet/snaplet/pull/1400#discussion_r982184327 16 | return gen as unknown as NodeJS.ReadWriteStream 17 | } 18 | -------------------------------------------------------------------------------- /packages/sdk/src/templates/categories/bits.ts: -------------------------------------------------------------------------------- 1 | import { TypeTemplates } from '../types.js' 2 | 3 | export const bits: TypeTemplates = ({ input, field }) => ` 4 | (() => { 5 | const len = ${field.maxLength} || 1 6 | let bits = '' 7 | for (let i = 0; i < len; i++) { 8 | bits += copycat.oneOf(${input} + i, ['0', '1']) 9 | } 10 | return bits 11 | })() 12 | ` 13 | -------------------------------------------------------------------------------- /packages/sdk/src/templates/categories/floats.ts: -------------------------------------------------------------------------------- 1 | import { copycatTemplate } from '../copycat.js' 2 | import { TypeTemplates } from '../types.js' 3 | 4 | export const floats = (bytes: number): TypeTemplates => { 5 | return { 6 | LATITUDE: copycatTemplate('float', { options: { min: -90, max: 90 } }), 7 | LONGITUDE: copycatTemplate('float', { options: { min: -90, max: 90 } }), 8 | __DEFAULT: copycatTemplate('float', { 9 | options: { min: 0, max: Math.pow(2, bytes) }, 10 | }), 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/sdk/src/templates/categories/geometry.ts: -------------------------------------------------------------------------------- 1 | import { TypeTemplates } from '../types.js' 2 | 3 | export const line: TypeTemplates = ({ input }) => ` 4 | (() => { 5 | let options = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 6 | const points = [] 7 | 8 | for (let i = 0; i < 4; i++) { 9 | const value = copycat.oneOf(${input}, options) 10 | points.push(value) 11 | options = options.filter((p) => p !== value) 12 | } 13 | 14 | return '(' + points[0] + ', ' + points[1] + '), (' + points[2] + ', ' + points[3] + ')' 15 | })() 16 | ` 17 | 18 | export const circle: TypeTemplates = ({ input }) => ` 19 | (() => { 20 | let options = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 21 | const points = [] 22 | 23 | for (let i = 0; i < 3; i++) { 24 | const value = copycat.oneOf(${input}, options) 25 | points.push(value) 26 | options = options.filter((p) => p !== value) 27 | } 28 | 29 | return '((' + points[0] + ', ' + points[1] + ' ), ' + points[2] + ' )' 30 | })() 31 | ` 32 | 33 | export const point: TypeTemplates = ({ input }) => 34 | `'(' + copycat.int(${input}, { max: 10 }) + ',' + copycat.int(${input}, { max: 10 }) + ')'` 35 | -------------------------------------------------------------------------------- /packages/sdk/src/templates/categories/integers.ts: -------------------------------------------------------------------------------- 1 | import { copycatTemplate } from '../copycat.js' 2 | import { TypeTemplates } from '../types.js' 3 | 4 | export const integers = (bytes: number): TypeTemplates => { 5 | return { 6 | INDEX: copycatTemplate('int', { 7 | options: { min: 1, max: Math.pow(bytes, 8) - 1 }, 8 | }), 9 | LATITUDE: copycatTemplate('int', { options: { min: -90, max: 90 } }), 10 | LONGITUDE: copycatTemplate('int', { options: { min: -90, max: 90 } }), 11 | __DEFAULT: copycatTemplate('int', { 12 | options: { min: 0, max: Math.pow(bytes, 8) - 1 }, 13 | }), 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/sdk/src/templates/sets/autoTransformStrings.ts: -------------------------------------------------------------------------------- 1 | import { TypeTemplatesRecord } from '../types.js' 2 | 3 | export const AUTO_TRANSFORM_STRING_TEMPLATES: TypeTemplatesRecord = { 4 | UUID: ({ input }) => `copycat.scramble(${input}, { preserve: ['-'] })`, 5 | INDEX: ({ input }) => `copycat.scramble(${input}, { preserve: [] })`, 6 | TOKEN: ({ input }) => `copycat.scramble(${input}, { preserve: [] })`, 7 | NUMBER: ({ input }) => `copycat.scramble(${input}, { preserve: [] })`, 8 | EMAIL: ({ input }) => `copycat.scramble(${input}, { preserve: ['@', '.']})`, 9 | USERNAME: ({ input }) => `copycat.scramble(${input})`, 10 | ZIP_CODE: ({ input }) => 11 | `copycat.scramble(${input}, { preserve: ['#', '-'] })`, 12 | PASSWORD: ({ input }) => `copycat.scramble(${input}, { preserve: [] })`, 13 | __DEFAULT: ({ input }) => `copycat.scramble(${input})`, 14 | } 15 | -------------------------------------------------------------------------------- /packages/sdk/src/templates/types.ts: -------------------------------------------------------------------------------- 1 | import type * as t from '@babel/types' 2 | 3 | import { Shape } from '../shapes.js' 4 | import { JsTypeName } from '../pgTypes.js' 5 | import { ShapeExtra } from '~/shapeExtra.js' 6 | import { ShapeGenerate } from '~/shapesGenerate.js' 7 | 8 | export type TemplateField = { 9 | type: string 10 | name: string 11 | maxLength?: number 12 | } 13 | 14 | export interface TemplateContext { 15 | field: TemplateField 16 | shape: Shape | ShapeGenerate | null 17 | jsType: JsTypeName 18 | input: TemplateInputNode 19 | } 20 | 21 | export type TemplateInputNode = t.Expression | t.PatternLike | string 22 | 23 | export type TemplateResult = string | null 24 | 25 | export type TemplateFn = (api: TemplateContext) => TemplateResult 26 | 27 | export type TypeTemplates = TemplateFn | TypeTemplatesRecord 28 | 29 | export type TypeTemplatesRecord = Partial< 30 | Record 31 | > 32 | 33 | export type Templates = Partial< 34 | Record 35 | > 36 | -------------------------------------------------------------------------------- /packages/sdk/src/testing.ts: -------------------------------------------------------------------------------- 1 | export * from './testing/index.js' 2 | -------------------------------------------------------------------------------- /packages/sdk/src/testing/createSqliteDb.ts: -------------------------------------------------------------------------------- 1 | import { execQueryNext } from '../db/sqlite/client.js' 2 | import { copyFile } from 'fs-extra' 3 | import path from 'path' 4 | import { createTestTmpDirectory } from './createTestTmpDirectory.js' 5 | 6 | const CHINOOK_DATABASE_PATH = path.resolve( 7 | __dirname, 8 | '../../__fixtures__/sqlite/chinook.db' 9 | ) 10 | 11 | export async function createSqliteTestDatabase(structure: string) { 12 | const tmp = await createTestTmpDirectory() 13 | const connString = path.join(tmp.name, 'test.sqlite3') 14 | await execQueryNext(structure, connString) 15 | return connString 16 | } 17 | 18 | // A sample database with data in it took from: https://www.sqlitetutorial.net/sqlite-sample-database/ 19 | export async function createChinookSqliteTestDatabase() { 20 | const tmp = await createTestTmpDirectory() 21 | const connString = path.join(tmp.name, 'chinook.sqlite3') 22 | // copy chinook database to tmp directory 23 | await copyFile(CHINOOK_DATABASE_PATH, connString) 24 | return connString 25 | } 26 | -------------------------------------------------------------------------------- /packages/sdk/src/testing/createTestDb.test.ts: -------------------------------------------------------------------------------- 1 | import { dbExistsNext } from '../db/tools.js' 2 | import { defineCreateTestDb } from './createTestDb.js' 3 | 4 | describe('createTestDb', () => { 5 | test('creates a test db', async () => { 6 | const state = { dbNames: [] } 7 | const createTestDb = defineCreateTestDb(state) 8 | const connString = await createTestDb() 9 | expect(await dbExistsNext(connString)).toBe(true) 10 | await createTestDb.afterEach() 11 | }) 12 | 13 | test('drops db after each test run', async () => { 14 | const state = { dbNames: [] } 15 | const createTestDb = defineCreateTestDb(state) 16 | 17 | const connString = await createTestDb() 18 | await createTestDb.afterEach() 19 | 20 | expect(await dbExistsNext(connString)).toBe(false) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /packages/sdk/src/testing/createTestTmpDirectory.ts: -------------------------------------------------------------------------------- 1 | import { mkdirpSync, removeSync } from 'fs-extra' 2 | import { dirSync } from 'tmp-promise' 3 | 4 | import { afterDebug } from './debug.js' 5 | 6 | interface State { 7 | tmpPaths: Array<{ 8 | keep: boolean 9 | name: string 10 | }> 11 | } 12 | 13 | const defineCreateTestTmpDirectory = (state: State) => { 14 | const createTestTmpDirectory = (keep = false): State['tmpPaths'][number] => { 15 | const x = dirSync() 16 | removeSync(x.name) 17 | mkdirpSync(x.name) 18 | state.tmpPaths.push({ keep, name: x.name }) 19 | return x 20 | } 21 | 22 | createTestTmpDirectory.afterAll = () => { 23 | const tmpPaths = state.tmpPaths 24 | state.tmpPaths = [] 25 | afterDebug(`createTestTmpDirectory afterAll cleanup: ${tmpPaths.length}`) 26 | 27 | for (const capturePath of tmpPaths) { 28 | if (capturePath.keep === false) { 29 | removeSync(capturePath.name) 30 | } 31 | } 32 | } 33 | 34 | return createTestTmpDirectory 35 | } 36 | 37 | export const createTestTmpDirectory = defineCreateTestTmpDirectory({ 38 | tmpPaths: [], 39 | }) 40 | 41 | afterAll(createTestTmpDirectory.afterAll) 42 | -------------------------------------------------------------------------------- /packages/sdk/src/testing/debug.ts: -------------------------------------------------------------------------------- 1 | import { xdebugRaw } from '~/x/xdebug.js' 2 | 3 | const testDebug = xdebugRaw.extend('testing') 4 | // Useful to log into afterEach/after/afterAll cleanup 5 | const afterDebug = testDebug.extend('afterDebug') 6 | 7 | export { afterDebug } 8 | -------------------------------------------------------------------------------- /packages/sdk/src/testing/index.ts: -------------------------------------------------------------------------------- 1 | export { createTestTmpDirectory } from './createTestTmpDirectory.js' 2 | export { createTestDb, createSnapletTestDb } from './createTestDb.js' 3 | export { createTestRole } from './createTestRole.js' 4 | 5 | export { 6 | fakeDbStructure, 7 | fakeTableStructure, 8 | fakeColumnStructure, 9 | } from './fakes.js' 10 | -------------------------------------------------------------------------------- /packages/sdk/src/testing/setup.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv-defaults' 2 | import path from 'path' 3 | 4 | const CLI_BASE_DIR = path.resolve(__dirname, '../../../../') 5 | 6 | dotenv.config({ 7 | defaults: path.resolve(CLI_BASE_DIR, '../.env.defaults'), 8 | }) 9 | -------------------------------------------------------------------------------- /packages/sdk/src/transform.ts: -------------------------------------------------------------------------------- 1 | export * from './transform/index.js' 2 | export type * from './transform/index.js' 3 | -------------------------------------------------------------------------------- /packages/sdk/src/transform/fillRows/index.ts: -------------------------------------------------------------------------------- 1 | export * from './fillRows.js' 2 | export * from './types.js' 3 | -------------------------------------------------------------------------------- /packages/sdk/src/transform/index.ts: -------------------------------------------------------------------------------- 1 | export * from './transform.js' 2 | export * from './fillRows/index.js' 3 | export * from './fallbacks.js' 4 | -------------------------------------------------------------------------------- /packages/sdk/src/transformError.test.ts: -------------------------------------------------------------------------------- 1 | import { TransformError } from './transformError.js' 2 | 3 | describe('TransformError', () => { 4 | test('the error is formatted correctly', async () => { 5 | const error = new TransformError( 6 | { 7 | schema: 'public', 8 | table: 'User', 9 | columns: ['id', 'name', 'email'], 10 | row: { 11 | line: 3654, 12 | raw: { 13 | id: 'u1654519848516', 14 | name: 'John Doe', 15 | email: 'john.doe@gmail.com', 16 | }, 17 | parsed: { 18 | id: 'u1654519848516', 19 | name: 'John Doe', 20 | email: 'john.doe@gmail.com', 21 | }, 22 | }, 23 | column: 'name', 24 | }, 25 | 'it broke here' 26 | ) 27 | 28 | expect(error.toString()).toMatchInlineSnapshot(` 29 | " ┌─[\\"public\\".\\"User\\"] 30 | │ 31 | 1 │ id,name,email 32 | · 33 | · 34 | ∙ 3655 │ u1654519848516,John Doe,john.doe@gmail.com 35 | ·  ────┬─── 36 | ·  ╰────── it broke here 37 | │ 38 | └─" 39 | `) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /packages/sdk/src/types.ts: -------------------------------------------------------------------------------- 1 | // Global types helpers 2 | export type NonEmptyArray = [T, ...T[]] 3 | export type DistributivePick = T extends unknown 4 | ? Pick 5 | : never 6 | export type AsyncFunctionSuccessType< 7 | T extends (...args: any) => Promise, 8 | > = Awaited> 9 | export type JsonPrimitive = null | number | string | boolean 10 | export type Nested = 11 | | V 12 | | { [s: string]: V | Nested } 13 | | Array> 14 | export type Json = Nested 15 | export type RowShape = Record 16 | export type TransformContext = { 17 | schema: string 18 | table: string 19 | columns?: string[] 20 | row: { 21 | line: number 22 | raw: Record 23 | parsed: Row 24 | } 25 | column?: string 26 | } 27 | export type ResultSuccess = { ok: true; value: T } 28 | export type ResultError = { ok: false; error: E } 29 | export type Result = ResultSuccess | ResultError 30 | -------------------------------------------------------------------------------- /packages/sdk/src/validConnectionString.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CONNECTION_STRING_PROTOCOLS, 3 | ConnectionString, 4 | encodeConnectionString, 5 | } from './db/connString/index.js' 6 | 7 | export const validConnectionString = async (cs: string) => { 8 | let connString = new ConnectionString(cs) 9 | if (connString.validationErrors === null) { 10 | return connString 11 | } 12 | 13 | // attempt to auto-encode the connection string 14 | if (connString.validationErrors !== 'INVALID') { 15 | connString = new ConnectionString( 16 | encodeConnectionString(connString).toString() 17 | ) 18 | 19 | if (connString.validationErrors === null) { 20 | return connString 21 | } 22 | } 23 | 24 | let reason 25 | if (connString.validationErrors === 'UNRECOGNIZED_PROTOCOL') { 26 | reason = `The protocol is not recognized. Use one of ${CONNECTION_STRING_PROTOCOLS.join( 27 | ', ' 28 | )}` 29 | } else { 30 | reason = `Unable to parse connection string. Learn more: https://docs.snaplet.dev/guides/postgresql#connection-strings` 31 | } 32 | throw new Error(reason) 33 | } 34 | -------------------------------------------------------------------------------- /packages/sdk/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "paths": { 7 | "~/*": ["./src/*"], 8 | }, 9 | "emitDeclarationOnly": false, 10 | "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", 11 | "plugins": [ 12 | { "transform": "typescript-transform-paths" }, 13 | { "transform": "typescript-transform-paths", "afterDeclarations": true }, 14 | ], 15 | }, 16 | "include": ["src", "./ambient.d.ts"], 17 | "exclude": ["src/**/*.test.ts", "src/testing", "src/testing.ts", "src/generate/testing.ts", "src/templates/testing.ts"] 18 | } -------------------------------------------------------------------------------- /packages/sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": ".dts", 5 | "baseUrl": ".", 6 | "paths": { 7 | "~/*": ["./src/*"] 8 | }, 9 | "customConditions": ["snaplet_development"], 10 | "types": ["vitest/globals"], 11 | "sourceMap": true 12 | }, 13 | "include": ["*json", "*.mts", "*.ts", "src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/sdk/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv-defaults' 2 | import path from 'path' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | import { defineConfig } from 'vitest/config' 5 | 6 | dotenv.config({ 7 | defaults: path.resolve(__dirname, '../../.env.defaults'), 8 | }) 9 | 10 | export default defineConfig({ 11 | plugins: [tsconfigPaths({ ignoreConfigErrors: true })], 12 | resolve: { 13 | conditions: ['snaplet_development'] 14 | }, 15 | test: { 16 | globals: true, 17 | globalSetup: ['./src/testing/globalSetup.ts'], 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/options.html 2 | module.exports = { 3 | trailingComma: 'es5', 4 | bracketSpacing: true, 5 | tabWidth: 2, 6 | semi: false, 7 | singleQuote: true, 8 | arrowParens: 'always', 9 | importOrder: ['^~api/(.*)$', '^src/(.*)$', '^~/(.*)$', '^[./]'], 10 | importOrderSeparation: true, 11 | overrides: [ 12 | { 13 | files: 'Routes.*', 14 | options: { 15 | printWidth: 999, 16 | }, 17 | }, 18 | ], 19 | } 20 | -------------------------------------------------------------------------------- /scripts/checkHealth.js: -------------------------------------------------------------------------------- 1 | const MAX_TIMEOUT = 30 * 1000 2 | const HEALTH_URL = 'https://api.snaplet.dev/health' 3 | 4 | async function checkHealth() { 5 | console.log('Checking health...') 6 | const startTime = Date.now() 7 | 8 | // eslint-disable-next-line no-constant-condition 9 | while (true) { 10 | try { 11 | const response = await fetch(HEALTH_URL) 12 | 13 | if (response.ok) { 14 | const responseBody = await response.text() 15 | 16 | if (responseBody === 'OK') { 17 | console.log('Health check passed') 18 | break 19 | } 20 | } 21 | 22 | await new Promise((resolve) => setTimeout(resolve, 5000)) 23 | } catch (error) { 24 | await new Promise((resolve) => setTimeout(resolve, 5000)) 25 | } 26 | 27 | const elapsedTime = Date.now() - startTime 28 | if (elapsedTime >= MAX_TIMEOUT) { 29 | throw new Error('Timeout reached') 30 | } 31 | } 32 | } 33 | 34 | module.exports = checkHealth 35 | -------------------------------------------------------------------------------- /scripts/hideSentryPreviewEnv.ts: -------------------------------------------------------------------------------- 1 | import kebabCase from 'lodash/kebabCase' 2 | 3 | const SENTRY_SNAPLET_PROJECTS = ['api', 'web', 'cli'] 4 | const SENTRY_API_BASE_URL = 'https://snaplet.sentry.io/api/0/projects/snaplet' 5 | 6 | // Hide all environments who might have been attached related to the preview on sentry 7 | async function main() { 8 | const sentryEnvName = kebabCase(process.env.STAGE).slice(0, 63) 9 | const authToken = `Bearer ${process.env.SNAPLET_SENTRY_AUTH_TOKEN}` 10 | const requests = SENTRY_SNAPLET_PROJECTS.map(async (projectName) => { 11 | const req = await fetch( 12 | `${SENTRY_API_BASE_URL}/${projectName}/environments/${sentryEnvName}/`, 13 | { 14 | method: 'PUT', 15 | headers: { 16 | accept: 'application/json; charset=utf-8', 17 | 'content-type': 'application/json', 18 | Authorization: authToken, 19 | }, 20 | body: JSON.stringify({ 21 | name: sentryEnvName, 22 | isHidden: true, 23 | }), 24 | } 25 | ) 26 | return req.json() 27 | }) 28 | const results = await Promise.allSettled(requests) 29 | console.log(results) 30 | } 31 | 32 | void main() 33 | -------------------------------------------------------------------------------- /scripts/readAccessToken.js: -------------------------------------------------------------------------------- 1 | const { readFile } = require('fs/promises') 2 | const path = require('path') 3 | const once = require('lodash/once') 4 | 5 | exports.readAccessToken = once(async () => { 6 | const configContents = await readFile( 7 | path.join(process.env.HOME, '.config', 'snaplet', 'system.json') 8 | ) 9 | 10 | return JSON.parse(configContents).accessToken 11 | }) 12 | -------------------------------------------------------------------------------- /scripts/upgradeSnapletDep.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs/promises') 3 | const path = require('path') 4 | const execa = require('execa') 5 | 6 | const main = async () => { 7 | const pkg = require('../package.json') 8 | const currentVersion = pkg.devDependencies.snaplet 9 | const nextVersion = require('../cli/package.json').version 10 | pkg.devDependencies.snaplet = nextVersion 11 | 12 | console.log(`Upgrading snaplet from ${currentVersion} to ${nextVersion}...`) 13 | 14 | await fs.writeFile( 15 | require.resolve('../package.json'), 16 | JSON.stringify(pkg, null, 2) 17 | ) 18 | 19 | await execa('yarn', ['install'], { 20 | stdio: 'inherit', 21 | cwd: path.join(__dirname, '..'), 22 | env: { 23 | YARN_ENABLE_IMMUTABLE_INSTALLS: false, 24 | }, 25 | }) 26 | } 27 | 28 | main() 29 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Base", 4 | "extends": "@tsconfig/node18/tsconfig", 5 | "compilerOptions": { 6 | "lib": ["es2023"], 7 | "resolveJsonModule": true, 8 | "emitDeclarationOnly": true, 9 | "composite": true, 10 | "module": "nodenext", 11 | "moduleResolution": "nodenext" 12 | } 13 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "packages/sdk/tsconfig.build.json" }, 4 | { "path": "cli/tsconfig.build.json" }, 5 | ], 6 | "files": [], 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "references": [ 3 | { "path": "cli" }, 4 | { "path": "packages/sdk" }, 5 | ], 6 | "compilerOptions": { 7 | "outDir": ".dts" 8 | }, 9 | "files": ["snaplet.config.ts"], 10 | "extends": "./tsconfig.base.json", 11 | } -------------------------------------------------------------------------------- /tsconfig.react.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Create React App", 4 | 5 | "compilerOptions": { 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "module": "esnext", 8 | "target": "es2015", 9 | 10 | "allowJs": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "isolatedModules": true, 15 | "jsx": "react-jsx", 16 | "moduleResolution": "bundler", 17 | "noFallthroughCasesInSwitch": true, 18 | "resolveJsonModule": true, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "emitDeclarationOnly": true, 22 | "composite": true, 23 | "customConditions": ["snaplet_development"] 24 | } 25 | } -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "build:binary": { 9 | "dependsOn": ["build"], 10 | "outputs": ["bin/**"], 11 | "cache": false 12 | }, 13 | "bundle:production": { 14 | "dependsOn": ["^bundle:production"], 15 | "outputs": ["dist/**", "out/**"] 16 | }, 17 | "clean": { 18 | "cache": false 19 | }, 20 | "dev": { 21 | "cache": false, 22 | "persistent": true 23 | }, 24 | "test": { 25 | "cache": false 26 | }, 27 | "test:e2e": { 28 | "cache": false, 29 | "dependsOn": ["build"] 30 | } 31 | }, 32 | "globalEnv": ["NODE_ENV", "STAGE", "CI_TESTS"] 33 | } --------------------------------------------------------------------------------