@pgpmjs/core 7.13.0 → 7.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,10 @@ const parse_package_name_1 = require("parse-package-name");
48
48
  const path_1 = __importStar(require("path"));
49
49
  const pg_cache_1 = require("pg-cache");
50
50
  const yanse_1 = __importDefault(require("yanse"));
51
+ const materialize_1 = require("../../apply/materialize");
52
+ const apply_spec_1 = require("../../apply/apply-spec");
53
+ const reuse_1 = require("../../apply/reuse");
54
+ const types_2 = require("../../apply/types");
51
55
  const extensions_1 = require("../../extensions/extensions");
52
56
  const files_1 = require("@pgpmjs/ast/files");
53
57
  const files_2 = require("@pgpmjs/ast/files");
@@ -57,7 +61,7 @@ const parser_1 = require("@pgpmjs/ast/files/plan/parser");
57
61
  const validators_1 = require("@pgpmjs/ast/files/plan/validators");
58
62
  const client_1 = require("../../migrate/client");
59
63
  const modules_1 = require("../../modules/modules");
60
- const package_1 = require("../../packaging/package");
64
+ const deploy_bundled_1 = require("../../bundle/deploy-bundled");
61
65
  const sync_versions_1 = require("../../packaging/sync-versions");
62
66
  const deps_1 = require("../../resolution/deps");
63
67
  const fs_2 = require("../../utils/fs");
@@ -281,11 +285,61 @@ class PgpmPackage {
281
285
  }
282
286
  });
283
287
  // Parse the selected control files
284
- return Array.from(selectedFiles.entries()).reduce((acc, [moduleName, file]) => {
288
+ const moduleMap = Array.from(selectedFiles.entries()).reduce((acc, [moduleName, file]) => {
285
289
  const module = (0, files_2.parseControlFile)(file, this.workspacePath);
286
290
  acc[moduleName] = module;
287
291
  return acc;
288
292
  }, {});
293
+ return this.addApplyModules(moduleMap, packageDirs);
294
+ }
295
+ /**
296
+ * Discover apply-spec proxy modules (`pgpm.apply.json`) and synthesize
297
+ * module-map entries for them so they participate in dependency resolution
298
+ * by name like any other module. A proxy needs no `.control` or `pgpm.plan`;
299
+ * its `requires` default to the source module's requires. Control-file
300
+ * modules win name collisions.
301
+ */
302
+ addApplyModules(moduleMap, packageDirs) {
303
+ const specFiles = [...new Set(packageDirs.flatMap(dir => glob.sync((0, glob_1.globPattern)(dir, `**/${types_2.APPLY_SPEC_FILE}`))))].filter((file) => !/node_modules/.test(file)).sort((a, b) => a.localeCompare(b));
304
+ for (const file of specFiles) {
305
+ let spec;
306
+ try {
307
+ spec = (0, apply_spec_1.readApplySpec)(path_1.default.dirname(file));
308
+ }
309
+ catch (err) {
310
+ console.warn(`Skipping invalid apply spec ${file}: ${err instanceof Error ? err.message : err}`);
311
+ continue;
312
+ }
313
+ const name = spec.name;
314
+ if (moduleMap[name])
315
+ continue;
316
+ const source = moduleMap[spec.source.module];
317
+ const relPath = path_1.default
318
+ .dirname(path_1.default.relative(this.workspacePath, file))
319
+ .replace(/\\/g, '/');
320
+ const baseRequires = spec.requires ?? source?.requires ?? [];
321
+ const version = spec.version ?? source?.version ?? '0.0.1';
322
+ if ((0, reuse_1.isReuseSpec)(spec)) {
323
+ // A reuse proxy expands into two modules that both materialize from
324
+ // this one directory: a shared module (deployed once, reused across
325
+ // tenants) and the per-tenant module that `requires` it. Multiple
326
+ // tenant proxies over the same source/shared/seed config resolve to
327
+ // the same deterministic shared name, so it is added at most once.
328
+ const sharedName = (0, reuse_1.resolveSharedModuleName)(spec);
329
+ if (!moduleMap[sharedName]) {
330
+ moduleMap[sharedName] = { path: relPath, requires: baseRequires, version };
331
+ }
332
+ moduleMap[name] = {
333
+ path: relPath,
334
+ requires: [...baseRequires, sharedName],
335
+ version
336
+ };
337
+ }
338
+ else {
339
+ moduleMap[name] = { path: relPath, requires: baseRequires, version };
340
+ }
341
+ }
342
+ return moduleMap;
289
343
  }
290
344
  getModuleMap() {
291
345
  if (!this.workspacePath)
@@ -1235,6 +1289,38 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1235
1289
  external: external
1236
1290
  };
1237
1291
  }
