1import {
2 deleteProject,
3 getConnectionURI,
4 getProject,
5 getProjectOperation,
6 listProjectBranchDatabases,
7 listProjectBranches,
8 listProjects,
9 type ListProjectsResponse,
10 type Project as NeonProject,
11 createProject as sdkCreateProject,
12 updateProject,
13} from "@distilled.cloud/neon";
14import * as Data from "effect/Data";
15import * as Effect from "effect/Effect";
16import * as Redacted from "effect/Redacted";
17import * as Schedule from "effect/Schedule";
18import { OwnedBySomeoneElse, Unowned } from "../AdoptPolicy.ts";
19import { isResolved } from "../Diff.ts";
20import { createPhysicalName } from "../PhysicalName.ts";
21import * as Provider from "../Provider.ts";
22import { Resource } from "../Resource.ts";
23import {
24 diffMigrations,
25 migrationsAttrs,
26 migrationsInputOf,
27 stampedOf,
28 type MigrationsInput,
29} from "../SQL/Migrations/index.ts";
30import { hashImports, readSqlFile } from "../SQL/SqlFile.ts";
31import { recordsEqual } from "../Util/equal.ts";
32import { runPgMigrations, runSql } from "./Migrations.ts";
33import { parsePostgresOrigin, type PostgresOrigin } from "./PostgresOrigin.ts";
34import type { Providers } from "./Providers.ts";
35
36const DEFAULT_REGION: NeonRegion = "aws-us-east-1";
37const DEFAULT_PG_VERSION: NeonPgVersion = 17;
38
39export type NeonRegion =
40 | "aws-us-east-1"
41 | "aws-us-east-2"
42 | "aws-us-west-2"
43 | "aws-eu-central-1"
44 | "aws-eu-west-2"
45 | "aws-ap-southeast-1"
46 | "aws-ap-southeast-2"
47 | "aws-sa-east-1"
48 | "azure-eastus2"
49 | "azure-westus3"
50 | "azure-gwc";
51
52export type NeonPgVersion = 14 | 15 | 16 | 17 | 18;
53
54export type ProjectProps = {
55 /**
56 * Mutable project name. If omitted at creation, a unique name is generated
57 * from `${app}-${stage}-${id}`. Omitting it later preserves the deployed name.
58 */
59 name?: string;
60 /**
61 * Region where the project is provisioned. Cannot be changed after
62 * creation.
63 *
64 * @default "aws-us-east-1"
65 */
66 region?: NeonRegion;
67 /**
68 * Postgres version. Cannot be changed after creation.
69 *
70 * @default 17
71 */
72 pgVersion?: NeonPgVersion;
73 /**
74 * Name of the default branch. Defaults to Neon's default ("main"). Cannot
75 * be changed after creation.
76 */
77 defaultBranchName?: string;
78 /**
79 * Name of the default role created with the project. Defaults to
80 * `neondb_owner` on creation; omission preserves the observed role on adoption
81 * and updates. An explicit change replaces the project.
82 */
83 roleName?: string;
84 /**
85 * Name of the default database created with the project. Defaults to
86 * `neondb` on creation; omission preserves the observed database on adoption
87 * and updates. An explicit change replaces the project.
88 */
89 databaseName?: string;
90 /**
91 * Number of seconds of WAL history retained on the project for
92 * point-in-time branching/restore.
93 *
94 * @default 86400
95 */
96 historyRetentionSeconds?: number;
97 /**
98 * Optional Neon organization ID. Cannot be changed after creation.
99 */
100 orgId?: string;
101 /**
102 * Enable Postgres logical replication on the project. Once enabled,
103 * Neon does not support disabling it again.
104 *
105 * @default false
106 * @see https://neon.tech/docs/guides/logical-replication-neon
107 */
108 enableLogicalReplication?: boolean;
109 /**
110 * SQL migrations to apply against the default branch's primary database.
111 * Accepts a directory path, a `Drizzle.Schema` resource, or
112 * `{ dir, table? }`.
113 *
114 * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table. A
115 * database previously migrated by drizzle-kit or Prisma is adopted by a
116 * one-way conversion on first deploy: the old tool's applied history is
117 * copied into Alchemy's table and the old table is left frozen. No
118 * baselining required.
119 */
120 migrations?: MigrationsInput;
121 /**
122 * Paths to additional `.sql` files to apply after migrations. Each file
123 * is hashed; only files whose contents change are re-applied on
124 * subsequent deploys.
125 */
126 importFiles?: string[];
127};
128
129export type Project = Resource<
130 "Neon.Project",
131 ProjectProps,
132 {
133 /** Neon project identifier. */
134 projectId: string;
135 /** Observed project name. */
136 projectName: string;
137 /** Immutable Postgres region. */
138 region: NeonRegion;
139 /** Immutable Postgres major version. */
140 pgVersion: NeonPgVersion;
141 /** Current default branch identifier. */
142 defaultBranchId: string;
143 /** Current default branch name. */
144 defaultBranchName: string;
145 /** Database selected for connection outputs and migrations. */
146 databaseName: string;
147 /** Observed owner of the selected database. */
148 roleName: string;
149 /** Postgres connection URI for the default branch + database. */
150 connectionUri: string;
151 /** Pooled connection URI (uses pgbouncer). */
152 pooledConnectionUri: string;
153 /**
154 * Parsed connection components ready to feed into a Postgres origin
155 * — e.g. `Cloudflare.Hyperdrive`'s `origin` prop. Points at the
156 * direct (non-pooled) endpoint, which is the recommended target
157 * when fronting Neon with another pooler like Hyperdrive.
158 */
159 origin: PostgresOrigin;
160 /**
161 * Parsed pooled connection components. Useful as a Hyperdrive `dev`
162 * origin when local workers bypass Hyperdrive and connect directly.
163 */
164 pooledOrigin: PostgresOrigin;
165 /** Observed history retention window in seconds. */
166 historyRetentionSeconds: number;
167 /** Whether logical replication is enabled. Once enabled it cannot be disabled. */
168 enableLogicalReplication: boolean;
169 /** Directory of the applied SQL migrations. */
170 migrationsDir: string | undefined;
171 /** Migration bookkeeping table. */
172 migrationsTable: string | undefined;
173 /** Applied migration content hashes. */
174 migrationsHashes: Record<string, string>;
175 /** Applied SQL import content hashes. */
176 importHashes: Record<string, string>;
177 },
178 never,
179 Providers
180>;
181
182type ProjectAttributes = Project["Attributes"];
183
184/**
185 * A Neon serverless Postgres project.
186 *
187 * Creating a project also provisions the project's default branch (named
188 * "main" by default), an initial role, an initial database, and a
189 * read-write compute endpoint, exposed as `connectionUri`.
190 * ### Creating a Project
191 * **Example:** Basic project
192 * ```typescript
193 * const project = yield* Neon.Project("my-project");
194 * ```
195 *
196 * **Example:** Project with explicit region and PG version
197 * ```typescript
198 * const project = yield* Neon.Project("my-project", {
199 * region: "aws-eu-central-1",
200 * pgVersion: 17,
201 * });
202 * ```
203 *
204 * **Example:** Project with logical replication enabled
205 * ```typescript
206 * const project = yield* Neon.Project("my-project", {
207 * enableLogicalReplication: true,
208 * });
209 * ```
210 *
211 * ### Migrations and seed data
212 * **Example:** Apply migrations and seed files
213 * ```typescript
214 * const project = yield* Neon.Project("my-project", {
215 * migrations: "./migrations",
216 * importFiles: ["./seed/users.sql"],
217 * });
218 * ```
219 *
220 * ### Branching
221 * **Example:** Create a branch off the project's default branch
222 * ```typescript
223 * const project = yield* Neon.Project("my-project");
224 * const dev = yield* Neon.Branch("dev-branch", { project });
225 * ```
226 *
227 * @see https://neon.tech/docs/manage/projects/
228 *
229 * @resource
230 * @product Project
231 */
232export const Project = Resource<Project>("Neon.Project");
233
234export const ProjectProvider = () =>
235 Provider.succeed(Project, {
236 stables: ["projectId", "defaultBranchId"],
237 list: Effect.fn(function* () {
238 // Account-scoped collection: enumerate every project via the Neon
239 // projects list API, then hydrate each into the exact `read`
240 // Attributes shape with bounded concurrency.
241 const projects = yield* listAllProjects;
242 const rows = yield* Effect.forEach(
243 projects,
244 (project) =>
245 hydrateProjectAttributes(project).pipe(
246 // A project can be deleted between the list call and
247 // hydration — skip it rather than fail the whole enumeration.
248 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
249 ),
250 { concurrency: 10 },
251 );
252 return rows.filter((row): row is ProjectAttributes => row !== undefined);
253 }),
254 diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) {
255 if (!isResolved(news)) return undefined;
256 const oldName =
257 output?.projectName ?? (yield* createProjectName(id, olds.name));
258 // Preserve generated names; only an explicit name requests a rename.
259 const name = news.name ?? oldName;
260 if (
261 (news.region ?? output?.region ?? DEFAULT_REGION) !==
262 (output?.region ?? olds.region ?? DEFAULT_REGION) ||
263 (news.pgVersion ?? output?.pgVersion ?? DEFAULT_PG_VERSION) !==
264 (output?.pgVersion ?? olds.pgVersion ?? DEFAULT_PG_VERSION) ||
265 (news.defaultBranchName ?? output?.defaultBranchName) !==
266 output?.defaultBranchName ||
267 (news.databaseName ?? output?.databaseName ?? "neondb") !==
268 (output?.databaseName ?? olds.databaseName ?? "neondb") ||
269 (news.roleName ?? output?.roleName ?? "neondb_owner") !==
270 (output?.roleName ?? olds.roleName ?? "neondb_owner") ||
271 news.orgId !== olds.orgId
272 ) {
273 return {
274 action: "replace",
275 deleteFirst: news.name === oldName && news.orgId === olds.orgId,
276 } as const;
277 }
278 if (
279 oldName !== name ||
280 (news.historyRetentionSeconds ?? 86400) !==
281 (output?.historyRetentionSeconds ?? 86400)
282 ) {
283 return { action: "update" } as const;
284 }
285 if (
286 news.enableLogicalReplication !== undefined &&
287 news.enableLogicalReplication !==
288 (output?.enableLogicalReplication ?? false)
289 ) {
290 return { action: "update" } as const;
291 }
292 if (yield* diffMigrations({ news, output })) {
293 return { action: "update" } as const;
294 }
295 if (news.importFiles?.length) {
296 const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
297 if (!recordsEqual(newHashes, output?.importHashes ?? {})) {
298 return { action: "update" } as const;
299 }
300 }
301 return undefined;
302 }),
303 read: Effect.fn(function* ({ id, output, olds }) {
304 if (output?.projectId) {
305 return yield* getProject({ project_id: output.projectId }).pipe(
306 Effect.flatMap(({ project }) =>
307 hydrateProjectAttributes(project, output),
308 ),
309 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
310 );
311 }
312 const name = yield* createProjectName(id, olds?.name);
313 const matches = yield* findProjectByName(name, olds?.orgId);
314 if (matches.length > 1) {
315 return yield* new ProjectStateError({
316 reason: "Ambiguous project name",
317 });
318 }
319 const match = matches[0];
320 if (!match) return undefined;
321 const attrs = yield* hydrateProjectAttributes(match, {
322 defaultBranchName: olds?.defaultBranchName,
323 migrationsDir: (olds && migrationsInputOf(olds))?.dir,
324 migrationsTable: (olds && migrationsInputOf(olds))?.table,
325 });
326 return attrs && Unowned(attrs);
327 }),
328 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
329 const name =
330 news.name ??
331 output?.projectName ??
332 (yield* createProjectName(id, undefined));
333 // Names are not ownership evidence; approval applies only to the observed ID.
334 const authorize = (
335 project: ObservedProject,
336 ): Effect.Effect<ObservedProject, OwnedBySomeoneElse> =>
337 project.id === output?.projectId
338 ? Effect.succeed(project)
339 : Effect.fail(
340 new OwnedBySomeoneElse({
341 message: `Neon project "${name}" requires explicit adoption`,
342 resourceType: "Neon.Project",
343 logicalId: id,
344 physicalName: project.id,
345 }),
346 );
347 let observed: ObservedProject | undefined = output?.projectId
348 ? yield* getProject({ project_id: output.projectId }).pipe(
349 Effect.map(({ project }) => project),
350 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
351 )
352 : undefined;
353 if (!observed) {
354 const matches = yield* findProjectByName(name, news.orgId);
355 if (matches.length > 1) {
356 return yield* new ProjectStateError({
357 reason: "Ambiguous project name",
358 });
359 }
360 observed = matches[0] ? yield* authorize(matches[0]) : undefined;
361 }
362 if (!observed) {
363 const created = yield* sdkCreateProject({
364 project: {
365 name,
366 region_id: news.region ?? DEFAULT_REGION,
367 pg_version: news.pgVersion ?? DEFAULT_PG_VERSION,
368 branch: {
369 name: news.defaultBranchName,
370 role_name: news.roleName,
371 database_name: news.databaseName,
372 },
373 history_retention_seconds: news.historyRetentionSeconds,
374 org_id: news.orgId,
375 settings: news.enableLogicalReplication
376 ? { enable_logical_replication: true }
377 : undefined,
378 },
379 }).pipe(
380 Effect.catchTag("Conflict", (error) =>
381 findProjectByName(name, news.orgId).pipe(
382 Effect.flatMap(
383 Effect.fn(function* (matches) {
384 if (matches.length !== 1) return yield* Effect.fail(error);
385 const project = yield* authorize(matches[0]!);
386 return { project, operations: [] };
387 }),
388 ),
389 ),
390 ),
391 );
392 yield* waitForOperations(created.operations);
393 observed = created.project;
394 }
395 const replication =
396 observed.settings?.enable_logical_replication === true;
397 if (replication && news.enableLogicalReplication === false) {
398 return yield* new ProjectStateError({
399 reason: "Neon logical replication cannot be disabled once enabled",
400 });
401 }
402 const retention = news.historyRetentionSeconds ?? 86400;
403 if (
404 observed.name !== name ||
405 observed.history_retention_seconds !== retention ||
406 (news.enableLogicalReplication === true && !replication)
407 ) {
408 const updated = yield* updateProject({
409 project_id: observed.id,
410 project: {
411 name: observed.name !== name ? name : undefined,
412 history_retention_seconds:
413 observed.history_retention_seconds !== retention
414 ? retention
415 : undefined,
416 settings:
417 news.enableLogicalReplication === true && !replication
418 ? { enable_logical_replication: true }
419 : undefined,
420 },
421 });
422 yield* waitForOperations(updated.operations);
423 }
424 const current = yield* getProject({ project_id: observed.id });
425 const projectInfo = yield* hydrateProjectAttributes(current.project, {
426 defaultBranchName: news.defaultBranchName,
427 databaseName:
428 news.databaseName ??
429 (output?.projectId === observed.id ? output.databaseName : undefined),
430 });
431 if (!projectInfo) {
432 return yield* new ProjectStateError({
433 reason: "Project has no default branch or database",
434 });
435 }
436 const previous =
437 projectInfo.projectId === output?.projectId ? output : undefined;
438
439 const connectionUri = Redacted.make(projectInfo.connectionUri);
440 const migrationsInput = migrationsInputOf(news);
441 const migrations = migrationsInput
442 ? yield* runPgMigrations({
443 connectionUri,
444 input: migrationsInput,
445 stamped: stampedOf(previous),
446 })
447 : undefined;
448 const importHashes = news.importFiles?.length
449 ? yield* runImports(
450 connectionUri,
451 news.importFiles,
452 yield* rootDir,
453 previous?.importHashes ?? {},
454 )
455 : {};
456
457 return {
458 ...projectInfo,
459 ...migrationsAttrs({
460 input: migrationsInput,
461 run: migrations,
462 output: previous,
463 }),
464 importHashes,
465 };
466 }),
467 delete: Effect.fn(function* ({ output }) {
468 yield* deleteProject({ project_id: output.projectId }).pipe(
469 Effect.catchTag("NotFound", () => Effect.void),
470 );
471 yield* getProject({ project_id: output.projectId }).pipe(
472 Effect.flatMap(() =>
473 Effect.fail(new DeletionPending({ resourceId: output.projectId })),
474 ),
475 Effect.catchTag("NotFound", () => Effect.void),
476 Effect.retry({
477 while: (error) => error._tag === "NeonDeletionPending",
478 schedule: Schedule.spaced("2 seconds"),
479 times: 8,
480 }),
481 Effect.timeout("20 seconds"),
482 );
483 }),
484 });
485
486const rootDir = Effect.sync(process.cwd);
487
488const createProjectName = (id: string, name: string | undefined) =>
489 Effect.gen(function* () {
490 return name ?? (yield* createPhysicalName({ id }));
491 });
492
493const resolveConnection = (
494 projectId: string,
495 branchId: string,
496 databaseName: string,
497 roleName: string,
498) =>
499 Effect.gen(function* () {
500 const direct = yield* getConnectionURI({
501 project_id: projectId,
502 branch_id: branchId,
503 database_name: databaseName,
504 role_name: roleName,
505 pooled: false,
506 });
507 const pooled = yield* getConnectionURI({
508 project_id: projectId,
509 branch_id: branchId,
510 database_name: databaseName,
511 role_name: roleName,
512 pooled: true,
513 });
514 return { uri: direct.uri, pooled: pooled.uri };
515 });
516
517export class ProjectStateError extends Data.TaggedError(
518 "NeonProjectStateError",
519)<{
520 reason: string;
521}> {}
522
523export class DeletionPending extends Data.TaggedError("NeonDeletionPending")<{
524 resourceId: string;
525}> {}
526
527export class OperationFailed extends Data.TaggedError("NeonOperationFailed")<{
528 operationId: string;
529 action: string;
530 status: string;
531}> {}
532
533export class OperationPending extends Data.TaggedError("NeonOperationPending")<{
534 operationId: string;
535 status: string;
536}> {}
537
538type PendingOperation = {
539 readonly id: string;
540 readonly project_id: string;
541 readonly action: string;
542 readonly status: string;
543};
544
545const checkOperation = (
546 op: PendingOperation,
547): Effect.Effect<void, OperationFailed | OperationPending> => {
548 if (op.status === "finished" || op.status === "skipped") return Effect.void;
549 if (
550 op.status === "failed" ||
551 op.status === "error" ||
552 op.status === "cancelled"
553 ) {
554 return Effect.fail(
555 new OperationFailed({
556 operationId: op.id,
557 action: op.action,
558 status: op.status,
559 }),
560 );
561 }
562 return Effect.fail(
563 new OperationPending({ operationId: op.id, status: op.status }),
564 );
565};
566
567/** Wait at most 55 seconds; pending, cancelled and failed operations never succeed. */
568export const waitForOperations = (
569 operations: ReadonlyArray<PendingOperation>,
570) =>
571 Effect.forEach(
572 operations,
573 (op) =>
574 checkOperation(op).pipe(
575 Effect.catchTag("NeonOperationPending", () =>
576 getProjectOperation({
577 project_id: op.project_id,
578 operation_id: op.id,
579 }).pipe(
580 Effect.flatMap(({ operation }) => checkOperation(operation)),
581 Effect.retry({
582 while: (error) => error._tag === "NeonOperationPending",
583 schedule: Schedule.spaced("5 seconds"),
584 times: 8,
585 }),
586 ),
587 ),
588 ),
589 { concurrency: 10, discard: true },
590 ).pipe(Effect.timeout("55 seconds"));
591
592const findProjectByName = (name: string, orgId?: string) =>
593 Effect.gen(function* () {
594 const matches: ListProjectsResponse["projects"][number][] = [];
595 let cursor: string | undefined;
596 while (true) {
597 const page = yield* listProjects({
598 search: name,
599 ...(orgId !== undefined ? { org_id: orgId } : {}),
600 ...(cursor !== undefined ? { cursor } : {}),
601 });
602 for (const p of page.projects) {
603 if (p.name === name) matches.push(p);
604 }
605 const nextCursor = page.pagination?.cursor;
606 // Neon returns a `pagination.cursor` on every response — it's the
607 // `created_at` of the last row, not a "has next page" flag — so we
608 // can't loop on cursor presence alone or we spin forever re-fetching
609 // empty/identical pages. Stop once a page comes back empty or the
610 // cursor stops advancing.
611 if (
612 page.projects.length === 0 ||
613 nextCursor === undefined ||
614 nextCursor === cursor
615 ) {
616 break;
617 }
618 cursor = nextCursor;
619 }
620 return matches;
621 });
622
623/**
624 * Exhaustively enumerate every project in the account. Uses the same
625 * cursor-stop heuristic as {@link findProjectByName} because Neon returns a
626 * `pagination.cursor` on every page (it's the last row's `created_at`, not a
627 * "has next page" flag), so we'd otherwise loop forever re-fetching.
628 */
629const listAllProjects = Effect.gen(function* () {
630 const projects: ListProjectsResponse["projects"][number][] = [];
631 let cursor: string | undefined;
632 while (true) {
633 const page = yield* listProjects(cursor !== undefined ? { cursor } : {});
634 projects.push(...page.projects);
635 const nextCursor = page.pagination?.cursor;
636 if (
637 page.projects.length === 0 ||
638 nextCursor === undefined ||
639 nextCursor === cursor
640 ) {
641 break;
642 }
643 cursor = nextCursor;
644 }
645 return projects;
646});
647
648type ObservedProject = NeonProject | ListProjectsResponse["projects"][number];
649
650/**
651 * Hydrate a project summary (from the list API) into the exact `read`
652 * Attributes shape — resolving the default branch, its primary database, and
653 * the direct + pooled connection URIs. Returns `undefined` when the project
654 * has no branch or database yet (mirrors `read`).
655 */
656const hydrateProjectAttributes = (
657 project: ObservedProject,
658 opts: {
659 defaultBranchName?: string;
660 databaseName?: string;
661 migrationsDir?: string;
662 migrationsTable?: string;
663 migrationsHashes?: Record<string, string>;
664 importHashes?: Record<string, string>;
665 } = {},
666) =>
667 Effect.gen(function* () {
668 const branches: import("@distilled.cloud/neon").Branch[] = [];
669 let cursor: string | undefined;
670 do {
671 const page = yield* listProjectBranches({
672 project_id: project.id,
673 cursor,
674 });
675 branches.push(...page.branches);
676 const next = page.pagination?.next;
677 if (!next || next === cursor || page.branches.length === 0) break;
678 cursor = next;
679 } while (cursor);
680 const defaultBranch = branches.find((b) => b.default);
681 if (!defaultBranch) return undefined;
682 const databases = yield* listProjectBranchDatabases({
683 project_id: project.id,
684 branch_id: defaultBranch.id,
685 });
686 const db = opts.databaseName
687 ? databases.databases.find((db) => db.name === opts.databaseName)
688 : databases.databases[0];
689 if (!db) return undefined;
690 const conn = yield* resolveConnection(
691 project.id,
692 defaultBranch.id,
693 db.name,
694 db.owner_name,
695 );
696 return {
697 projectId: project.id,
698 projectName: project.name,
699 region: project.region_id as NeonRegion,
700 pgVersion: project.pg_version as NeonPgVersion,
701 defaultBranchId: defaultBranch.id,
702 defaultBranchName: defaultBranch.name,
703 databaseName: db.name,
704 roleName: db.owner_name,
705 connectionUri: conn.uri,
706 pooledConnectionUri: conn.pooled,
707 origin: parsePostgresOrigin(conn.uri),
708 pooledOrigin: parsePostgresOrigin(conn.pooled),
709 historyRetentionSeconds: project.history_retention_seconds ?? 86400,
710 enableLogicalReplication:
711 project.settings?.enable_logical_replication === true,
712 migrationsDir: opts.migrationsDir,
713 migrationsTable: opts.migrationsTable,
714 migrationsHashes: opts.migrationsHashes ?? {},
715 importHashes: opts.importHashes ?? {},
716 } satisfies ProjectAttributes;
717 });
718
719const runImports = (
720 connectionUri: Redacted.Redacted<string>,
721 importFiles: ReadonlyArray<string>,
722 rootDir: string,
723 previous: Record<string, string>,
724) =>
725 Effect.gen(function* () {
726 const hashes: Record<string, string> = { ...previous };
727 for (const filePath of importFiles) {
728 const file = yield* readSqlFile(rootDir, filePath);
729 if (previous[filePath] === file.hash) {
730 hashes[filePath] = file.hash;
731 continue;
732 }
733 yield* runSql(connectionUri, file.sql);
734 hashes[filePath] = file.hash;
735 }
736 const tracked = new Set(importFiles);
737 for (const key of Object.keys(hashes)) {
738 if (!tracked.has(key)) delete hashes[key];
739 }
740 return hashes;
741 });