1292
+ /**
1293
+ * Dependency closure for a named module. Proxy (apply-spec) modules have no
1294
+ * pgpm.plan, so they resolve straight off the workspace module map instead
1295
+ * of instantiating a module-rooted package.
1296
+ */
1297
+ getModuleExtensionsFor(name) {
1298
+ const modules = this.getModuleMap();
1299
+ if (!modules[name]) {
1300
+ throw types_1.errors.MODULE_NOT_FOUND({ name });
1301
+ }
1302
+ const modulePath = (0, path_1.resolve)(this.workspacePath, modules[name].path);
1303
+ if ((0, apply_spec_1.hasApplySpec)(modulePath)) {
1304
+ return (0, deps_1.resolveExtensionDependencies)(name, modules);
1305
+ }
1306
+ return this.getModuleProject(name).getModuleExtensions();
1307
+ }
1308
+ /**
1309
+ * Directory the migration engine should run against for a named module:
1310
+ * the module's own directory, or — for proxy (apply-spec) modules — the
1311
+ * materialized transpiled instance.
1312
+ */
1313
+ async getEffectiveModulePathFor(name) {
1314
+ const modules = this.getModuleMap();
1315
+ if (!modules[name]) {
1316
+ throw types_1.errors.MODULE_NOT_FOUND({ name });
1317
+ }
1318
+ const modulePath = (0, path_1.resolve)(this.workspacePath, modules[name].path);
1319
+ if ((0, apply_spec_1.hasApplySpec)(modulePath)) {
1320
+ return (0, materialize_1.resolveEffectiveModulePath)(name, modulePath, modules, this.workspacePath);
1321
+ }
1322
+ return this.getModuleProject(name).getModulePath();
1323
+ }
1238
1324
  parsePackageTarget(target) {
1239
1325
  let name;
1240
1326
  let toChange;
@@ -1266,12 +1352,6 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1266
1352
  const log = new logger_1.Logger('deploy');
1267
1353
  const { name, toChange } = this.parsePackageTarget(target);
1268
1354
  if (recursive) {
1269
- // Cache for fast deployment
1270
- const deployFastCache = {};
1271
- const getCacheKey = (pg, name, database) => {
1272
- const { host, port, user } = pg ?? {};
1273
- return `${host}:${port}:${user}:${database}:${name}`;
1274
- };
1275
1355
  const modules = this.getModuleMap();
1276
1356
  let extensions;
1277
1357
  if (name === null) {
@@ -1279,8 +1359,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1279
1359
  extensions = await this.resolveWorkspaceExtensionDependencies();
1280
1360
  }
1281
1361
  else {
1282
- const moduleProject = this.getModuleProject(name);
1283
- extensions = moduleProject.getModuleExtensions();
1362
+ extensions = this.getModuleExtensionsFor(name);
1284
1363
  }
1285
1364
  const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
1286
1365
  const targetDescription = name === null ? 'all modules' : name;
@@ -1293,52 +1372,24 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1293
1372
  await pgPool.query(msg);
1294
1373
  }
1295
1374
  else {
1296
- const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path);
1375
+ const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
1297
1376
  log.info(`📂 Deploying local module: ${extension}`);
1298
- if (opts.deployment.fast) {
1299
- const localProject = this.getModuleProject(extension);
1300
- const cacheKey = getCacheKey(opts.pg, extension, opts.pg.database);
1301
- if (opts.deployment.cache && deployFastCache[cacheKey]) {
1302
- log.warn(`⚡ Using cached pkg for ${extension}.`);
1303
- await pgPool.query(deployFastCache[cacheKey].sql);
1377
+ if (opts.deployment.fast || opts.deployment.bundled) {
1378
+ // Fast strategy: one-shot execution plus a bulk-written
1379
+ // pgpm_migrate ledger, from the module's verified bundle artifact
1380
+ // when it has one and from `deploy/` when it does not.
1381
+ const outcome = await (0, deploy_bundled_1.deployModuleFast)(modulePath, {
1382
+ config: opts.pg,
1383
+ logOnly: opts.deployment.logOnly,
1384
+ useTransaction: opts.deployment.useTx,
1385
+ hashMethod: opts.deployment.hashMethod
1386
+ });
1387
+ if ((0, deploy_bundled_1.isBundledDeployResult)(outcome)) {
1304
1388
  continue;
1305
1389
  }
1306
- let pkg;
1307
- try {
1308
- pkg = await (0, package_1.packageModule)(localProject.modulePath, {
1309
- usePlan: opts.deployment.usePlan,
1310
- extension: false
1311
- });
1312
- }
1313
- catch (err) {
1314
- const errorLines = [];
1315
- errorLines.push(`❌ Failed to package module "${extension}" at path: ${modulePath}`);
1316
- errorLines.push(` Module Path: ${modulePath}`);
1317
- errorLines.push(` Workspace Path: ${this.workspacePath}`);
1318
- errorLines.push(` Error Code: ${err.code || 'N/A'}`);
1319
- errorLines.push(` Error Message: ${err.message || 'Unknown error'}`);
1320
- if (err.code === 'ENOENT') {
1321
- errorLines.push('💡 Hint: File or directory not found. Check if the module path is correct.');
1322
- }
1323
- else if (err.code === 'EACCES') {
1324
- errorLines.push('💡 Hint: Permission denied. Check file permissions.');
1325
- }
1326
- else if (err.message && err.message.includes('pgpm.plan')) {
1327
- errorLines.push('💡 Hint: pgpm.plan file issue. Check if the plan file exists and is valid.');
1328
- }
1329
- log.error(errorLines.join('\n'));
1330
- console.error(err);
1331
- throw types_1.errors.DEPLOYMENT_FAILED({
1332
- type: 'Deployment',
1333
- module: extension
1334
- });
1335
- }
1336
- await pgPool.query(pkg.sql);
1337
- if (opts.deployment.cache) {
1338
- deployFastCache[cacheKey] = pkg;
1339
- }
1390
+ log.info(`↩️ Fast deploy unavailable for ${extension} (${outcome}); using per-change path.`);
1340
1391
  }
1341
- else {
1392
+ {
1342
1393
  try {
1343
1394
  const client = new client_1.PgpmMigrate(opts.pg);
1344
1395
  // Only apply toChange to the target module, not its dependencies
@@ -1374,8 +1425,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1374
1425
  if (name === null) {
1375
1426
  throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'deployment' });
1376
1427
  }
1377
- const moduleProject = this.getModuleProject(name);
1378
- const modulePath = moduleProject.getModulePath();
1428
+ const modulePath = await this.getEffectiveModulePathFor(name);
1379
1429
  if (!modulePath) {
1380
1430
  throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' });
1381
1431
  }
@@ -1442,7 +1492,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1442
1492
  }
1443
1493
  }
1444
1494
  else {
1445
- const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path);
1495
+ const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
1446
1496
  log.info(`📂 Reverting local module: ${extension}`);
1447
1497
  try {
1448
1498
  const client = new client_1.PgpmMigrate(opts.pg);
@@ -1475,8 +1525,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1475
1525
  if (name === null) {
1476
1526
  throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'revert' });
1477
1527
  }
1478
- const moduleProject = this.getModuleProject(name);
1479
- const modulePath = moduleProject.getModulePath();
1528
+ const modulePath = await this.getEffectiveModulePathFor(name);
1480
1529
  if (!modulePath) {
1481
1530
  throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' });
1482
1531
  }
@@ -1503,8 +1552,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1503
1552
  extensions = await this.resolveWorkspaceExtensionDependencies();
1504
1553
  }
1505
1554
  else {
1506
- const moduleProject = this.getModuleProject(name);
1507
- extensions = moduleProject.getModuleExtensions();
1555
+ extensions = this.getModuleExtensionsFor(name);
1508
1556
  }
1509
1557
  const pgPool = (0, pg_cache_1.getPgPool)(opts.pg);
1510
1558
  const targetDescription = name === null ? 'all modules' : name;
@@ -1517,7 +1565,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1517
1565
  await pgPool.query(query, [extension]);
1518
1566
  }
1519
1567
  else {
1520
- const modulePath = (0, path_1.resolve)(this.workspacePath, modules[extension].path);
1568
+ const modulePath = await (0, materialize_1.resolveEffectiveModulePath)(extension, (0, path_1.resolve)(this.workspacePath, modules[extension].path), modules, this.workspacePath);
1521
1569
  log.info(`📂 Verifying local module: ${extension}`);
1522
1570
  try {
1523
1571
  const client = new client_1.PgpmMigrate(opts.pg);
@@ -1549,8 +1597,7 @@ ${dependencies.length > 0 ? dependencies.map(dep => `-- requires: ${dep}`).join(
1549
1597
  if (name === null) {
1550
1598
  throw types_1.errors.WORKSPACE_OPERATION_ERROR({ operation: 'verification' });
1551
1599
  }
1552
- const moduleProject = this.getModuleProject(name);
1553
- const modulePath = moduleProject.getModulePath();
1600
+ const modulePath = await this.getEffectiveModulePathFor(name);
1554
1601
  if (!modulePath) {
1555
1602
  throw types_1.errors.PATH_NOT_FOUND({ path: name, type: 'module' });
1556
1603
  }
@@ -0,0 +1,160 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { basename, dirname, join } from 'path';
3
+ import { APPLY_SPEC_FILE } from './types';
4
+ /** Whether a module directory is an apply-spec proxy module. */
5
+ export function hasApplySpec(moduleDir) {
6
+ return existsSync(join(moduleDir, APPLY_SPEC_FILE));
7
+ }
8
+ /**
9
+ * Read, validate, and normalize a proxy module's `pgpm.apply.json`.
10
+ *
11
+ * Normalizations: `source` is expanded to its object form and `name` defaults
12
+ * to the proxy directory's name.
13
+ *
14
+ * @throws when the file is missing, unparsable, or structurally invalid.
15
+ */
16
+ export function readApplySpec(moduleDir) {
17
+ const specPath = join(moduleDir, APPLY_SPEC_FILE);
18
+ if (!existsSync(specPath)) {
19
+ throw new Error(`No ${APPLY_SPEC_FILE} found in ${moduleDir}`);
20
+ }
21
+ return parseApplySpec(readFileSync(specPath, 'utf-8'), specPath);
22
+ }
23
+ /** In-memory core of {@link readApplySpec}. */
24
+ export function parseApplySpec(content, specPath) {
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(content);
28
+ }
29
+ catch (err) {
30
+ throw new Error(`Failed to parse ${specPath}: ${err instanceof Error ? err.message : err}`);
31
+ }
32
+ const source = typeof parsed?.source === 'string' ? { module: parsed.source } : parsed?.source;
33
+ if (!source || typeof source.module !== 'string' || !source.module) {
34
+ throw new Error(`${specPath}: "source" (source module name, or { "module": ... }) is required`);
35
+ }
36
+ const isSchemaMap = (value) => typeof value === 'object' &&
37
+ value !== null &&
38
+ !Array.isArray(value) &&
39
+ Object.keys(value).length > 0 &&
40
+ Object.entries(value).every(([from, to]) => typeof from === 'string' && typeof to === 'string' && !!from && !!to);
41
+ const hasSchemas = parsed.schemas !== undefined;
42
+ if (hasSchemas && !isSchemaMap(parsed.schemas)) {
43
+ throw new Error(`${specPath}: "schemas" must be a non-empty string → string map`);
44
+ }
45
+ const ROUTE_KINDS = ['table', 'view', 'function', 'procedure', 'type'];
46
+ const hasRoute = parsed.route !== undefined;
47
+ if (hasRoute) {
48
+ if (!Array.isArray(parsed.route) || parsed.route.length === 0) {
49
+ throw new Error(`${specPath}: "route" must be a non-empty array of route entries`);
50
+ }
51
+ for (const entry of parsed.route) {
52
+ if (!entry ||
53
+ typeof entry !== 'object' ||
54
+ typeof entry.fromSchema !== 'string' ||
55
+ !entry.fromSchema ||
56
+ typeof entry.name !== 'string' ||
57
+ !entry.name ||
58
+ typeof entry.toSchema !== 'string' ||
59
+ !entry.toSchema ||
60
+ !ROUTE_KINDS.includes(entry.kind)) {
61
+ throw new Error(`${specPath}: each "route" entry needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name, toSchema } as non-empty strings`);
62
+ }
63
+ }
64
+ }
65
+ const hasReuse = parsed.reuse !== undefined;
66
+ if (hasReuse) {
67
+ const reuse = parsed.reuse;
68
+ if (typeof reuse !== 'object' || reuse === null || Array.isArray(reuse)) {
69
+ throw new Error(`${specPath}: "reuse" must be an object`);
70
+ }
71
+ if (!isSchemaMap(reuse.sharedSchema)) {
72
+ throw new Error(`${specPath}: "reuse.sharedSchema" must be a non-empty string → string map`);
73
+ }
74
+ if (!Array.isArray(reuse.perTenant) || reuse.perTenant.length === 0) {
75
+ throw new Error(`${specPath}: "reuse.perTenant" must be a non-empty array of seed objects`);
76
+ }
77
+ for (const seed of reuse.perTenant) {
78
+ if (!seed ||
79
+ typeof seed !== 'object' ||
80
+ typeof seed.fromSchema !== 'string' ||
81
+ !seed.fromSchema ||
82
+ typeof seed.name !== 'string' ||
83
+ !seed.name ||
84
+ !ROUTE_KINDS.includes(seed.kind)) {
85
+ throw new Error(`${specPath}: each "reuse.perTenant" seed needs { fromSchema, kind (${ROUTE_KINDS.join('|')}), name } as non-empty strings`);
86
+ }
87
+ }
88
+ if (reuse.sharedName !== undefined && (typeof reuse.sharedName !== 'string' || !reuse.sharedName)) {
89
+ throw new Error(`${specPath}: "reuse.sharedName" must be a non-empty string`);
90
+ }
91
+ if (!hasSchemas) {
92
+ throw new Error(`${specPath}: "reuse" requires "schemas" (the per-tenant source → target schema map)`);
93
+ }
94
+ for (const seed of reuse.perTenant) {
95
+ if (!(seed.fromSchema in parsed.schemas)) {
96
+ throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no per-tenant ` +
97
+ `target in "schemas"`);
98
+ }
99
+ if (!(seed.fromSchema in reuse.sharedSchema)) {
100
+ throw new Error(`${specPath}: "reuse.perTenant" seed schema "${seed.fromSchema}" has no shared ` +
101
+ `target in "reuse.sharedSchema"`);
102
+ }
103
+ }
104
+ }
105
+ const hasExtensions = parsed.extensions !== undefined;
106
+ if (hasExtensions) {
107
+ const ext = parsed.extensions;
108
+ if (typeof ext !== 'object' || ext === null || Array.isArray(ext)) {
109
+ throw new Error(`${specPath}: "extensions" must be an object`);
110
+ }
111
+ const isSchemaOrNull = (v) => v === null || (typeof v === 'string' && !!v);
112
+ const hasRoutes = ext.routes !== undefined;
113
+ if (hasRoutes) {
114
+ if (typeof ext.routes !== 'object' || ext.routes === null || Array.isArray(ext.routes)) {
115
+ throw new Error(`${specPath}: "extensions.routes" must be an object keyed by extension name`);
116
+ }
117
+ for (const [name, route] of Object.entries(ext.routes)) {
118
+ if (!route || typeof route !== 'object' || !isSchemaOrNull(route.to)) {
119
+ throw new Error(`${specPath}: "extensions.routes.${name}" must be { to: schema | null, from?: (string|null)[] }`);
120
+ }
121
+ }
122
+ }
123
+ else if (!('toSchema' in ext)) {
124
+ throw new Error(`${specPath}: "extensions" needs "toSchema" (schema | null) or "routes"`);
125
+ }
126
+ else if (!isSchemaOrNull(ext.toSchema)) {
127
+ throw new Error(`${specPath}: "extensions.toSchema" must be a non-empty string or null`);
128
+ }
129
+ if (ext.only !== undefined && (!Array.isArray(ext.only) || ext.only.some((e) => typeof e !== 'string' || !e))) {
130
+ throw new Error(`${specPath}: "extensions.only" must be an array of extension names`);
131
+ }
132
+ if (ext.serverVersion !== undefined && typeof ext.serverVersion !== 'number') {
133
+ throw new Error(`${specPath}: "extensions.serverVersion" must be a number`);
134
+ }
135
+ }
136
+ const hasRoles = parsed.roles !== undefined;
137
+ if (hasRoles && !isSchemaMap(parsed.roles)) {
138
+ throw new Error(`${specPath}: "roles" must be a non-empty string → string map`);
139
+ }
140
+ if (!hasSchemas && !hasRoute && !hasExtensions && !hasRoles) {
141
+ throw new Error(`${specPath}: at least one of "schemas", "route", "extensions", or "roles" is required`);
142
+ }
143
+ const name = parsed.name ?? basename(dirname(specPath));
144
+ if (typeof name !== 'string' || !name) {
145
+ throw new Error(`${specPath}: "name" must be a non-empty string`);
146
+ }
147
+ if (name === source.module) {
148
+ throw new Error(`${specPath}: apply module "${name}" cannot apply itself`);
149
+ }
150
+ const requires = parsed.requires;
151
+ if (requires !== undefined &&
152
+ (!Array.isArray(requires) || requires.some((r) => typeof r !== 'string' || !r))) {
153
+ throw new Error(`${specPath}: "requires" must be an array of module names`);
154
+ }
155
+ if (requires?.includes(source.module)) {
156
+ throw new Error(`${specPath}: "requires" must not include the source module "${source.module}" — ` +
157
+ `it is source material, not a runtime dependency`);
158
+ }
159
+ return { ...parsed, name, source, requires };
160
+ }
@@ -0,0 +1,4 @@
1
+ export * from './materialize';
2
+ export * from './apply-spec';
3
+ export * from './reuse';
4
+ export * from './types';
@@ -0,0 +1,129 @@
1
+ import { mkdtempSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join, resolve } from 'path';
4
+ import { bundleFromModule, materializeBundle, transpileBundle, verifyBundle } from '@pgpmjs/bundle';
5
+ import { loadModule, makeSchemaTranspiler } from '@pgpmjs/transform';
6
+ import { hasApplySpec, readApplySpec } from './apply-spec';
7
+ import { isReuseSpec, materializeReuseModule, resolveSharedModuleName } from './reuse';
8
+ // Functions that take a bare schema name as a string-literal first argument.
9
+ // The AST pass only remaps dotted literals ('schema.object'); these helpers
10
+ // (ubiquitous in verify scripts) name the schema alone, so a string-level
11
+ // pre-pass remaps them.
12
+ const SCHEMA_NAME_LITERAL_FUNCS = ['verify_schema', 'has_schema_privilege'];
13
+ const schemaNameLiteralPass = (content, schemaMapping) => {
14
+ const pattern = new RegExp(`\\b(${SCHEMA_NAME_LITERAL_FUNCS.join('|')})\\s*\\(\\s*'([^']+)'`, 'gi');
15
+ return content.replace(pattern, (match, _fn, schema) => {
16
+ const mapped = schemaMapping.get(schema);
17
+ return mapped ? match.replace(`'${schema}'`, `'${mapped}'`) : match;
18
+ });
19
+ };
20
+ /**
21
+ * Transpile a source module per an apply spec and materialize the result as a
22
+ * deployable module directory carrying the instance's identity.
23
+ *
24
+ * Pure pipeline over existing primitives: `bundleFromModule` →
25
+ * `transpileBundle(makeSchemaTranspiler)` → `verifyBundle` →
26
+ * `materializeBundle`. Deterministic — identical source + spec always produce
27
+ * an identical bundle (and digest), which is what makes verify/revert able to
28
+ * re-derive the exact deployed artifact.
29
+ */
30
+ export async function materializeApplyModule(options) {
31
+ const { sourceDir, spec } = options;
32
+ const instanceName = spec.name;
33
+ // the schema transpiler's SQL/PLpgSQL parser is WASM-backed and needs a
34
+ // one-time async init (idempotent)
35
+ await loadModule();
36
+ const source = bundleFromModule(sourceDir);
37
+ if (spec.source.bundleDigest && spec.source.bundleDigest !== source.manifest.digest) {
38
+ throw new Error(`Apply spec for "${instanceName}" pins source bundle digest ${spec.source.bundleDigest}, ` +
39
+ `but the installed source "${spec.source.module}" hashes to ${source.manifest.digest}. ` +
40
+ `Reinstall the pinned version or update the spec.`);
41
+ }
42
+ // Every distinct target schema is treated as "may already exist": apply
43
+ // deploys into consumer-chosen namespaces that can pre-exist (a shared schema,
44
+ // another tenant), so `CREATE SCHEMA` is emitted idempotently. This is what
45
+ // lets the same source apply cleanly whether or not the destination exists.
46
+ const targetSchemas = [
47
+ ...new Set([
48
+ ...Object.values(spec.schemas ?? {}),
49
+ ...(spec.route ?? []).map(r => r.toSchema)
50
+ ])
51
+ ];
52
+ const { renameChange, transformScript, result } = makeSchemaTranspiler({
53
+ schemaMap: spec.schemas,
54
+ routes: spec.route,
55
+ extensions: spec.extensions,
56
+ roles: spec.roles,
57
+ transform: {
58
+ prePasses: [schemaNameLiteralPass],
59
+ assumeSchemasExist: targetSchemas
60
+ }
61
+ });
62
+ const transpiled = transpileBundle(source, {
63
+ renameChange,
64
+ transformScript,
65
+ renameModule: instanceName,
66
+ provenance: {
67
+ appliedFrom: spec.source.module,
68
+ ...(spec.source.package ? { sourcePackage: spec.source.package } : {}),
69
+ ...(spec.source.version ? { sourceVersion: spec.source.version } : {})
70
+ }
71
+ });
72
+ if (result.errors.length > 0) {
73
+ const detail = result.errors.map(e => `${e.file}: ${e.error}`).join('; ');
74
+ throw new Error(`Apply transpile of "${spec.source.module}" as "${instanceName}" failed: ${detail}`);
75
+ }
76
+ const issues = verifyBundle(transpiled);
77
+ if (issues.length > 0) {
78
+ throw new Error(`Transpiled bundle for "${instanceName}" failed integrity verification: ` +
79
+ issues.map(i => i.kind).join(', '));
80
+ }
81
+ const outDir = options.outDir ?? mkdtempSync(join(tmpdir(), `pgpm-apply-${instanceName}-`));
82
+ materializeBundle(transpiled, outDir);
83
+ return { bundle: transpiled, outDir };
84
+ }
85
+ // One materialization per proxy path per process: the pipeline is
86
+ // deterministic, so re-deriving within a process is pure waste.
87
+ const materializedCache = new Map();
88
+ /**
89
+ * Deploy-path dispatch seam: given a resolved module's directory, return the
90
+ * directory the migration engine should actually run against.
91
+ *
92
+ * Non-proxy modules pass through unchanged. For proxy modules (directories
93
+ * carrying a `pgpm.apply.json`), the source module is located in the workspace
94
+ * module map, transpiled per the spec, and materialized to a cached temp dir
95
+ * carrying the instance's identity.
96
+ */
97
+ export async function resolveEffectiveModulePath(moduleName, modulePath, moduleMap, workspacePath) {
98
+ if (!hasApplySpec(modulePath))
99
+ return modulePath;
100
+ // A reuse proxy resolves under two names (shared + per-tenant) from one
101
+ // directory, so the cache is keyed by the requested module name too.
102
+ const cacheKey = `${modulePath}::${moduleName}`;
103
+ const cached = materializedCache.get(cacheKey);
104
+ if (cached)
105
+ return cached;
106
+ const spec = readApplySpec(modulePath);
107
+ const sourceModule = moduleMap[spec.source.module];
108
+ if (!sourceModule) {
109
+ throw new Error(`Apply spec in "${moduleName}" references source module "${spec.source.module}", ` +
110
+ `which was not found in the workspace. Run \`pgpm install\` to install it` +
111
+ (spec.source.package ? ` (${spec.source.package})` : '') +
112
+ `.`);
113
+ }
114
+ const sourceDir = resolve(workspacePath, sourceModule.path);
115
+ if (isReuseSpec(spec)) {
116
+ const result = await materializeReuseModule({ sourceDir, spec });
117
+ materializedCache.set(`${modulePath}::${result.sharedName}`, result.shared.outDir);
118
+ materializedCache.set(`${modulePath}::${result.perTenantName}`, result.perTenant.outDir);
119
+ const outDir = moduleName === resolveSharedModuleName(spec) ? result.shared.outDir : result.perTenant.outDir;
120
+ return outDir;
121
+ }
122
+ const { outDir } = await materializeApplyModule({ sourceDir, spec });
123
+ materializedCache.set(cacheKey, outDir);
124
+ return outDir;
125
+ }
126
+ /** Test seam: clear the per-process materialization cache. */
127
+ export function clearApplyMaterializationCache() {
128
+ materializedCache.clear();
129
+ }