From cd33a1e76fa15e686b2b601c6f08165725839c37 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 19 Oct 2026 06:40:47 +0000 Subject: [PATCH 1/3] feat(extensions): complete PostGIS companion coverage and artifact contracts --- .../src/generated/extension-smoke.ts | 12 +- .../tools/extension-artifact-packager.mts | 82 +++-- .../extension-artifact-packager.test.mts | 158 +++++++++ .../tools/windows-extension-sources.mts | 198 ++++++++++- .../tools/windows-extension-sources.test.mts | 114 +++++- .../tools/build-extension-ci-artifacts.mts | 43 ++- .../packages/tools/check-carriers.mts | 38 +- .../packages/tools/check-carriers.test.mts | 17 +- .../tools/extension-artifact-inventory.mts | 112 +++++- .../extension-artifact-inventory.test.mts | 95 ++++- .../package-extension-release-carriers.mts | 59 ++-- src/extensions/catalog/extensions.source.json | 2 +- src/extensions/external/postgis/recipe.toml | 7 + .../targets/native-static-registry.toml | 3 - .../external/postgis/targets/native.toml | 4 +- .../external/postgis/targets/wasix.toml | 18 +- .../external/postgis/tools/build_wasix.sh | 38 +- .../postgis/tools/windows/build-sql.sh | 6 + .../postgis/tools/windows/meson.build.in | 8 + .../generated/docs/extension-evidence.json | 2 +- .../generated/extensions.catalog.json | 4 +- src/extensions/generated/sdk/extensions.json | 247 ++++++++++++- .../generated/wasix/extensions.json | 10 +- src/extensions/tools/extension-model.test.mts | 15 + .../tools/extension-projections.mts | 25 +- .../runtime/bin/build-postgres18-linux.sh | 13 +- .../runtime/bin/build-postgres18-macos.sh | 14 +- .../runtime/bin/build-postgres18-windows.sh | 13 +- .../runtime/bin/mobile-postgis-extensions.sh | 62 +++- .../bin/mobile-postgis-extensions.test.sh | 67 ++++ src/native/rust-bindings/src/extension.rs | 176 +++++++++- .../rust-bindings/src/generated/extensions.rs | 99 +++++- .../src/liboliphaunt/root/extensions.rs | 36 +- .../src/liboliphaunt/root/runtime.rs | 1 + .../liboliphaunt/root/runtime/cache_key.rs | 260 ++++++++++++-- .../root/runtime/extension_files.rs | 218 ++++++++++++ .../src/liboliphaunt/root/runtime/install.rs | 328 ++++++++++++++++-- .../android/OliphauntExtensionCatalog.java | 6 +- .../ResolveOliphauntAndroidAssetsTask.java | 90 +++-- .../android/extension-legal-catalog.json | 2 +- .../oliphaunt/android/extensions.properties | 43 ++- ...OliphauntExtensionCatalogContractTest.java | 26 ++ .../react-native/src/generated/extensions.ts | 251 +++++++++++++- .../tools/mobile-extension-artifact-paths.mts | 19 +- .../sdks/react-native/tools/stage-ios-app.mts | 106 +++++- .../react-native/tools/stage-ios-app.test.mts | 75 +++- .../tools/validate-mobile-runtime-files.mts | 15 +- .../tools/extension-resource-inventory.mts | 66 +++- .../extension-resource-inventory.test.mts | 60 ++++ .../sdks/swift/tools/ios-carrier-manifest.mts | 11 + .../swift/tools/ios-carrier-manifest.test.mts | 11 + .../swift/tools/render-extension-products.mts | 4 + .../swift/tools/swift-carrier-resolver.mts | 15 + .../tools/swift-carrier-resolver.test.mts | 32 ++ .../ts/src/__tests__/asset-resolver.test.ts | 160 +++++++++ .../sdks/ts/src/generated/extensions.ts | 249 ++++++++++++- src/native/sdks/ts/src/native/assets.ts | 217 +++++++++--- src/test-fixtures/extensions/postgis.sql | 28 ++ .../src/oliphaunt/generated_extensions.rs | 6 +- 59 files changed, 3743 insertions(+), 353 deletions(-) create mode 100644 src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts create mode 100644 src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs diff --git a/src/examples/native/react-native-expo/src/generated/extension-smoke.ts b/src/examples/native/react-native-expo/src/generated/extension-smoke.ts index 538a0c4a8..4f385e0a5 100644 --- a/src/examples/native/react-native-expo/src/generated/extension-smoke.ts +++ b/src/examples/native/react-native-expo/src/generated/extension-smoke.ts @@ -7,7 +7,7 @@ export type GeneratedMobileExtensionProof = { readonly selectedExtensionDependencies: readonly string[]; }; -export const GENERATED_MOBILE_EXTENSION_METADATA_SHA256 = "26ce911f641272aa18249e7c7d044dfa5ea7061d07119bb275544e164bb9249a" as const; +export const GENERATED_MOBILE_EXTENSION_METADATA_SHA256 = "dbe1ab284693ef923cb12e35e1c75bcc7fc1ed42af1fecf9fe9fee0aaf0ef8d0" as const; export const GENERATED_MOBILE_EXTENSION_PLAN = [ { @@ -165,7 +165,9 @@ export const GENERATED_MOBILE_EXTENSION_PLAN = [ { "sqlName": "postgis", "createsExtension": true, - "selectedExtensionDependencies": [] + "selectedExtensionDependencies": [ + "fuzzystrmatch" + ] }, { "sqlName": "seg", @@ -353,11 +355,15 @@ export const GENERATED_MOBILE_EXTENSION_SMOKE = { ], "postgis": [ "CREATE EXTENSION IF NOT EXISTS postgis_raster;", + "CREATE EXTENSION IF NOT EXISTS postgis_topology;", + "CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;", + "CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder;", + "DO $$ BEGIN\n IF EXISTS (SELECT 1 FROM topology.topology WHERE name = 'oliphaunt_postgis_topology') THEN\n PERFORM topology.DropTopology('oliphaunt_postgis_topology');\n END IF;\n PERFORM topology.CreateTopology('oliphaunt_postgis_topology', 4326);\n PERFORM topology.ST_AddIsoNode('oliphaunt_postgis_topology', NULL, ST_SetSRID(ST_MakePoint(1, 1), 4326));\nEND $$;", "DROP TABLE IF EXISTS oliphaunt_postgis_points;", "CREATE TABLE oliphaunt_postgis_points(id int PRIMARY KEY, geom geometry(Point, 4326));", "INSERT INTO oliphaunt_postgis_points VALUES\n (1, ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326)),\n (2, ST_SetSRID(ST_MakePoint(-71.061, 48.433), 4326));", "CREATE INDEX oliphaunt_postgis_points_gix ON oliphaunt_postgis_points USING GIST (geom);", - "-- oliphaunt-verify\nDO $$\nDECLARE\n distance float8;\n srid int;\n area float8;\n polygons int;\n nearby int;\n gdal_drivers int;\n raster_roundtrip raster;\n raster_value float8;\nBEGIN\n SET LOCAL postgis.gdal_enabled_drivers = 'GTiff';\n SELECT ST_Distance(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(3 4)')) INTO distance;\n IF distance <> 5 THEN\n RAISE EXCEPTION 'postgis geometry distance failed: %', distance;\n END IF;\n IF ST_AsText(ST_Buffer(ST_GeomFromText('POINT(0 0)'), 1, 'quad_segs=1')) IS NULL THEN\n RAISE EXCEPTION 'postgis buffer failed';\n END IF;\n IF NOT ST_Within(\n ST_GeomFromText('POINT(0.5 0.5)'),\n ST_GeomFromText('POLYGON((0 0,0 1,1 1,1 0,0 0))')\n ) THEN\n RAISE EXCEPTION 'postgis within failed';\n END IF;\n SELECT ST_SRID(ST_Transform(ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326), 3857)) INTO srid;\n IF srid <> 3857 THEN\n RAISE EXCEPTION 'postgis transform failed: %', srid;\n END IF;\n SELECT ST_Area(ST_Transform(ST_SetSRID(ST_MakePolygon(ST_GeomFromText('LINESTRING(-71.1776848522251 42.3902896512902,-71.1776843766797 42.3903701743239,-71.1775844305465 42.3903829478009,-71.1775825927231 42.3902893647987,-71.1776848522251 42.3902896512902)')), 4326), 26986)) INTO area;\n IF area <= 0 THEN\n RAISE EXCEPTION 'postgis projected area failed: %', area;\n END IF;\n SELECT ST_NumGeometries(ST_Polygonize(ARRAY[\n ST_GeomFromText('LINESTRING(0 0, 1 0)'),\n ST_GeomFromText('LINESTRING(1 0, 1 1)'),\n ST_GeomFromText('LINESTRING(1 1, 0 1)'),\n ST_GeomFromText('LINESTRING(0 1, 0 0)')\n ])) INTO polygons;\n IF polygons <> 1 THEN\n RAISE EXCEPTION 'postgis polygonize failed: %', polygons;\n END IF;\n SELECT count(*) INTO nearby\n FROM oliphaunt_postgis_points\n WHERE ST_DWithin(\n geom::geography,\n ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326)::geography,\n 200\n );\n IF nearby <> 2 THEN\n RAISE EXCEPTION 'postgis dwithin failed: %', nearby;\n END IF;\n SELECT count(*) INTO gdal_drivers\n FROM ST_GDALDrivers()\n WHERE short_name = 'GTiff' AND can_read AND can_write;\n IF gdal_drivers <> 1 THEN\n RAISE EXCEPTION 'postgis raster GeoTIFF driver is unavailable';\n END IF;\n SELECT ST_FromGDALRaster(\n ST_AsGDALRaster(\n ST_AddBand(\n ST_MakeEmptyRaster(2, 2, 0, 0, 1, -1, 0, 0, 4326),\n '8BUI'::text,\n 7::double precision,\n 0::double precision\n ),\n 'GTiff'\n )\n ) INTO raster_roundtrip;\n SELECT ST_Value(raster_roundtrip, 1, 1, 1) INTO raster_value;\n IF raster_value IS DISTINCT FROM 7 OR ST_SRID(raster_roundtrip) IS DISTINCT FROM 4326\n OR ST_Width(raster_roundtrip) IS DISTINCT FROM 2 OR ST_Height(raster_roundtrip) IS DISTINCT FROM 2 THEN\n RAISE EXCEPTION 'postgis raster GDAL round-trip failed: value %, srid %',\n raster_value,\n ST_SRID(raster_roundtrip);\n END IF;\nEND $$;" + "-- oliphaunt-verify\nDO $$\nDECLARE\n distance float8;\n srid int;\n area float8;\n polygons int;\n nearby int;\n gdal_drivers int;\n raster_roundtrip raster;\n raster_value float8;\n topology_nodes int;\n normalized tiger.norm_addy;\nBEGIN\n SELECT count(*) INTO topology_nodes FROM oliphaunt_postgis_topology.node\n WHERE ST_Equals(geom, ST_SetSRID(ST_MakePoint(1, 1), 4326));\n IF topology_nodes <> 1 OR EXISTS (SELECT 1 FROM topology.ValidateTopology('oliphaunt_postgis_topology')) THEN\n RAISE EXCEPTION 'postgis topology persistence or validation failed: % nodes', topology_nodes;\n END IF;\n normalized := tiger.normalize_address('1 Devonshire Place, Boston, MA 02109');\n IF normalized.address IS DISTINCT FROM 1 OR normalized.streetname IS DISTINCT FROM 'Devonshire'\n OR normalized.streettypeabbrev IS DISTINCT FROM 'Pl' OR normalized.location IS DISTINCT FROM 'Boston'\n OR normalized.stateabbrev IS DISTINCT FROM 'MA' OR normalized.zip IS DISTINCT FROM '02109'\n OR normalized.parsed IS DISTINCT FROM true THEN\n RAISE EXCEPTION 'postgis tiger address normalization failed: %', normalized;\n END IF;\n SET LOCAL postgis.gdal_enabled_drivers = 'GTiff';\n SELECT ST_Distance(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(3 4)')) INTO distance;\n IF distance <> 5 THEN\n RAISE EXCEPTION 'postgis geometry distance failed: %', distance;\n END IF;\n IF ST_AsText(ST_Buffer(ST_GeomFromText('POINT(0 0)'), 1, 'quad_segs=1')) IS NULL THEN\n RAISE EXCEPTION 'postgis buffer failed';\n END IF;\n IF NOT ST_Within(\n ST_GeomFromText('POINT(0.5 0.5)'),\n ST_GeomFromText('POLYGON((0 0,0 1,1 1,1 0,0 0))')\n ) THEN\n RAISE EXCEPTION 'postgis within failed';\n END IF;\n SELECT ST_SRID(ST_Transform(ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326), 3857)) INTO srid;\n IF srid <> 3857 THEN\n RAISE EXCEPTION 'postgis transform failed: %', srid;\n END IF;\n SELECT ST_Area(ST_Transform(ST_SetSRID(ST_MakePolygon(ST_GeomFromText('LINESTRING(-71.1776848522251 42.3902896512902,-71.1776843766797 42.3903701743239,-71.1775844305465 42.3903829478009,-71.1775825927231 42.3902893647987,-71.1776848522251 42.3902896512902)')), 4326), 26986)) INTO area;\n IF area <= 0 THEN\n RAISE EXCEPTION 'postgis projected area failed: %', area;\n END IF;\n SELECT ST_NumGeometries(ST_Polygonize(ARRAY[\n ST_GeomFromText('LINESTRING(0 0, 1 0)'),\n ST_GeomFromText('LINESTRING(1 0, 1 1)'),\n ST_GeomFromText('LINESTRING(1 1, 0 1)'),\n ST_GeomFromText('LINESTRING(0 1, 0 0)')\n ])) INTO polygons;\n IF polygons <> 1 THEN\n RAISE EXCEPTION 'postgis polygonize failed: %', polygons;\n END IF;\n SELECT count(*) INTO nearby\n FROM oliphaunt_postgis_points\n WHERE ST_DWithin(\n geom::geography,\n ST_SetSRID(ST_MakePoint(-71.060316, 48.432044), 4326)::geography,\n 200\n );\n IF nearby <> 2 THEN\n RAISE EXCEPTION 'postgis dwithin failed: %', nearby;\n END IF;\n SELECT count(*) INTO gdal_drivers\n FROM ST_GDALDrivers()\n WHERE short_name = 'GTiff' AND can_read AND can_write;\n IF gdal_drivers <> 1 THEN\n RAISE EXCEPTION 'postgis raster GeoTIFF driver is unavailable';\n END IF;\n SELECT ST_FromGDALRaster(\n ST_AsGDALRaster(\n ST_AddBand(\n ST_MakeEmptyRaster(2, 2, 0, 0, 1, -1, 0, 0, 4326),\n '8BUI'::text,\n 7::double precision,\n 0::double precision\n ),\n 'GTiff'\n )\n ) INTO raster_roundtrip;\n SELECT ST_Value(raster_roundtrip, 1, 1, 1) INTO raster_value;\n IF raster_value IS DISTINCT FROM 7 OR ST_SRID(raster_roundtrip) IS DISTINCT FROM 4326\n OR ST_Width(raster_roundtrip) IS DISTINCT FROM 2 OR ST_Height(raster_roundtrip) IS DISTINCT FROM 2 THEN\n RAISE EXCEPTION 'postgis raster GDAL round-trip failed: value %, srid %',\n raster_value,\n ST_SRID(raster_roundtrip);\n END IF;\nEND $$;" ], "seg": [ "DO $$ BEGIN IF '7(+-)1'::seg::text <> '6 .. 8' THEN RAISE EXCEPTION 'seg cast failed'; END IF; END $$;" diff --git a/src/extensions/artifacts/native/tools/extension-artifact-packager.mts b/src/extensions/artifacts/native/tools/extension-artifact-packager.mts index e2adc9f29..1fa077368 100755 --- a/src/extensions/artifacts/native/tools/extension-artifact-packager.mts +++ b/src/extensions/artifacts/native/tools/extension-artifact-packager.mts @@ -1,31 +1,31 @@ #!/usr/bin/env bun -import { fileURLToPath } from 'node:url'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { tarHeader } from '../../../../../tools/packaging/archive-directory.mts'; -import { promises as fs } from 'node:fs'; - -import { validateWindowsExtensionArtifactBinaryContract } from './stage-windows-binary-contract.mts'; import { inspectPlatformBinaryTree } from '../../../../../tools/packaging/platform-binary-contract.mts'; -import { - validateExtensionArtifactArchivePlan, - validateExtensionArtifactCompressedBytes, -} from '../../../tools/extension-artifact-archive-policy.mts'; -import { - extensionControlDefaultVersion, - isCanonicalExtensionInstallSql, - validateExtensionInstallSqlReachability, -} from '../../packages/tools/extension-artifact-inventory.mts'; +import { canonicalGzipSync } from '../../../../../tools/packaging/portable-archive.mts'; import { assertReleaseNoticesInArchive, stageReleaseNotices, } from '../../../../../tools/packaging/release-notices.mts'; -import { stageExtensionUpstreamLicenses } from '../../../tools/extension-upstream-licenses.mts'; -import { canonicalGzipSync } from '../../../../../tools/packaging/portable-archive.mts'; import { extensionSqlNames } from '../../../../../tools/release/release-artifact-targets.mts'; +import { + validateExtensionArtifactArchivePlan, + validateExtensionArtifactCompressedBytes, +} from '../../../tools/extension-artifact-archive-policy.mts'; +import { stageExtensionUpstreamLicenses } from '../../../tools/extension-upstream-licenses.mts'; import { loadNativeComponentContract, resolveNativeComponentClosure, } from '../../../tools/native-component-contract.mts'; +import { + extensionControlDefaultVersion, + extensionNativeArtifactClosure, + isCanonicalExtensionInstallSql, + validateExtensionInstallSqlReachability, +} from '../../packages/tools/extension-artifact-inventory.mts'; +import { validateWindowsExtensionArtifactBinaryContract } from './stage-windows-binary-contract.mts'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(scriptDir, '../../../../..'); @@ -785,6 +785,12 @@ function artifactMobilePrebuilt(args) { function extensionSqlFileBelongs(sqlName, fileName, extraSql, createsExtension) { return ( + extraSql.controls.includes(fileName) || + extraSql.controls.some( + (control) => + fileName.startsWith(`${control.slice(0, -'.control'.length)}--`) && + fileName.endsWith('.sql'), + ) || (createsExtension && fileName === `${sqlName}.control`) || (createsExtension && fileName === `${sqlName}.sql`) || (createsExtension && fileName.startsWith(`${sqlName}--`) && fileName.endsWith('.sql')) || @@ -793,7 +799,7 @@ function extensionSqlFileBelongs(sqlName, fileName, extraSql, createsExtension) ); } -async function extensionSqlMetadata(sqlName) { +async function extensionSqlMetadata(sqlName, target) { const prefixes = await extensionArtifactList(sqlName, 'extension_sql_file_prefixes'); const names = await extensionArtifactList(sqlName, 'extension_sql_file_names'); if (new Set(prefixes).size !== prefixes.length || new Set(names).size !== names.length) { @@ -810,7 +816,10 @@ async function extensionSqlMetadata(sqlName) { fail(`prebuilt extension '${sqlName}' SQL file prefix '${prefix}' must not contain '.'`); } } - return { names, prefixes }; + const closure = extensionNativeArtifactClosure(sqlName, target); + // Mobile combines companion objects into its primary static module. + if (!DESKTOP_NATIVE_TARGETS.has(target)) closure.modules = []; + return { names, prefixes, ...closure }; } async function ensureParent(file) { @@ -898,6 +907,21 @@ async function copySqlFiles(args, artifactRoot, artifactFiles, extraSql) { label: `prebuilt extension artifact ${artifactRoot}`, }); } + for (const controlFile of extraSql.controls) { + if (controlFile === `${args.sqlName}.control`) continue; + const controlPath = path.join(targetDir, controlFile); + if (!copiedFileNames.includes(controlFile)) { + fail( + `prebuilt extension artifact ${artifactRoot} is missing companion control ${controlFile}`, + ); + } + validateExtensionInstallSqlReachability({ + sqlName: controlFile.slice(0, -'.control'.length), + control: await fs.readFile(controlPath, 'utf8'), + fileNames: copiedFileNames, + label: `prebuilt extension artifact ${artifactRoot}`, + }); + } if (!args.createsExtension && copied === 0) { return; } @@ -969,25 +993,27 @@ function extensionMetadata(args, extraSql) { }; } -async function writeArtifactDirectory(artifactRoot, args) { +export async function writeArtifactDirectory(artifactRoot, args) { const filesRoot = path.join(artifactRoot, 'files'); - const extraSql = await extensionSqlMetadata(args.sqlName); + const extraSql = await extensionSqlMetadata(args.sqlName, args.nativeTarget); const metadata = extensionMetadata(args, extraSql); + const sqlMembers = sortedDeduped([ + ...(args.createsExtension ? [args.sqlName] : []), + ...extraSql.controls.map((control) => control.slice(0, -'.control'.length)), + ]); await copySqlFiles(args, artifactRoot, filesRoot, extraSql); for (const dataFile of metadata.dataFiles) { await copyRuntimeRelativeFile(args.runtime, filesRoot, `share/postgresql/${dataFile}`); } - if (metadata.nativeModuleFile.length > 0) { - await copyRuntimeRelativeFile( - args.runtime, - filesRoot, - `lib/postgresql/${metadata.nativeModuleFile}`, - ); + const modules = new Set(extraSql.modules); + if (metadata.nativeModuleFile.length > 0) modules.add(metadata.nativeModuleFile); + for (const module of modules) { + await copyRuntimeRelativeFile(args.runtime, filesRoot, `lib/postgresql/${module}`); if (DESKTOP_NATIVE_TARGETS.has(args.nativeTarget)) { await copyFileChecked( args.embeddedModuleRoot, - path.join(args.embeddedModuleRoot, metadata.nativeModuleFile), - path.join(filesRoot, 'lib/modules', metadata.nativeModuleFile), + path.join(args.embeddedModuleRoot, module), + path.join(filesRoot, 'lib/modules', module), ); } } @@ -1031,6 +1057,8 @@ async function writeArtifactDirectory(artifactRoot, args) { `staticSymbolAliases=${metadata.staticSymbolAliases.map((alias) => `${alias.sqlSymbol}:${alias.linkedSymbol}`).join(',')}`, `licenseFiles=${licenseFiles.join(',')}`, `licenseProfile=${licenseProfile}`, + `sqlMembers=${sqlMembers.join(',')}`, + `nativeModuleStems=${sortedDeduped([...modules].map((module) => module.replace(/\.(so|dylib|dll)$/u, ''))).join(',')}`, 'files=files', '', ].join('\n'); diff --git a/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts b/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts new file mode 100644 index 000000000..f1d1dd529 --- /dev/null +++ b/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts @@ -0,0 +1,158 @@ +import { test } from 'bun:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { validateExtensionArtifactEntries } from '../../packages/tools/extension-artifact-inventory.mts'; +import { writeArtifactDirectory } from './extension-artifact-packager.mts'; + +const repo = path.resolve(import.meta.dir, '../../../../..'); +const recipe = Bun.TOML.parse( + await fs.readFile(path.join(repo, 'src/extensions/external/postgis/recipe.toml'), 'utf8'), +); +const source = Bun.TOML.parse( + await fs.readFile(path.join(repo, 'src/extensions/external/postgis/source.toml'), 'utf8'), +); +const version = source['extension-control']['default-version']; +const controls = ['postgis', 'postgis_raster', 'postgis_topology', 'postgis_tiger_geocoder']; +const stems = ['postgis-3', 'postgis_raster-3', 'postgis_topology-3']; + +async function write(file, data) { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, data); +} + +async function entriesAt(root) { + const entries = new Map(); + for (const relative of await fs.readdir(root, { recursive: true })) { + const file = path.join(root, relative); + const stat = await fs.stat(file); + if (!stat.isFile()) continue; + const data = await fs.readFile(file); + entries.set(relative.split(path.sep).join('/'), { + data, + bytes: data.length, + mode: stat.mode & 0o777, + sha256: createHash('sha256').update(data).digest('hex'), + }); + } + return entries; +} + +for (const [target, suffix] of [ + ['linux-x64-gnu', '.so'], + ['macos-arm64', '.dylib'], + ['windows-x64-msvc', '.dll'], + ['android-arm64-v8a', '.so'], + ['ios-xcframework', '.dylib'], +]) { + test(`PostGIS ${target} freezes and stages its declared companion closure`, async () => { + const mobile = target === 'ios-xcframework' || target.startsWith('android-'); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'postgis-artifact-')); + const runtime = path.join(root, 'runtime'); + const embedded = path.join(root, 'embedded'); + const artifact = path.join(root, 'artifact'); + const dataFiles = recipe.artifacts.data_files.map((file) => + file.slice('share/postgresql/'.length), + ); + const metadata = { + sqlName: 'postgis', + createsExtension: true, + nativeModuleStem: 'postgis-3', + sqlMembers: [...controls].sort(), + nativeModuleStems: [...stems].sort(), + dependencies: [], + dataFiles, + extensionSqlFileNames: recipe.artifacts.extension_sql_file_names, + extensionSqlFilePrefixes: recipe.artifacts.extension_sql_file_prefixes, + sharedPreloadLibraries: [], + }; + const args = { + ...metadata, + runtime, + embeddedModuleRoot: embedded, + nativeTarget: target, + nativeModuleFile: `postgis-3${suffix}`, + nativeRuntimeProduct: 'liboliphaunt-native', + nativeRuntimeVersion: '1.2.3', + mobileStaticArchives: [], + mobileStaticDependencyArchives: [], + staticSymbolAliases: [], + mobilePrebuilt: false, + }; + try { + for (const name of controls) { + await write( + path.join(runtime, `share/postgresql/extension/${name}.control`), + `default_version = '${version}'\n`, + ); + await write( + path.join(runtime, `share/postgresql/extension/${name}--${version}.sql`), + `-- ${name} install\n`, + ); + } + for (const stem of stems) { + await write(path.join(runtime, `lib/postgresql/${stem}${suffix}`), `server ${stem}`); + await write(path.join(embedded, `${stem}${suffix}`), `embedded ${stem}`); + } + await write( + path.join(runtime, 'share/postgresql/extension/postgis_topology-foreign.control'), + 'foreign', + ); + await write( + path.join(runtime, `lib/postgresql/liboliphaunt_postgis_deps${suffix}`), + 'WASIX-only dependency', + ); + for (const file of dataFiles) await write(path.join(runtime, 'share/postgresql', file), file); + await writeArtifactDirectory(artifact, args); + const entries = await entriesAt(artifact); + const manifest = entries.get('manifest.properties').data.toString(); + assert(manifest.includes(`sqlMembers=${[...controls].sort().join(',')}\n`)); + if (mobile) { + assert(manifest.includes('nativeModuleStems=postgis-3\n')); + for (const name of controls) + assert(entries.has(`files/share/postgresql/extension/${name}.control`)); + assert(entries.has(`files/lib/postgresql/postgis-3${suffix}`)); + assert(!entries.has(`files/lib/postgresql/postgis_topology-3${suffix}`)); + assert(!entries.has(`files/lib/modules/postgis-3${suffix}`)); + return; + } + const validate = (selected) => + validateExtensionArtifactEntries({ + entries: selected, + metadata, + target, + nativeRuntimeVersion: '1.2.3', + label: target, + }); + validate(entries); + assert(!entries.has('files/share/postgresql/extension/postgis_topology-foreign.control')); + assert(!entries.has(`files/lib/postgresql/liboliphaunt_postgis_deps${suffix}`)); + for (const stem of stems) { + assert.equal( + entries.get(`files/lib/postgresql/${stem}${suffix}`).data.toString(), + `server ${stem}`, + ); + assert.equal( + entries.get(`files/lib/modules/${stem}${suffix}`).data.toString(), + `embedded ${stem}`, + ); + } + for (const missing of [ + 'files/share/postgresql/extension/postgis_topology.control', + `files/share/postgresql/extension/postgis_tiger_geocoder--${version}.sql`, + `files/lib/postgresql/postgis_raster-3${suffix}`, + `files/lib/modules/postgis_topology-3${suffix}`, + ]) { + const incomplete = new Map(entries); + incomplete.delete(missing); + assert.throws(() => validate(incomplete), /missing|canonical installation script/); + } + await fs.unlink(path.join(embedded, `postgis_topology-3${suffix}`)); + await assert.rejects(writeArtifactDirectory(path.join(root, 'incomplete'), args), /ENOENT/); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +} diff --git a/src/extensions/artifacts/native/tools/windows-extension-sources.mts b/src/extensions/artifacts/native/tools/windows-extension-sources.mts index da7994879..45e4fa3ba 100644 --- a/src/extensions/artifacts/native/tools/windows-extension-sources.mts +++ b/src/extensions/artifacts/native/tools/windows-extension-sources.mts @@ -68,6 +68,24 @@ export function pgxsSources(root: string) { return values.map((value) => value.slice(0, -2) + '.c'); } +// Preserve the pinned tiger SQL recipe's order (and Make's $^ deduplication). +// Only literal SQL prerequisites are supported; upstream expressions need review. +export function postgisSqlInputs(root: string, target: string) { + const makefile = read(path.join(root, 'Makefile.in')).replace(/\\\r?\n/g, ' '); + const rule = makefile.split(/\r?\n/).find((line) => line.startsWith(`${target}:`)); + assert(rule, `missing PostGIS SQL rule ${target}`); + const inputs = rule + .slice(target.length + 1) + .split('|')[0] + .trim() + .split(/\s+/); + assert( + inputs.length && inputs.every((input) => /^[\w./-]+\.sql(?:\.in)?$/.test(input)), + `unsupported PostGIS SQL prerequisites for ${target}`, + ); + return [...new Set(inputs)].map((input) => path.resolve(root, input)); +} + export function patchTextsearch(root: string) { write( path.join(root, 'src/oliphaunt_windows_compat.h'), @@ -232,6 +250,24 @@ function postgisDirectory(config: Config) { function postgisTemplates(config: Config) { return path.join(config.repo, 'src/extensions/external/postgis/tools/windows'); } +export function postgisModuleStems(config: Pick) { + const target = Bun.TOML.parse( + read(path.join(config.repo, 'src/extensions/external/postgis/targets/native.toml')), + ); + assert( + Array.isArray(target.module_files) && target.module_files.length, + 'missing PostGIS native modules', + ); + const stems = target.module_files.map((file: unknown) => { + assert(typeof file === 'string', 'invalid PostGIS native module'); + const stem = /^lib\/postgresql\/([\w-]+)\.so$/.exec(file)?.[1]; + assert(stem, `invalid PostGIS native module ${file}`); + return stem; + }); + assert.equal(new Set(stems).size, stems.length, 'duplicate PostGIS native modules'); + assert(stems.includes('postgis-3'), 'missing primary PostGIS native module'); + return stems; +} function expand(input: string, output: string, values: Record) { let text = read(input); for (const [key, value] of Object.entries(values)) text = text.replaceAll(`@${key}@`, value); @@ -342,7 +378,18 @@ export function preparePostgis(config: Config) { MODULEPATH: '$libdir/postgis_raster-3', }, ); - for (const file of ['raster/rt_core/librtcore.h', 'raster/rt_pg/rtpostgis.c']) { + for (const name of ['postgis_topology', 'postgis_tiger_geocoder']) { + expand( + path.join(directory, `extensions/${name}/${name}.control.in`), + path.join(directory, `extensions/${name}/${name}.control`), + { EXTVERSION: pgis.version }, + ); + } + for (const file of [ + 'raster/rt_core/librtcore.h', + 'raster/rt_pg/rtpostgis.c', + 'topology/postgis_topology.c', + ]) { replace( path.join(directory, file), '# define __attribute__ (x)', @@ -357,7 +404,14 @@ export function preparePostgis(config: Config) { path.join(postgisTemplates(config), 'flatgeobuf-compat.h'), path.join(directory, 'oliphaunt_flatgeobuf_windows_compat.h'), ); - for (const folder of ['postgis', 'libpgcommon', 'liblwgeom', 'raster/rt_core', 'raster/rt_pg']) { + for (const folder of [ + 'postgis', + 'libpgcommon', + 'liblwgeom', + 'raster/rt_core', + 'raster/rt_pg', + 'topology', + ]) { for (const file of glob(path.join(directory, folder), '**/*.{c,h}')) { const target = path.join(directory, folder, file), text = read(target); @@ -368,6 +422,13 @@ export function preparePostgis(config: Config) { if (updated !== text) write(target, updated); } } + for (const hook of ['_PG_init', '_PG_fini']) { + replace( + path.join(directory, 'topology/postgis_topology.c'), + `void ${hook}(void);`, + `PGDLLEXPORT void ${hook}(void);`, + ); + } replace( path.join(directory, 'postgis/postgis_legacy.c'), /^([ \t]*)Datum[ \t]+funcname[ \t]*\(PG_FUNCTION_ARGS\);[ \t]*\\\r?$/gm, @@ -456,6 +517,10 @@ export function postgisMeson(config: Config) { ...glob(directory, 'extensions/postgis/sql/postgis--*.sql'), 'extensions/postgis_raster/postgis_raster.control', ...glob(directory, 'extensions/postgis_raster/sql/postgis_raster--*.sql'), + 'extensions/postgis_topology/postgis_topology.control', + ...glob(directory, 'extensions/postgis_topology/sql/postgis_topology--*.sql'), + 'extensions/postgis_tiger_geocoder/postgis_tiger_geocoder.control', + ...glob(directory, 'extensions/postgis_tiger_geocoder/sql/postgis_tiger_geocoder--*.sql'), ]; const contrib = [ 'postgis/legacy.sql', @@ -467,6 +532,9 @@ export function postgisMeson(config: Config) { 'postgis/uninstall_legacy.sql', 'postgis/uninstall_postgis.sql', 'doc/postgis_comments.sql', + 'topology/topology.sql', + 'topology/topology_upgrade.sql', + 'topology/uninstall_topology.sql', ]; expand( path.join(postgisTemplates(config), 'meson.build.in'), @@ -515,8 +583,17 @@ export function postgisSql(config: Config, phase: string) { sql = path.join(directory, 'extensions/postgis/sql'); const raster = path.join(directory, 'raster/rt_pg'); const rasterSql = path.join(directory, 'extensions/postgis_raster/sql'); - mkdirSync(sql, { recursive: true }); - mkdirSync(rasterSql, { recursive: true }); + const topology = path.join(directory, 'topology'); + const topologyExtension = path.join(directory, 'extensions/postgis_topology'); + const topologySql = path.join(topologyExtension, 'sql'); + const tigerExtension = path.join(directory, 'extensions/postgis_tiger_geocoder'); + const tigerSql = path.join(tigerExtension, 'sql'); + const topologyBody = (text: string) => + withoutTransactions(text).replace(/^CREATE SCHEMA[^\r\n]*(?:\r?\n|$)/gm, ''); + const tigerDropSchema = + "SELECT postgis_extension_drop_if_exists('postgis_tiger_geocoder', 'DROP SCHEMA tiger_data');"; + for (const folder of [sql, rasterSql, topologySql, tigerSql]) + mkdirSync(folder, { recursive: true }); if (phase === 'postgis-sql-preprocess') { const template = ( input: string, @@ -579,6 +656,45 @@ export function postgisSql(config: Config, phase: string) { true, false, ); + for (const name of ['topology', 'topology_before_upgrade', 'topology_after_upgrade']) { + template( + path.join(topology, name + '.sql.in'), + path.join(topology, name + '.sql'), + [pg, directory], + '$libdir/postgis_topology-3', + false, + false, + ); + } + write( + path.join(topologySql, 'topology.sql'), + topologyBody(read(path.join(topology, 'topology.sql'))), + ); + copyFileSync( + path.join(tigerExtension, 'sql_bits/add_search_path.sql.in'), + path.join(tigerSql, 'add_search_path.sql'), + ); + for (const name of ['tiger_geocoder', 'tiger_geocoder_minor']) { + const inputs = postgisSqlInputs(tigerExtension, `sql/${name}.sql.in`); + const tail = + name === 'tiger_geocoder' + ? [ + tigerDropSchema, + read(path.join(directory, 'extensions/postgis_extension_helper_uninstall.sql')), + ] + : []; + write( + path.join(tigerSql, `${name}.sql`), + withoutTransactions(joined(inputs.map(read).join(''), ...tail)), + ); + } + write( + path.join(tigerSql, 'postgis_tiger_geocoder_pre.sql'), + joined( + read(path.join(tigerSql, 'tiger_geocoder.sql')), + read(path.join(tigerExtension, 'sql_bits/mark_editable_objects.sql.in')), + ), + ); } else if (phase === 'postgis-sql-upgrade') { const upgrade = (body: string) => joined( @@ -624,6 +740,20 @@ export function postgisSql(config: Config, phase: string) { ), ), ); + const topologyUpgrade = joined( + ...[ + path.join(pg, 'common_before_upgrade.sql'), + path.join(topology, 'topology_before_upgrade.sql'), + path.join(topology, 'topology_upgrade.sql.in'), + path.join(topology, 'topology_after_upgrade.sql'), + path.join(pg, 'common_after_upgrade.sql'), + ].map(read), + ); + write( + path.join(topology, 'topology_upgrade.sql'), + joined('BEGIN;', topologyUpgrade, 'COMMIT;'), + ); + write(path.join(topologySql, 'topology_upgrade.sql'), topologyBody(topologyUpgrade)); } else if (phase === 'postgis-sql-install') { const template = read( path.join(directory, 'extensions/postgis/unpackage_raster_if_needed.sql'), @@ -682,6 +812,55 @@ export function postgisSql(config: Config, phase: string) { write(path.join(rasterSql, 'postgis_raster--TEMPLATED--TO--ANY.sql'), rasterTag); write(path.join(rasterSql, `postgis_raster--${pgis.version}--ANY.sql`), rasterTag); write(path.join(rasterSql, 'postgis_raster--unpackaged.sql'), '-- Nothing to do here\n'); + write( + path.join(topologySql, `postgis_topology--${pgis.version}.sql`), + joined( + ...[ + path.join(topologyExtension, 'extlock.sql'), + path.join(topologySql, 'topology.sql'), + path.join(topologyExtension, 'sql_bits/mark_editable_objects.sql.in'), + ].map(read), + ), + ); + write( + path.join(topologySql, `postgis_topology--ANY--${pgis.version}.sql`), + joined( + ...[ + path.join(topologyExtension, 'extlock.sql'), + path.join(directory, 'extensions/postgis_extension_helper.sql'), + path.join(topologySql, 'topology_upgrade.sql'), + path.join(topologyExtension, 'sql_bits/mark_editable_objects.sql.in'), + path.join(directory, 'extensions/postgis_extension_helper_uninstall.sql'), + ].map(read), + ), + ); + write(path.join(topologySql, 'postgis_topology--unpackaged.sql'), '-- Nothing to do here\n'); + copyFileSync( + path.join(tigerSql, 'postgis_tiger_geocoder.sql'), + path.join(tigerSql, `postgis_tiger_geocoder--${pgis.version}.sql`), + ); + write( + path.join(tigerSql, `postgis_tiger_geocoder--ANY--${pgis.version}.sql`), + joined( + read(path.join(directory, 'extensions/postgis_extension_helper.sql')), + read(path.join(tigerSql, 'tiger_geocoder_minor.sql')), + read(path.join(tigerExtension, 'sql_bits/mark_editable_objects.sql.in')), + tigerDropSchema, + read(path.join(directory, 'extensions/postgis_extension_helper_uninstall.sql')), + ), + ); + copyFileSync( + path.join(tigerExtension, 'sql_bits/tiger_geocoder--unpackaged.sql.in'), + path.join(tigerSql, `postgis_tiger_geocoder--unpackaged--${pgis.version}.sql`), + ); + for (const [name, folder] of [ + ['postgis_topology', topologySql], + ['postgis_tiger_geocoder', tigerSql], + ]) { + const extensionTag = tag.replaceAll('extension postgis ', `extension ${name} `); + write(path.join(folder, `${name}--TEMPLATED--TO--ANY.sql`), extensionTag); + write(path.join(folder, `${name}--${pgis.version}--ANY.sql`), extensionTag); + } } else if (phase === 'postgis-sql-unpackaged') { for (const [name, folder] of [ ['postgis', sql], @@ -690,6 +869,14 @@ export function postgisSql(config: Config, phase: string) { const file = path.join(folder, `${name}--unpackaged--${pgis.version}.sql`); write(file, joined(read(file), read(path.join(folder, `${name}--ANY--${pgis.version}.sql`)))); } + const topologyUnpackaged = path.join( + topologySql, + `postgis_topology--unpackaged--${pgis.version}.sql`, + ); + write( + topologyUnpackaged, + joined(read(topologyUnpackaged), read(path.join(topologySql, 'topology_upgrade.sql'))), + ); } else throw new Error(`unknown SQL phase ${phase}`); } @@ -743,6 +930,9 @@ if (import.meta.main) { case 'postgis-meson': postgisMeson(config); break; + case 'postgis-module-stems': + console.log(postgisModuleStems(config).join('\n')); + break; case 'postgis-version': console.log(postgisVersion(postgisDirectory(config)).version); break; diff --git a/src/extensions/artifacts/native/tools/windows-extension-sources.test.mts b/src/extensions/artifacts/native/tools/windows-extension-sources.test.mts index ed0659bd6..51aa8eeb2 100644 --- a/src/extensions/artifacts/native/tools/windows-extension-sources.test.mts +++ b/src/extensions/artifacts/native/tools/windows-extension-sources.test.mts @@ -3,7 +3,12 @@ import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { patchTextsearch, postgisSql } from './windows-extension-sources.mts'; +import { + patchTextsearch, + postgisModuleStems, + postgisSql, + postgisSqlInputs, +} from './windows-extension-sources.mts'; test('Windows packing applies to each exact textsearch struct, including V3 predecessors', () => { const root = mkdtempSync(path.join(tmpdir(), 'textsearch-windows-')); @@ -48,11 +53,13 @@ test('Windows packing applies to each exact textsearch struct, including V3 pred } }); -test('Windows raster SQL installs and upgrades the raster module under its own extension identity', () => { +test('Windows PostGIS SQL keeps companion extension identities and upgrade paths', () => { const root = mkdtempSync(path.join(tmpdir(), 'postgis-windows-')); const directory = path.join(root, 'postgres/contrib/oliphaunt_external/postgis'); const sql = 'extensions/postgis/sql/'; const rasterSql = 'extensions/postgis_raster/sql/'; + const topologySql = 'extensions/postgis_topology/sql/'; + const tigerSql = 'extensions/postgis_tiger_geocoder/sql/'; const write = (file: string, text: string) => { const target = path.join(directory, file); mkdirSync(path.dirname(target), { recursive: true }); @@ -84,6 +91,10 @@ test('Windows raster SQL installs and upgrades the raster module under its own e sql + 'spatial_ref_sys.sql', 'extensions/postgis_extension_helper.sql', 'extensions/postgis_extension_helper_uninstall.sql', + 'topology/topology_before_upgrade.sql', + 'topology/topology_after_upgrade.sql', + 'extensions/postgis_topology/sql_bits/mark_editable_objects.sql.in', + 'extensions/postgis_tiger_geocoder/sql_bits/mark_editable_objects.sql.in', ]) write(file, '-- fixture\n'); write( @@ -91,6 +102,30 @@ test('Windows raster SQL installs and upgrades the raster module under its own e "CREATE FUNCTION raster_fixture() RETURNS text AS '$libdir/postgis_raster-3', 'raster_fixture' LANGUAGE C;\n", ); write(rasterSql + 'rtpostgis_upgrade.sql.in', 'DROP FUNCTION raster_fixture();\n'); + write( + 'topology/topology_upgrade.sql.in', + 'CREATE SCHEMA topology;\nSELECT topology_fixture();\n', + ); + write( + topologySql + 'topology.sql', + "CREATE FUNCTION topology_fixture() RETURNS text AS '$libdir/postgis_topology-3', 'topology_fixture' LANGUAGE C;\n", + ); + write( + 'extensions/postgis_topology/extlock.sql', + '\\echo Use "CREATE EXTENSION postgis_topology" to load this file. \\quit\n', + ); + write( + tigerSql + 'postgis_tiger_geocoder.sql', + "CREATE FUNCTION tiger.fixture() RETURNS text AS $$ SELECT @extschema:fuzzystrmatch@.soundex('value') $$ LANGUAGE SQL;\n", + ); + write( + tigerSql + 'tiger_geocoder_minor.sql', + "CREATE OR REPLACE FUNCTION tiger.fixture() RETURNS text AS $$ SELECT @extschema:fuzzystrmatch@.soundex('value') $$ LANGUAGE SQL;\n", + ); + write( + 'extensions/postgis_tiger_geocoder/sql_bits/tiger_geocoder--unpackaged.sql.in', + 'ALTER EXTENSION postgis_tiger_geocoder ADD SCHEMA tiger;\n', + ); write('extensions/postgis/unpackage_raster_if_needed.sql', '-- UNPACKAGE_CODE\n-- end\n'); const config = { repo: root, @@ -111,6 +146,23 @@ test('Windows raster SQL installs and upgrades the raster module under its own e ), ); assert(!upgrade.includes('BEGIN;')); + const topologyInstall = read(topologySql + 'postgis_topology--3.6.4.sql'); + assert(topologyInstall.includes('CREATE EXTENSION postgis_topology')); + assert(topologyInstall.includes('$libdir/postgis_topology-3')); + const topologyUpgrade = read(topologySql + 'postgis_topology--ANY--3.6.4.sql'); + assert(topologyUpgrade.includes('SELECT topology_fixture();')); + assert(!topologyUpgrade.includes('CREATE SCHEMA')); + assert(!topologyUpgrade.includes('BEGIN;')); + const tigerInstall = read(tigerSql + 'postgis_tiger_geocoder--3.6.4.sql'); + assert(tigerInstall.includes('CREATE FUNCTION tiger.fixture()')); + assert(tigerInstall.includes('@extschema:fuzzystrmatch@.soundex')); + const tigerUpgrade = read(tigerSql + 'postgis_tiger_geocoder--ANY--3.6.4.sql'); + assert(tigerUpgrade.includes('CREATE OR REPLACE FUNCTION tiger.fixture()')); + assert( + tigerUpgrade.includes( + "postgis_extension_drop_if_exists('postgis_tiger_geocoder', 'DROP SCHEMA tiger_data')", + ), + ); assert( read(rasterSql + 'postgis_raster--3.6.4--ANY.sql').includes( 'extension postgis_raster version', @@ -119,14 +171,72 @@ test('Windows raster SQL installs and upgrades the raster module under its own e for (const [folder, name] of [ [sql, 'postgis'], [rasterSql, 'postgis_raster'], + [topologySql, 'postgis_topology'], ]) { write(folder + name + '--unpackaged--3.6.4.sql', '-- unpackaged objects\n'); } postgisSql(config, 'postgis-sql-unpackaged'); assert(read(rasterSql + 'postgis_raster--unpackaged--3.6.4.sql').endsWith(upgrade)); + assert( + read(topologySql + 'postgis_topology--unpackaged--3.6.4.sql').endsWith( + read(topologySql + 'topology_upgrade.sql'), + ), + ); + assert.equal( + read(tigerSql + 'postgis_tiger_geocoder--unpackaged--3.6.4.sql'), + 'ALTER EXTENSION postgis_tiger_geocoder ADD SCHEMA tiger;\n', + ); } finally { if (originalEpoch === undefined) delete process.env.SOURCE_DATE_EPOCH; else process.env.SOURCE_DATE_EPOCH = originalEpoch; rmSync(root, { recursive: true, force: true }); } }); + +test('Windows tiger SQL follows literal upstream prerequisite order and rejects Make expressions', () => { + const root = mkdtempSync(path.join(tmpdir(), 'postgis-tiger-windows-')); + try { + const makefile = path.join(root, 'Makefile.in'); + writeFileSync( + makefile, + 'sql/tiger_geocoder.sql.in: sql_bits/types.sql.in \\\n ../../extras/functions.sql sql_bits/types.sql.in | sql\n\tcat $^ > $@\n', + ); + assert.deepEqual(postgisSqlInputs(root, 'sql/tiger_geocoder.sql.in'), [ + path.resolve(root, 'sql_bits/types.sql.in'), + path.resolve(root, '../../extras/functions.sql'), + ]); + writeFileSync(makefile, 'sql/tiger_geocoder.sql.in: $(SQL_INPUTS) | sql\n'); + assert.throws( + () => postgisSqlInputs(root, 'sql/tiger_geocoder.sql.in'), + /unsupported PostGIS SQL prerequisites/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('Windows embedded PostGIS builds every declared native module and rejects invalid paths', () => { + const root = mkdtempSync(path.join(tmpdir(), 'postgis-modules-windows-')); + try { + const manifest = path.join(root, 'src/extensions/external/postgis/targets/native.toml'); + mkdirSync(path.dirname(manifest), { recursive: true }); + writeFileSync( + manifest, + 'module_files = ["lib/postgresql/postgis-3.so", "lib/postgresql/postgis_raster-3.so", "lib/postgresql/postgis_topology-3.so"]\n', + ); + assert.deepEqual(postgisModuleStems({ repo: root }), [ + 'postgis-3', + 'postgis_raster-3', + 'postgis_topology-3', + ]); + writeFileSync(manifest, 'module_files = ["../postgis-3.so"]\n'); + assert.throws(() => postgisModuleStems({ repo: root }), /invalid PostGIS native module/); + writeFileSync( + manifest, + 'module_files = ["lib/postgresql/postgis-3.so", "lib/postgresql/postgis-3.so"]\n', + ); + assert.throws(() => postgisModuleStems({ repo: root }), /duplicate PostGIS native modules/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts b/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts index b9113a2d1..7b89b777c 100644 --- a/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts +++ b/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { createHash } from 'node:crypto'; import { + chmodSync, copyFileSync, cpSync, existsSync, @@ -10,25 +11,15 @@ import { rmSync, statSync, writeFileSync, - chmodSync, } from 'node:fs'; import path from 'node:path'; - +import { createDeterministicTar } from '../../../../../tools/packaging/cargo-source-package.mts'; +import { canonicalGzipSync } from '../../../../../tools/packaging/portable-archive.mts'; import { assertReleaseNoticesInArchive, stageReleaseNotices, } from '../../../../../tools/packaging/release-notices.mts'; import { - assertExtensionUpstreamLicensesInArchive, - extensionCarrierLegalContract, - stageExtensionUpstreamLicenses, -} from '../../../tools/extension-upstream-licenses.mts'; - -import { createDeterministicTar } from '../../../../../tools/packaging/cargo-source-package.mts'; -import { extensionRuntimeAssetContract } from './extension-runtime-asset-contract.mts'; -import { canonicalGzipSync } from '../../../../../tools/packaging/portable-archive.mts'; -import { - ROOT, compareText, currentProductVersionSync, exactExtensionProducts, @@ -39,18 +30,25 @@ import { extensionReleaseVersion, extensionSourceIdentity, extensionSqlNames, + ROOT, } from '../../../../../tools/release/release-artifact-targets.mts'; import { swiftExtensionCarrierAssetName, writeSwiftExtensionCarrierManifest, } from '../../../../native/sdks/swift/tools/ios-carrier-manifest.mts'; -import { AOT_TARGET_TRIPLES } from '../../../../wasix/runtime/tools/wasix-cargo-artifact-contract.mts'; import { assertCanonicalWasixAotManifest } from '../../../../wasix/runtime/tools/wasix-aot-manifest.mts'; +import { AOT_TARGET_TRIPLES } from '../../../../wasix/runtime/tools/wasix-cargo-artifact-contract.mts'; import { assertWasixExtensionArchiveInstall, assertWasixExtensionInstallSidecar, projectWasixExtensionInstallSidecar, } from '../../../contracts/wasix-extension-install.mts'; +import { + assertExtensionUpstreamLicensesInArchive, + extensionCarrierLegalContract, + stageExtensionUpstreamLicenses, +} from '../../../tools/extension-upstream-licenses.mts'; +import { extensionRuntimeAssetContract } from './extension-runtime-asset-contract.mts'; const PREFIX = 'build-extension-ci-artifacts.mts'; @@ -700,6 +698,11 @@ function stageMember( `${sqlName}.extension-sql-file-prefixes`, ), nativeModuleStem: extensionRow['native-module-stem'], + sqlMembers: stringList(extensionRow['sql-members'], `${sqlName}.sql-members`), + nativeModuleStems: stringList( + extensionRow['native-module-stems'], + `${sqlName}.native-module-stems`, + ), iosNativeDependencies: assets .filter((asset) => asset.kind === 'ios-dependency-xcframework') .map((asset) => asset.identity) @@ -878,6 +881,12 @@ export function extensionReleasePropertiesText({ `extensionSqlFileNames=${propertiesCsv(manifest.extensionSqlFileNames)}\n`, `extensionSqlFilePrefixes=${propertiesCsv(manifest.extensionSqlFilePrefixes)}\n`, `nativeModuleStem=${manifest.nativeModuleStem || ''}\n`, + ...(manifest.sqlMembers === undefined + ? [] + : [ + `sqlMembers=${propertiesCsv(manifest.sqlMembers)}\n`, + `nativeModuleStems=${propertiesCsv(manifest.nativeModuleStems)}\n`, + ]), `iosNativeDependencies=${propertiesCsv(manifest.iosNativeDependencies)}\n`, `sharedPreloadLibraries=${propertiesCsv(manifest.sharedPreloadLibraries)}\n`, ); @@ -904,6 +913,12 @@ export function extensionReleasePropertiesText({ `${prefix}.extensionSqlFileNames=${propertiesCsv(member.extensionSqlFileNames)}\n`, `${prefix}.extensionSqlFilePrefixes=${propertiesCsv(member.extensionSqlFilePrefixes)}\n`, `${prefix}.nativeModuleStem=${member.nativeModuleStem || ''}\n`, + ...(member.sqlMembers === undefined + ? [] + : [ + `${prefix}.sqlMembers=${propertiesCsv(member.sqlMembers)}\n`, + `${prefix}.nativeModuleStems=${propertiesCsv(member.nativeModuleStems)}\n`, + ]), `${prefix}.iosNativeDependencies=${propertiesCsv(member.iosNativeDependencies)}\n`, `${prefix}.sharedPreloadLibraries=${propertiesCsv(member.sharedPreloadLibraries)}\n`, ); @@ -1087,6 +1102,8 @@ function stageProductVariant( extensionSqlFilePrefixes: member.extensionSqlFilePrefixes, createsExtension: member.createsExtension, nativeModuleStem: member.nativeModuleStem, + sqlMembers: member.sqlMembers, + nativeModuleStems: member.nativeModuleStems, iosNativeDependencies: member.iosNativeDependencies, iosRegistration: member.iosRegistration, wasixInstall: member.wasixInstall, diff --git a/src/extensions/artifacts/packages/tools/check-carriers.mts b/src/extensions/artifacts/packages/tools/check-carriers.mts index 33118746f..0b1debddc 100644 --- a/src/extensions/artifacts/packages/tools/check-carriers.mts +++ b/src/extensions/artifacts/packages/tools/check-carriers.mts @@ -1,10 +1,6 @@ import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; -import { - buildSwiftExtensionCarrierManifest, - swiftExtensionCarrierAssetName, -} from '../../../../native/sdks/swift/tools/ios-carrier-manifest.mts'; import { portableMemberName, readFileOnlyTarGzipEntries, @@ -36,6 +32,10 @@ import { extensionSqlNames, ROOT, } from '../../../../../tools/release/release-artifact-targets.mts'; +import { + buildSwiftExtensionCarrierManifest, + swiftExtensionCarrierAssetName, +} from '../../../../native/sdks/swift/tools/ios-carrier-manifest.mts'; import { assertWasixExtensionMemberInstall } from '../../../contracts/wasix-extension-install.mts'; import { assertExtensionUpstreamLicensesInArchive, @@ -242,6 +242,9 @@ function requireExactKeys(value, expected, context) { if (value === null || Array.isArray(value) || typeof value !== 'object') { fail(`${context} must be an object`); } + if (expected.has('nativeModuleStem') && expected.has('sqlName')) { + expected = new Set([...expected, ...Object.keys(optionalFrozenClosure(value, context))]); + } const actual = new Set(Object.keys(value)); if (!setEquals(actual, expected)) { fail( @@ -250,6 +253,24 @@ function requireExactKeys(value, expected, context) { } } +function optionalFrozenClosure(member, context) { + if (member.sqlMembers === undefined && member.nativeModuleStems === undefined) return {}; + for (const key of ['sqlMembers', 'nativeModuleStems']) { + requireSortedUniqueStrings(member[key], `${context}.${key}`); + if (member[key].some((name) => !/^[A-Za-z0-9._-]{1,128}$/u.test(name))) + fail(`${context}.${key} must contain portable identifiers`); + } + if (member.createsExtension && !member.sqlMembers.includes(member.sqlName)) + fail(`${context}.sqlMembers must include its primary SQL extension`); + if ( + member.nativeModuleStem === null + ? member.nativeModuleStems.length > 0 + : !member.nativeModuleStems.includes(member.nativeModuleStem) + ) + fail(`${context}.nativeModuleStems must include its primary native module`); + return { sqlMembers: member.sqlMembers, nativeModuleStems: member.nativeModuleStems }; +} + function requireSortedUniqueStrings(value, context) { if ( !Array.isArray(value) || @@ -854,6 +875,8 @@ async function checkExtensionBundleProduct( expectedProperties[`${prefix}.extensionSqlFilePrefixes`] = member.extensionSqlFilePrefixes.join(','); expectedProperties[`${prefix}.nativeModuleStem`] = member.nativeModuleStem ?? ''; + for (const [key, values] of Object.entries(optionalFrozenClosure(member, prefix))) + expectedProperties[`${prefix}.${key}`] = values.join(','); expectedProperties[`${prefix}.iosNativeDependencies`] = member.iosNativeDependencies.join(','); expectedProperties[`${prefix}.sharedPreloadLibraries`] = member.sharedPreloadLibraries.join(','); @@ -1115,6 +1138,7 @@ async function checkExtensionProductVariant( extensionSqlFileNames: data.extensionSqlFileNames, extensionSqlFilePrefixes: data.extensionSqlFilePrefixes, nativeModuleStem: data.nativeModuleStem, + ...optionalFrozenClosure(data, rel(manifest)), iosNativeDependencies: data.iosNativeDependencies, iosRegistration: data.iosRegistration, wasixInstall: data.wasixInstall, @@ -1186,6 +1210,12 @@ async function checkExtensionProductVariant( extensionSqlFileNames: data.extensionSqlFileNames.join(','), extensionSqlFilePrefixes: data.extensionSqlFilePrefixes.join(','), nativeModuleStem: data.nativeModuleStem ?? '', + ...Object.fromEntries( + Object.entries(optionalFrozenClosure(data, rel(manifest))).map(([key, values]) => [ + key, + values.join(','), + ]), + ), iosNativeDependencies: data.iosNativeDependencies.join(','), sharedPreloadLibraries: data.sharedPreloadLibraries.join(','), }; diff --git a/src/extensions/artifacts/packages/tools/check-carriers.test.mts b/src/extensions/artifacts/packages/tools/check-carriers.test.mts index b603d9117..e178cd1e3 100644 --- a/src/extensions/artifacts/packages/tools/check-carriers.test.mts +++ b/src/extensions/artifacts/packages/tools/check-carriers.test.mts @@ -1,8 +1,8 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; -import { expectedExtensionBundleManifest } from './check-carriers.mts'; -import { extensionReleasePropertiesText } from './build-extension-ci-artifacts.mts'; +import test from 'node:test'; import { parseUniquePropertiesText } from '../../../../../tools/packaging/release-carrier.mts'; +import { extensionReleasePropertiesText } from './build-extension-ci-artifacts.mts'; +import { expectedExtensionBundleManifest } from './check-carriers.mts'; test('derives nested bundle compatibility from the staged bundle data', () => { const compatibility = { @@ -95,6 +95,8 @@ test('renders every single-extension asset identity into the public properties m extensionSqlFileNames: ['uninstall_postgis.sql'], extensionSqlFilePrefixes: ['postgis_comments', 'rtpostgis'], nativeModuleStem: 'postgis-3', + sqlMembers: ['postgis', 'postgis_raster', 'postgis_tiger_geocoder', 'postgis_topology'], + nativeModuleStems: ['postgis-3', 'postgis_raster-3', 'postgis_topology-3'], iosNativeDependencies: dependencyIdentities, sharedPreloadLibraries: [], assets, @@ -128,6 +130,11 @@ test('renders every single-extension asset identity into the public properties m assert.equal(properties.dataFiles, 'contrib/postgis-3.6/postgis.sql,proj/proj.db'); assert.equal(properties.extensionSqlFileNames, 'uninstall_postgis.sql'); assert.equal(properties.extensionSqlFilePrefixes, 'postgis_comments,rtpostgis'); + assert.equal( + properties.sqlMembers, + 'postgis,postgis_raster,postgis_tiger_geocoder,postgis_topology', + ); + assert.equal(properties.nativeModuleStems, 'postgis-3,postgis_raster-3,postgis_topology-3'); assert.doesNotMatch(text, /^carrier\./mu); }); @@ -146,6 +153,8 @@ test('freezes each bundle member desktop inventory in the public properties mani extensionSqlFileNames: ['uninstall_pgtap.sql'], extensionSqlFilePrefixes: ['pgtap-core', 'pgtap-schema'], nativeModuleStem: null, + sqlMembers: ['pgtap'], + nativeModuleStems: [], iosNativeDependencies: [], sharedPreloadLibraries: [], assets: [], @@ -166,4 +175,6 @@ test('freezes each bundle member desktop inventory in the public properties mani assert.equal(properties['extension.pgtap.dataFiles'], ''); assert.equal(properties['extension.pgtap.extensionSqlFileNames'], 'uninstall_pgtap.sql'); assert.equal(properties['extension.pgtap.extensionSqlFilePrefixes'], 'pgtap-core,pgtap-schema'); + assert.equal(properties['extension.pgtap.sqlMembers'], 'pgtap'); + assert.equal(properties['extension.pgtap.nativeModuleStems'], ''); }); diff --git a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts index 96f8ca457..11494e127 100644 --- a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts +++ b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { closeSync, constants, + existsSync, fstatSync, lstatSync, openSync, @@ -11,14 +12,13 @@ import { import path from 'node:path'; import { TextDecoder } from 'node:util'; import { strictGunzip } from '../../../../../tools/packaging/portable-archive.mts'; - +import { releaseNoticeRows } from '../../../../../tools/packaging/release-notices.mts'; +import { extensionProductForSqlName } from '../../../../../tools/release/release-artifact-targets.mts'; import { EXTENSION_ARTIFACT_ARCHIVE_POLICY } from '../../../tools/extension-artifact-archive-policy.mts'; import { extensionCarrierLegalContract, extensionUpstreamLicenseRow, } from '../../../tools/extension-upstream-licenses.mts'; -import { extensionProductForSqlName } from '../../../../../tools/release/release-artifact-targets.mts'; -import { releaseNoticeRows } from '../../../../../tools/packaging/release-notices.mts'; export const EXTENSION_ARTIFACT_PROPERTY_KEYS = Object.freeze([ 'packageLayout', @@ -61,6 +61,7 @@ const DESKTOP_NATIVE_TARGETS = new Set([ 'macos-arm64', 'windows-x64-msvc', ]); +const FROZEN_CLOSURE_KEYS = ['sqlMembers', 'nativeModuleStems']; function inventoryError(label, message) { return new Error(`${label}: ${message}`); @@ -145,15 +146,19 @@ export function parseExtensionArtifactProperties(text, label) { } properties.set(key, rawLine.slice(separator + 1)); } + const hasClosure = FROZEN_CLOSURE_KEYS.some((key) => properties.has(key)); + const orderedKeys = hasClosure + ? [...EXTENSION_ARTIFACT_PROPERTY_KEYS.slice(0, -1), ...FROZEN_CLOSURE_KEYS, 'files'] + : EXTENSION_ARTIFACT_PROPERTY_KEYS; const actual = [...properties.keys()].sort(compareText); - const expected = [...EXTENSION_ARTIFACT_PROPERTY_KEYS].sort(compareText); + const expected = [...orderedKeys].sort(compareText); if (JSON.stringify(actual) !== JSON.stringify(expected)) { throw inventoryError( label, `property fields must be exactly ${expected.join(',')}; got ${actual.join(',')}`, ); } - if (JSON.stringify([...properties.keys()]) !== JSON.stringify(EXTENSION_ARTIFACT_PROPERTY_KEYS)) { + if (JSON.stringify([...properties.keys()]) !== JSON.stringify(orderedKeys)) { throw inventoryError(label, 'properties must use the canonical field order'); } return properties; @@ -487,8 +492,51 @@ function moduleSuffix(target) { return '.so'; } +// The target recipe owns native modules; the common recipe also lists WASIX-only dependencies. +export function extensionNativeArtifactClosure(sqlName, target) { + if (!PORTABLE_ID.test(sqlName)) throw inventoryError(sqlName, 'invalid extension SQL name'); + const directory = path.resolve(import.meta.dir, '../../../external', sqlName); + const read = (file) => { + const full = path.join(directory, file); + return existsSync(full) ? Bun.TOML.parse(readFileSync(full, 'utf8')) : {}; + }; + const controls = (read('recipe.toml').artifacts?.control_files ?? []).map((file) => { + const normalized = safeRelativePath(file, `${sqlName} control file`); + const name = normalized.slice('share/postgresql/extension/'.length); + if ( + normalized !== `share/postgresql/extension/${name}` || + !name.endsWith('.control') || + !PORTABLE_ID.test(name.slice(0, -'.control'.length)) + ) { + throw inventoryError(sqlName, `invalid extension control file ${file}`); + } + return name; + }); + const modules = (read('targets/native.toml').module_files ?? []).map((file) => { + const normalized = safeRelativePath(file, `${sqlName} native module`); + const name = normalized.slice('lib/postgresql/'.length); + if (normalized !== `lib/postgresql/${name}` || !PORTABLE_ID.test(name)) { + throw inventoryError(sqlName, `invalid native module ${file}`); + } + if (!/\.(so|dylib|dll)$/u.test(name)) { + throw inventoryError(sqlName, `invalid native module suffix ${file}`); + } + return name.replace(/\.(so|dylib|dll)$/u, moduleSuffix(target)); + }); + return { + controls: [...new Set(controls)].sort(compareText), + modules: [...new Set(modules)].sort(compareText), + }; +} + function sqlFileOwned(fileName, metadata) { return ( + metadata.companionControls?.includes(fileName) || + metadata.companionControls?.some( + (control) => + fileName.startsWith(`${control.slice(0, -'.control'.length)}--`) && + fileName.endsWith('.sql'), + ) || (metadata.createsExtension && fileName === `${metadata.sqlName}.control`) || (metadata.createsExtension && fileName === `${metadata.sqlName}.sql`) || (metadata.createsExtension && @@ -740,6 +788,41 @@ export function validateExtensionArtifactEntries({ decodeUtf8(manifestEntry.data, `${label} manifest.properties`), `${label} manifest.properties`, ); + // Old independently versioned artifacts only promised their primary member/module. + // A current SDK catalog must not silently add new requirements to those artifacts. + const sqlMembers = properties.has('sqlMembers') + ? csv(properties.get('sqlMembers'), `${label} sqlMembers`) + : metadata.createsExtension + ? [metadata.sqlName] + : []; + const nativeModuleStems = properties.has('nativeModuleStems') + ? csv(properties.get('nativeModuleStems'), `${label} nativeModuleStems`) + : metadata.nativeModuleStem === null + ? [] + : [metadata.nativeModuleStem]; + if ( + [...sqlMembers, ...nativeModuleStems].some((name) => !PORTABLE_ID.test(name)) || + (metadata.createsExtension && !sqlMembers.includes(metadata.sqlName)) || + (!metadata.createsExtension && sqlMembers.length > 0) || + (metadata.nativeModuleStem === null + ? nativeModuleStems.length > 0 + : !nativeModuleStems.includes(metadata.nativeModuleStem)) + ) + throw inventoryError(label, 'invalid frozen SQL member or native module closure'); + for (const [key, actual] of [ + ['sqlMembers', sqlMembers], + ['nativeModuleStems', nativeModuleStems], + ]) { + if ( + rawMetadata[key] !== undefined && + (!properties.has(key) || JSON.stringify(rawMetadata[key]) !== JSON.stringify(actual)) + ) { + throw inventoryError(label, `manifest ${key} must match the frozen release inventory`); + } + } + metadata.companionControls = sqlMembers + .filter((name) => name !== metadata.sqlName) + .map((name) => `${name}.control`); const legal = canonicalLegalContract(metadata, target, label); const expectedProperties = new Map([ ['packageLayout', 'oliphaunt-extension-artifact-v1'], @@ -824,13 +907,26 @@ export function validateExtensionArtifactEntries({ label, }); } + for (const controlFile of metadata.companionControls) { + const controlName = `${extensionPrefix}${controlFile}`; + const control = entries.get(controlName); + if (control === undefined) + throw inventoryError(label, `missing companion control ${controlName}`); + validateExtensionInstallSqlReachability({ + sqlName: controlFile.slice(0, -'.control'.length), + control: decodeUtf8(control.data, `${label} ${controlName}`), + fileNames: extensionFileNames, + label, + }); + } for (const dataFile of metadata.dataFiles) { allowed.add(`files/share/postgresql/${dataFile}`); } - if (metadata.nativeModuleStem !== null) { - allowed.add(`files/lib/postgresql/${properties.get('nativeModuleFile')}`); + const modules = new Set(nativeModuleStems.map((stem) => `${stem}${moduleSuffix(target)}`)); + for (const module of modules) { + allowed.add(`files/lib/postgresql/${module}`); if (DESKTOP_NATIVE_TARGETS.has(target)) { - allowed.add(`files/lib/modules/${properties.get('nativeModuleFile')}`); + allowed.add(`files/lib/modules/${module}`); } } const mobilePaths = declaredMobilePaths(properties, metadata, label); diff --git a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts index 2740113a5..5cf59dda4 100644 --- a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts +++ b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts @@ -25,6 +25,10 @@ import { parseExtensionArtifactProperties, validateExtensionArtifactArchive, } from './extension-artifact-inventory.mts'; +import { + extractExtensionRuntime, + frozenExtensionMemberInventory, +} from './package-extension-release-carriers.mts'; function writeString(buffer, offset, length, value) { const bytes = Buffer.from(value); @@ -137,7 +141,16 @@ function manifest(overrides = {}) { files: 'files', ...overrides, }; - return `${EXTENSION_ARTIFACT_PROPERTY_KEYS.map((key) => `${key}=${values[key]}`).join('\n')}\n`; + const keys = + overrides.sqlMembers === undefined + ? EXTENSION_ARTIFACT_PROPERTY_KEYS + : [ + ...EXTENSION_ARTIFACT_PROPERTY_KEYS.slice(0, -1), + 'sqlMembers', + 'nativeModuleStems', + 'files', + ]; + return `${keys.map((key) => `${key}=${values[key]}`).join('\n')}\n`; } async function canonicalLegalEntries(root, sqlName, target = 'linux-x64-gnu') { @@ -567,6 +580,8 @@ async function main() { sqlName: 'postgis', createsExtension: true, nativeModuleStem: 'postgis-3', + sqlMembers: ['postgis', 'postgis_raster', 'postgis_tiger_geocoder', 'postgis_topology'], + nativeModuleStems: ['postgis-3', 'postgis_raster-3', 'postgis_topology-3'], dependencies: [], dataFiles: [ 'contrib/postgis-3.6/legacy.sql', @@ -591,6 +606,8 @@ async function main() { sqlName: 'postgis', nativeModuleStem: 'postgis-3', nativeModuleFile: 'postgis-3.so', + sqlMembers: postgisMetadata.sqlMembers.join(','), + nativeModuleStems: postgisMetadata.nativeModuleStems.join(','), dataFiles: postgisMetadata.dataFiles.join(','), extensionSqlFileNames: postgisMetadata.extensionSqlFileNames.join(','), extensionSqlFilePrefixes: postgisMetadata.extensionSqlFilePrefixes.join(','), @@ -598,6 +615,14 @@ async function main() { ], ['files/lib/postgresql/postgis-3.so', 'module'], ['files/lib/modules/postgis-3.so', 'embedded module'], + ...['postgis_raster-3', 'postgis_topology-3'].flatMap((stem) => [ + [`files/lib/postgresql/${stem}.so`, 'module'], + [`files/lib/modules/${stem}.so`, 'embedded module'], + ]), + ...['postgis_raster', 'postgis_topology', 'postgis_tiger_geocoder'].flatMap((name) => [ + [`files/share/postgresql/extension/${name}.control`, "default_version = '3.6.3'\n"], + [`files/share/postgresql/extension/${name}--3.6.3.sql`, 'install'], + ]), ['files/share/postgresql/extension/postgis--3.6.1--3.6.2.sql', 'upgrade'], ['files/share/postgresql/extension/postgis--3.6.2--3.6.3.sql', 'upgrade'], ['files/share/postgresql/extension/postgis--3.6.3.sql', 'install'], @@ -609,6 +634,14 @@ async function main() { ...postgisLegal, ]); const postgisFile = await writeArchive(root, 'postgis-legitimate.tar.gz', postgis); + assert.throws( + () => + parseExtensionArtifactProperties( + postgis.get('manifest.properties').replace(/^nativeModuleStems=.*\n/mu, ''), + 'partial frozen closure', + ), + /property fields/u, + ); const validatedPostgis = validateExtensionArtifactArchive({ file: postgisFile, metadata: postgisMetadata, @@ -616,6 +649,28 @@ async function main() { nativeRuntimeVersion: '1.2.3', label: 'postgis legitimate', }); + const extracted = path.join(root, 'extracted-postgis'); + extractExtensionRuntime(postgisFile, extracted, { + metadata: postgisMetadata, + target: 'linux-x64-gnu', + nativeRuntimeVersion: '1.2.3', + }); + assert.equal( + await fs.readFile(path.join(extracted, '.oliphaunt/extensions/postgis.properties'), 'utf8'), + postgis.get('manifest.properties'), + ); + assert.equal( + await fs.readFile(path.join(extracted, 'lib/modules/postgis_topology-3.so'), 'utf8'), + 'embedded module', + ); + assert.deepEqual( + frozenExtensionMemberInventory(postgisMetadata).nativeModuleStems, + postgisMetadata.nativeModuleStems, + ); + assert.throws( + () => frozenExtensionMemberInventory({ ...postgisMetadata, nativeModuleStems: undefined }), + /nativeModuleStems/u, + ); assert.equal( validatedPostgis.runtimeFiles.length, [...postgis.keys()].filter((member) => member.startsWith('files/')).length, @@ -624,6 +679,44 @@ async function main() { validatedPostgis.legalFiles.map(({ path: member }) => member), [...postgisLegal.keys()].sort(), ); + const legacyPostgis = new Map( + [...postgis].filter(([name]) => !/postgis_(raster|topology|tiger_geocoder)/u.test(name)), + ); + legacyPostgis.set( + 'manifest.properties', + legacyPostgis + .get('manifest.properties') + .split('\n') + .filter((line) => !/^(sqlMembers|nativeModuleStems)=/u.test(line)) + .join('\n'), + ); + const legacyFile = await writeArchive(root, 'postgis-legacy-core-only.tar.gz', legacyPostgis); + validateExtensionArtifactArchive({ + file: legacyFile, + metadata: { ...postgisMetadata, sqlMembers: undefined, nativeModuleStems: undefined }, + target: 'linux-x64-gnu', + nativeRuntimeVersion: '1.2.3', + label: 'legacy PostGIS', + }); + assert.equal( + frozenExtensionMemberInventory({ + ...postgisMetadata, + sqlMembers: undefined, + nativeModuleStems: undefined, + }).sqlMembers, + undefined, + ); + assert.throws( + () => + validateExtensionArtifactArchive({ + file: legacyFile, + metadata: postgisMetadata, + target: 'linux-x64-gnu', + nativeRuntimeVersion: '1.2.3', + label: 'new frozen PostGIS rejects incomplete legacy payload', + }), + /frozen release inventory/u, + ); const missingEmbeddedPostgis = new Map(postgis); missingEmbeddedPostgis.delete('files/lib/modules/postgis-3.so'); diff --git a/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts b/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts index 67ee51748..f0e88ca0a 100644 --- a/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts +++ b/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts @@ -19,22 +19,11 @@ import { writeFileSync, } from 'node:fs'; import path from 'node:path'; -import { extensionRuntimeAssetContract } from './extension-runtime-asset-contract.mts'; -import { RUST_BUILD_SCRIPT_SHA256 } from '../../../../../tools/packaging/rust-build-script-sha256.mts'; -import { - AOT_TARGET_TRIPLES, - CORE_RUNTIME_ARCHIVE_FILES, -} from '../../../../wasix/runtime/tools/wasix-cargo-artifact-contract.mts'; -import { parseNpmExtensionLicenseFiles } from '../../../../native/sdks/ts/src/native/extension-contract.ts'; import { fitCargoPayloadParts, packageGeneratedCargoSource, readCargoPackageNameVersion, } from '../../../../../tools/packaging/cargo-source-package.mts'; -import { - buildIosCarrierManifest, - IOS_CARRIER_FILENAME, -} from '../../../../native/sdks/swift/tools/ios-carrier-manifest.mts'; import { packGeneratedNpmCarrier } from '../../../../../tools/packaging/npm-package.mts'; import { NPM_TRUSTED_PUBLISHING_REPOSITORY } from '../../../../../tools/packaging/npm-trusted-publishing.mts'; import { @@ -47,6 +36,22 @@ import { releaseNoticeRows, stageReleaseNotices, } from '../../../../../tools/packaging/release-notices.mts'; +import { RUST_BUILD_SCRIPT_SHA256 } from '../../../../../tools/packaging/rust-build-script-sha256.mts'; +import { + compareText, + extensionRegistryPackageTargetSets, +} from '../../../../../tools/release/release-artifact-targets.mts'; +import { + buildIosCarrierManifest, + IOS_CARRIER_FILENAME, +} from '../../../../native/sdks/swift/tools/ios-carrier-manifest.mts'; +import { parseNpmExtensionLicenseFiles } from '../../../../native/sdks/ts/src/native/extension-contract.ts'; +import { assertWasixAotArtifactPayloads } from '../../../../wasix/runtime/tools/check-release-assets.mts'; +import { assertCanonicalWasixAotManifest } from '../../../../wasix/runtime/tools/wasix-aot-manifest.mts'; +import { + AOT_TARGET_TRIPLES, + CORE_RUNTIME_ARCHIVE_FILES, +} from '../../../../wasix/runtime/tools/wasix-cargo-artifact-contract.mts'; import { assertWasixExtensionArchiveInstall, assertWasixExtensionInstall, @@ -56,10 +61,6 @@ import { WASIX_EXTENSION_INSTALL_SCHEMA, WASIX_EXTENSION_INSTALL_SIDECAR_SCHEMA, } from '../../../contracts/wasix-extension-install.mts'; -import { - compareText, - extensionRegistryPackageTargetSets, -} from '../../../../../tools/release/release-artifact-targets.mts'; import { assertExtensionUpstreamLicensesInArchive, assertExtensionUpstreamLicensesInDirectory, @@ -71,15 +72,13 @@ import { validateExtensionArtifactArchive } from './extension-artifact-inventory import { extensionNpmPackageForProduct, extensionNpmTargetPackageForProduct, - extensionNpmWasixPackageForProduct, extensionNpmWasixAotTargets, + extensionNpmWasixPackageForProduct, nativeExtensionCargoLinksName, nativeExtensionCargoPackageName, nativeExtensionCargoPartPackageName, } from './extension-registry-packages.mts'; - -import { assertCanonicalWasixAotManifest } from '../../../../wasix/runtime/tools/wasix-aot-manifest.mts'; -import { assertWasixAotArtifactPayloads } from '../../../../wasix/runtime/tools/check-release-assets.mts'; +import { extensionRuntimeAssetContract } from './extension-runtime-asset-contract.mts'; const ROOT = path.resolve(import.meta.dirname, '../../../../..'); const TOOL = 'package-extension-release-carriers.mts'; @@ -499,7 +498,11 @@ export function frozenExtensionMemberInventory(member, { product, version } = {} createsExtension: member.createsExtension, nativeModuleStem: member.nativeModuleStem, }; - for (const field of FROZEN_EXTENSION_INVENTORY_LIST_FIELDS) { + const closureFields = + member.sqlMembers === undefined && member.nativeModuleStems === undefined + ? [] + : ['sqlMembers', 'nativeModuleStems']; + for (const field of [...FROZEN_EXTENSION_INVENTORY_LIST_FIELDS, ...closureFields]) { const values = member[field]; if ( !Array.isArray(values) || @@ -1018,7 +1021,11 @@ function checkedArchiveMemberPath(name, archive) { return parts.join('/'); } -function extractExtensionRuntime(asset, runtimeDir, { metadata, target, nativeRuntimeVersion }) { +export function extractExtensionRuntime( + asset, + runtimeDir, + { metadata, target, nativeRuntimeVersion }, +) { // Native release assets are stripped and platform-validated on their target // builders. Carrier assembly preserves those qualified bytes; host-side // binary rewriting would make output coordinator-dependent. @@ -1048,6 +1055,16 @@ function extractExtensionRuntime(asset, runtimeDir, { metadata, target, nativeRu writeFileSync(destination, entry.data, { flag: 'wx', mode: entry.mode }); chmodSync(destination, entry.mode); } + const manifestPath = path.join( + runtimeDir, + '.oliphaunt/extensions', + `${metadata.sqlName}.properties`, + ); + mkdirSync(path.dirname(manifestPath), { recursive: true }); + writeFileSync(manifestPath, validated.entries.get('manifest.properties').data, { + flag: 'wx', + mode: 0o644, + }); return validated.runtimeFiles; } diff --git a/src/extensions/catalog/extensions.source.json b/src/extensions/catalog/extensions.source.json index 6afb49f57..0fd1d6883 100644 --- a/src/extensions/catalog/extensions.source.json +++ b/src/extensions/catalog/extensions.source.json @@ -996,7 +996,7 @@ "requires": [], "relocatable": "false" }, - "dependencies": [], + "dependencies": ["fuzzystrmatch"], "load-order": ["lib/postgresql/postgis-3.so"], "lifecycle": { "create-extension": true, diff --git a/src/extensions/external/postgis/recipe.toml b/src/extensions/external/postgis/recipe.toml index 4a0b79d97..8ab503f98 100644 --- a/src/extensions/external/postgis/recipe.toml +++ b/src/extensions/external/postgis/recipe.toml @@ -24,21 +24,28 @@ trusted = false control_files = [ "share/postgresql/extension/postgis.control", "share/postgresql/extension/postgis_raster.control", + "share/postgresql/extension/postgis_topology.control", + "share/postgresql/extension/postgis_tiger_geocoder.control", ] sql_globs = [ "share/postgresql/extension/postgis--*.sql", "share/postgresql/extension/postgis_raster--*.sql", + "share/postgresql/extension/postgis_topology--*.sql", + "share/postgresql/extension/postgis_tiger_geocoder--*.sql", ] extension_sql_file_prefixes = [ "postgis_comments", "postgis_proc_set_search_path", "postgis_raster", + "postgis_topology", + "postgis_tiger_geocoder", "rtpostgis", ] extension_sql_file_names = ["uninstall_postgis.sql"] native_modules = ["lib/postgresql/postgis-3.so"] native_dependency_modules = [ "lib/postgresql/postgis_raster-3.so", + "lib/postgresql/postgis_topology-3.so", "lib/postgresql/liboliphaunt_postgis_deps.so", ] data_files = [ diff --git a/src/extensions/external/postgis/targets/native-static-registry.toml b/src/extensions/external/postgis/targets/native-static-registry.toml index 569cd4447..1fb49d877 100644 --- a/src/extensions/external/postgis/targets/native-static-registry.toml +++ b/src/extensions/external/postgis/targets/native-static-registry.toml @@ -47,15 +47,12 @@ data_files = [ "share/postgresql/proj/proj.db", ] configure_flags = [ - "--without-topology", "--without-sfcgal", "--without-address-standardizer", "--without-protobuf", ] excluded_sql_extensions = [ - "postgis_topology", "postgis_sfcgal", - "postgis_tiger_geocoder", "address_standardizer", "address_standardizer_data_us", ] diff --git a/src/extensions/external/postgis/targets/native.toml b/src/extensions/external/postgis/targets/native.toml index d8ca7f3d6..aa411bcca 100644 --- a/src/extensions/external/postgis/targets/native.toml +++ b/src/extensions/external/postgis/targets/native.toml @@ -4,6 +4,7 @@ build_kind = "autotools" module_files = [ "lib/postgresql/postgis-3.so", "lib/postgresql/postgis_raster-3.so", + "lib/postgresql/postgis_topology-3.so", ] data_files = [ "share/postgresql/gdal/GDALLogoBW.svg", @@ -43,15 +44,12 @@ data_files = [ "share/postgresql/proj/proj.db", ] configure_flags = [ - "--without-topology", "--without-sfcgal", "--without-address-standardizer", "--without-protobuf", ] excluded_sql_extensions = [ - "postgis_topology", "postgis_sfcgal", - "postgis_tiger_geocoder", "address_standardizer", "address_standardizer_data_us", ] diff --git a/src/extensions/external/postgis/targets/wasix.toml b/src/extensions/external/postgis/targets/wasix.toml index 78ead3d7d..8e17b0d73 100644 --- a/src/extensions/external/postgis/targets/wasix.toml +++ b/src/extensions/external/postgis/targets/wasix.toml @@ -5,6 +5,7 @@ build_script = "src/extensions/external/postgis/tools/build_wasix.sh" module_files = [ "lib/postgresql/postgis-3.so", "lib/postgresql/postgis_raster-3.so", + "lib/postgresql/postgis_topology-3.so", "lib/postgresql/liboliphaunt_postgis_deps.so", ] data_files = [ @@ -45,10 +46,8 @@ data_files = [ "share/postgresql/proj/proj.db", ] configure_flags = [ - "--without-topology", "--without-sfcgal", "--without-address-standardizer", - "--without-tiger", "--without-protobuf", "--disable-nls", ] @@ -56,19 +55,22 @@ required_build_files = [ "postgis/postgis-3.so", "postgis/liboliphaunt_postgis_deps.so", "raster/rt_pg/postgis_raster-3.so", + "topology/postgis_topology-3.so", "extensions/postgis/postgis.control", "extensions/postgis_raster/postgis_raster.control", + "extensions/postgis_topology/postgis_topology.control", + "extensions/postgis_tiger_geocoder/postgis_tiger_geocoder.control", "share/gdal/gdalvrt.xsd", "share/proj/proj.db", ] required_build_globs = [ "extensions/postgis/sql/postgis--*.sql", "extensions/postgis_raster/sql/postgis_raster--*.sql", + "extensions/postgis_topology/sql/postgis_topology--*.sql", + "extensions/postgis_tiger_geocoder/sql/postgis_tiger_geocoder--*.sql", ] excluded_sql_extensions = [ - "postgis_topology", "postgis_sfcgal", - "postgis_tiger_geocoder", "address_standardizer", "address_standardizer_data_us", ] @@ -85,6 +87,12 @@ runtime_path = "lib/postgresql/postgis_raster-3.so" build_path = "postgis/raster/rt_pg/postgis_raster-3.so" aot_file = "postgis_raster-llvm-opta.bin.zst" +[[native_support_modules]] +name = "postgis_topology" +runtime_path = "lib/postgresql/postgis_topology-3.so" +build_path = "postgis/topology/postgis_topology-3.so" +aot_file = "postgis_topology-llvm-opta.bin.zst" + [staging] module_source_dir = "postgis/postgis" control_source = "postgis/extensions/postgis/postgis.control" @@ -99,5 +107,5 @@ source = "postgis/share/gdal" destination = "share/postgresql/gdal" [[staging.data_dirs]] -source = "postgis/share/extension-raster" +source = "postgis/share/extension-members" destination = "share/postgresql/extension" diff --git a/src/extensions/external/postgis/tools/build_wasix.sh b/src/extensions/external/postgis/tools/build_wasix.sh index 8a1ccef90..1b0b2d240 100755 --- a/src/extensions/external/postgis/tools/build_wasix.sh +++ b/src/extensions/external/postgis/tools/build_wasix.sh @@ -86,7 +86,7 @@ $dependency_stamp_block profile=$(oliphaunt_wasix_wasix_profile_signature) wasixcc=$wasixcc_version configure=$(oliphaunt_wasix_extension_configure_signature "$REPO_ROOT" postgis) -link=postgis-raster-plus-selected-support-side-module-v3" +link=postgis-raster-topology-plus-selected-support-side-module-v4" if [ -f "$POSTGIS_BUILD_DIR/.oliphaunt-wasix-postgis-build" ] && oliphaunt_wasix_extension_build_outputs_exist "$REPO_ROOT" postgis "$POSTGIS_BUILD_DIR" --quiet && @@ -359,11 +359,33 @@ EOF -rpath '$ORIGIN' \ -o "$POSTGIS_BUILD_DIR/raster/rt_pg/postgis_raster-3.so" + make -s -j"$JOBS" -C topology postgis_topology.o + make -s -j1 -C topology topology.sql topology_upgrade.sql uninstall_topology.sql + "$wasm_ld" \ + --shared \ + --shared-memory \ + --experimental-pic \ + --unresolved-symbols=import-dynamic \ + --extra-features=atomics,bulk-memory,mutable-globals \ + --export=__wasm_call_ctors \ + --export-if-defined=__wasm_apply_data_relocs \ + --export-all \ + --no-gc-sections \ + "$POSTGIS_BUILD_DIR/topology/postgis_topology.o" \ + "$POSTGIS_BUILD_DIR/libpgcommon/libpgcommon.a" \ + "$POSTGIS_BUILD_DIR/liblwgeom/.libs/liblwgeom.a" \ + --Bdynamic \ + -L"$POSTGIS_BUILD_DIR/postgis" \ + -loliphaunt_postgis_deps \ + -rpath '$ORIGIN' \ + -o "$POSTGIS_BUILD_DIR/topology/postgis_topology-3.so" + # Keep extension SQL generation serial: the PostGIS SQL Makefiles share # generated .tmp files and do not declare complete parallel dependency edges. oliphaunt_wasix_verify_side_module_sjlj "$postgis_deps_module" oliphaunt_wasix_verify_side_module_sjlj "$POSTGIS_BUILD_DIR/postgis/postgis-3.so" oliphaunt_wasix_verify_side_module_sjlj "$POSTGIS_BUILD_DIR/raster/rt_pg/postgis_raster-3.so" + oliphaunt_wasix_verify_side_module_sjlj "$POSTGIS_BUILD_DIR/topology/postgis_topology-3.so" make -s -j1 -C extensions postgis_extension_helper.sql make -s -j1 -C extensions all mkdir -p "$POSTGIS_BUILD_DIR/share" @@ -371,12 +393,14 @@ EOF cp -R "$PROJ_PREFIX/share/proj" "$POSTGIS_BUILD_DIR/share/proj" rm -rf "$POSTGIS_BUILD_DIR/share/gdal" cp -R "$GDAL_PREFIX/share/gdal" "$POSTGIS_BUILD_DIR/share/gdal" - rm -rf "$POSTGIS_BUILD_DIR/share/extension-raster" - mkdir -p "$POSTGIS_BUILD_DIR/share/extension-raster" - cp "$POSTGIS_BUILD_DIR/extensions/postgis_raster/postgis_raster.control" \ - "$POSTGIS_BUILD_DIR/share/extension-raster/" - cp "$POSTGIS_BUILD_DIR/extensions/postgis_raster/sql/"*.sql \ - "$POSTGIS_BUILD_DIR/share/extension-raster/" + rm -rf "$POSTGIS_BUILD_DIR/share/extension-members" + mkdir -p "$POSTGIS_BUILD_DIR/share/extension-members" + for member in postgis_raster postgis_topology postgis_tiger_geocoder; do + cp "$POSTGIS_BUILD_DIR/extensions/$member/$member.control" \ + "$POSTGIS_BUILD_DIR/share/extension-members/" + cp "$POSTGIS_BUILD_DIR/extensions/$member/sql/"*.sql \ + "$POSTGIS_BUILD_DIR/share/extension-members/" + done } >&2 oliphaunt_wasix_extension_build_outputs_exist "$REPO_ROOT" postgis "$POSTGIS_BUILD_DIR" diff --git a/src/extensions/external/postgis/tools/windows/build-sql.sh b/src/extensions/external/postgis/tools/windows/build-sql.sh index ed936b30a..5f6a57091 100644 --- a/src/extensions/external/postgis/tools/windows/build-sql.sh +++ b/src/extensions/external/postgis/tools/windows/build-sql.sh @@ -5,6 +5,8 @@ windows_postgis_build_sql() { local directory="$build_dir/contrib/oliphaunt_external/postgis" local pg="$directory/postgis" sql="$directory/extensions/postgis/sql" raster="$directory/raster/rt_pg" local raster_sql="$directory/extensions/postgis_raster/sql" + local topology="$directory/topology" topology_sql="$directory/extensions/postgis_topology/sql" + local tiger_sql="$directory/extensions/postgis_tiger_geocoder/sql" local version pg_version version="$(windows_extension_generate postgis-version)" pg_version="$(windows_extension_generate postgres-major)" @@ -16,10 +18,14 @@ windows_postgis_build_sql() { perl "$directory/utils/create_upgrade.pl" "$sql/postgis_for_extension.sql" >"$sql/postgis_upgrade_for_extension.sql.in" perl "$directory/utils/create_uninstall.pl" "$raster/rtpostgis.sql" "$pg_version" >"$raster/uninstall_rtpostgis.sql" perl "$directory/utils/create_upgrade.pl" "$raster_sql/rtpostgis.sql" >"$raster_sql/rtpostgis_upgrade.sql.in" + perl "$directory/utils/create_upgrade.pl" "$topology/topology.sql" >"$topology/topology_upgrade.sql.in" + perl "$directory/utils/create_uninstall.pl" "$topology/topology.sql" "$pg_version" >"$topology/uninstall_topology.sql" + perl "$directory/utils/create_or_replace_to_create.pl" "$tiger_sql/postgis_tiger_geocoder_pre.sql" >"$tiger_sql/postgis_tiger_geocoder.sql" windows_extension_generate postgis-sql-upgrade perl "$directory/utils/create_extension_unpackage.pl" postgis <"$sql/raster_drop_all.sql" >"$sql/raster_unpackage_body.sql" windows_extension_generate postgis-sql-install perl "$directory/utils/create_unpackaged.pl" postgis <"$sql/postgis--$version.sql" >"$sql/postgis--unpackaged--$version.sql" perl "$directory/utils/create_unpackaged.pl" postgis_raster <"$raster_sql/postgis_raster--$version.sql" >"$raster_sql/postgis_raster--unpackaged--$version.sql" + perl "$directory/utils/create_unpackaged.pl" postgis_topology <"$topology/topology.sql" >"$topology_sql/postgis_topology--unpackaged--$version.sql" windows_extension_generate postgis-sql-unpackaged } diff --git a/src/extensions/external/postgis/tools/windows/meson.build.in b/src/extensions/external/postgis/tools/windows/meson.build.in index 4c1b37749..5e01ba6d8 100644 --- a/src/extensions/external/postgis/tools/windows/meson.build.in +++ b/src/extensions/external/postgis/tools/windows/meson.build.in @@ -206,6 +206,14 @@ postgis_raster = shared_module( ) contrib_targets += postgis_raster +postgis_topology = shared_module( + 'postgis_topology-3', + files('topology/postgis_topology.c') + postgis_common_sources, + c_pch: pch_postgres_h, + kwargs: postgis_module_args, +) +contrib_targets += postgis_topology + install_data( @EXTENSION_DATA@, kwargs: contrib_data_args, diff --git a/src/extensions/generated/docs/extension-evidence.json b/src/extensions/generated/docs/extension-evidence.json index fe148a51e..6ecb421e9 100644 --- a/src/extensions/generated/docs/extension-evidence.json +++ b/src/extensions/generated/docs/extension-evidence.json @@ -1346,7 +1346,7 @@ "collector": "src/extensions/tools/collect-wasix-evidence.sh", "kind": "exact-sha-ci" }, - "source-digest": "sha256:a7f5ae8eaa573692693d63d80c2f3d1902cfca741de9d9975a740f461f37ddf2", + "source-digest": "sha256:19c2319bf07db61cca14243cc01e52096ae7a1dfb5e1acd468d5e82d30ca68bf", "source-digest-inputs": [ "src/third-party/postgres/source.toml", "src/extensions/catalog/extensions.source.json", diff --git a/src/extensions/generated/extensions.catalog.json b/src/extensions/generated/extensions.catalog.json index a3aadf54e..868591586 100644 --- a/src/extensions/generated/extensions.catalog.json +++ b/src/extensions/generated/extensions.catalog.json @@ -1177,7 +1177,9 @@ "requires": [], "relocatable": "false" }, - "dependencies": [], + "dependencies": [ + "fuzzystrmatch" + ], "load-order": [ "lib/postgresql/postgis-3.so" ], diff --git a/src/extensions/generated/sdk/extensions.json b/src/extensions/generated/sdk/extensions.json index 0bc985f91..09f565cca 100644 --- a/src/extensions/generated/sdk/extensions.json +++ b/src/extensions/generated/sdk/extensions.json @@ -1,5 +1,5 @@ { - "extension-catalog-sha256": "26ce911f641272aa18249e7c7d044dfa5ea7061d07119bb275544e164bb9249a", + "extension-catalog-sha256": "dbe1ab284693ef923cb12e35e1c75bcc7fc1ed42af1fecf9fe9fee0aaf0ef8d0", "extensions": [ { "artifact-product": "oliphaunt-extension-contrib-pg18", @@ -16,6 +16,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "amcheck", + "native-module-stems": [ + "amcheck" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -25,6 +28,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "amcheck" + ], "sql-name": "amcheck" }, { @@ -42,6 +48,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "auto_explain", + "native-module-stems": [ + "auto_explain" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -51,6 +60,7 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [], "sql-name": "auto_explain" }, { @@ -68,6 +78,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "bloom", + "native-module-stems": [ + "bloom" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -77,6 +90,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "bloom" + ], "sql-name": "bloom" }, { @@ -94,6 +110,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "btree_gin", + "native-module-stems": [ + "btree_gin" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -103,6 +122,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "btree_gin" + ], "sql-name": "btree_gin" }, { @@ -120,6 +142,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "btree_gist", + "native-module-stems": [ + "btree_gist" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -129,6 +154,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "btree_gist" + ], "sql-name": "btree_gist" }, { @@ -146,6 +174,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "citext", + "native-module-stems": [ + "citext" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -155,6 +186,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "citext" + ], "sql-name": "citext" }, { @@ -172,6 +206,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "cube", + "native-module-stems": [ + "cube" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -181,6 +218,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "cube" + ], "sql-name": "cube" }, { @@ -198,6 +238,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "dict_int", + "native-module-stems": [ + "dict_int" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -207,6 +250,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "dict_int" + ], "sql-name": "dict_int" }, { @@ -226,6 +272,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "dict_xsyn", + "native-module-stems": [ + "dict_xsyn" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -237,6 +286,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "dict_xsyn" + ], "sql-name": "dict_xsyn" }, { @@ -256,6 +308,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "earthdistance", + "native-module-stems": [ + "earthdistance" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -267,6 +322,9 @@ ], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "earthdistance" + ], "sql-name": "earthdistance" }, { @@ -284,6 +342,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "file_fdw", + "native-module-stems": [ + "file_fdw" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -293,6 +354,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "file_fdw" + ], "sql-name": "file_fdw" }, { @@ -310,6 +374,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "fuzzystrmatch", + "native-module-stems": [ + "fuzzystrmatch" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -319,6 +386,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "fuzzystrmatch" + ], "sql-name": "fuzzystrmatch" }, { @@ -336,6 +406,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "hstore", + "native-module-stems": [ + "hstore" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -345,6 +418,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "hstore" + ], "sql-name": "hstore" }, { @@ -362,6 +438,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "_int", + "native-module-stems": [ + "_int" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -371,6 +450,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "intarray" + ], "sql-name": "intarray" }, { @@ -388,6 +470,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "isn", + "native-module-stems": [ + "isn" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -397,6 +482,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "isn" + ], "sql-name": "isn" }, { @@ -414,6 +502,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "lo", + "native-module-stems": [ + "lo" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -423,6 +514,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "lo" + ], "sql-name": "lo" }, { @@ -440,6 +534,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "ltree", + "native-module-stems": [ + "ltree" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -449,6 +546,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "ltree" + ], "sql-name": "ltree" }, { @@ -466,6 +566,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pageinspect", + "native-module-stems": [ + "pageinspect" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -475,6 +578,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pageinspect" + ], "sql-name": "pageinspect" }, { @@ -492,6 +598,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_buffercache", + "native-module-stems": [ + "pg_buffercache" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -501,6 +610,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pg_buffercache" + ], "sql-name": "pg_buffercache" }, { @@ -518,6 +630,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_freespacemap", + "native-module-stems": [ + "pg_freespacemap" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -527,6 +642,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pg_freespacemap" + ], "sql-name": "pg_freespacemap" }, { @@ -544,6 +662,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_hashids", + "native-module-stems": [ + "pg_hashids" + ], "npm-package": "@oliphaunt/extension-pg-hashids", "postgres-major": 18, "release-product": "oliphaunt-extension-pg-hashids", @@ -553,6 +674,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "oliphaunt-other-extension", + "sql-members": [ + "pg_hashids" + ], "sql-name": "pg_hashids" }, { @@ -570,6 +694,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_ivm", + "native-module-stems": [ + "pg_ivm" + ], "npm-package": "@oliphaunt/extension-pg-ivm", "postgres-major": 18, "release-product": "oliphaunt-extension-pg-ivm", @@ -579,6 +706,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "oliphaunt-other-extension", + "sql-members": [ + "pg_ivm" + ], "sql-name": "pg_ivm" }, { @@ -596,6 +726,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_surgery", + "native-module-stems": [ + "pg_surgery" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -605,6 +738,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pg_surgery" + ], "sql-name": "pg_surgery" }, { @@ -622,6 +758,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_textsearch", + "native-module-stems": [ + "pg_textsearch" + ], "npm-package": "@oliphaunt/extension-pg-textsearch", "postgres-major": 18, "release-product": "oliphaunt-extension-pg-textsearch", @@ -633,6 +772,9 @@ "pg_textsearch" ], "source-kind": "oliphaunt-other-extension", + "sql-members": [ + "pg_textsearch" + ], "sql-name": "pg_textsearch" }, { @@ -650,6 +792,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_trgm", + "native-module-stems": [ + "pg_trgm" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -659,6 +804,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pg_trgm" + ], "sql-name": "pg_trgm" }, { @@ -676,6 +824,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_uuidv7", + "native-module-stems": [ + "pg_uuidv7" + ], "npm-package": "@oliphaunt/extension-pg-uuidv7", "postgres-major": 18, "release-product": "oliphaunt-extension-pg-uuidv7", @@ -685,6 +836,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "oliphaunt-other-extension", + "sql-members": [ + "pg_uuidv7" + ], "sql-name": "pg_uuidv7" }, { @@ -702,6 +856,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_visibility", + "native-module-stems": [ + "pg_visibility" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -711,6 +868,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pg_visibility" + ], "sql-name": "pg_visibility" }, { @@ -728,6 +888,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "pg_walinspect", + "native-module-stems": [ + "pg_walinspect" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -737,6 +900,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pg_walinspect" + ], "sql-name": "pg_walinspect" }, { @@ -853,6 +1019,9 @@ "openssl" ], "native-module-stem": "pgcrypto", + "native-module-stems": [ + "pgcrypto" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -862,6 +1031,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "pgcrypto" + ], "sql-name": "pgcrypto" }, { @@ -886,6 +1058,7 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": null, + "native-module-stems": [], "npm-package": "@oliphaunt/extension-pgtap", "postgres-major": 18, "release-product": "oliphaunt-extension-pgtap", @@ -895,6 +1068,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "oliphaunt-other-extension", + "sql-members": [ + "pgtap" + ], "sql-name": "pgtap" }, { @@ -938,7 +1114,9 @@ "share/postgresql/gdal/tms_NZTM2000.json", "share/postgresql/proj/proj.db" ], - "dependencies": [], + "dependencies": [ + "fuzzystrmatch" + ], "display-name": "PostGIS", "extension-sql-file-names": [ "uninstall_postgis.sql" @@ -947,6 +1125,8 @@ "postgis_comments", "postgis_proc_set_search_path", "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology", "rtpostgis" ], "id": "postgis", @@ -1181,6 +1361,11 @@ "sqlite" ], "native-module-stem": "postgis-3", + "native-module-stems": [ + "postgis-3", + "postgis_raster-3", + "postgis_topology-3" + ], "npm-package": "@oliphaunt/extension-postgis", "postgres-major": 18, "release-product": "oliphaunt-extension-postgis", @@ -1234,9 +1419,17 @@ "gdal/tms_NZTM2000.json", "proj/proj.db" ], - "selected-extension-dependencies": [], + "selected-extension-dependencies": [ + "fuzzystrmatch" + ], "shared-preload-libraries": [], "source-kind": "postgis", + "sql-members": [ + "postgis", + "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology" + ], "sql-name": "postgis" }, { @@ -1254,6 +1447,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "seg", + "native-module-stems": [ + "seg" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1263,6 +1459,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "seg" + ], "sql-name": "seg" }, { @@ -1280,6 +1479,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "tablefunc", + "native-module-stems": [ + "tablefunc" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1289,6 +1491,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "tablefunc" + ], "sql-name": "tablefunc" }, { @@ -1306,6 +1511,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "tcn", + "native-module-stems": [ + "tcn" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1315,6 +1523,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "tcn" + ], "sql-name": "tcn" }, { @@ -1332,6 +1543,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "tsm_system_rows", + "native-module-stems": [ + "tsm_system_rows" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1341,6 +1555,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "tsm_system_rows" + ], "sql-name": "tsm_system_rows" }, { @@ -1358,6 +1575,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "tsm_system_time", + "native-module-stems": [ + "tsm_system_time" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1367,6 +1587,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "tsm_system_time" + ], "sql-name": "tsm_system_time" }, { @@ -1386,6 +1609,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "unaccent", + "native-module-stems": [ + "unaccent" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1397,6 +1623,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "unaccent" + ], "sql-name": "unaccent" }, { @@ -1513,6 +1742,9 @@ "portable-uuid" ], "native-module-stem": "uuid-ossp", + "native-module-stems": [ + "uuid-ossp" + ], "npm-package": "@oliphaunt/extension-contrib-pg18", "postgres-major": 18, "release-product": "liboliphaunt-native", @@ -1522,6 +1754,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "postgres-contrib", + "sql-members": [ + "uuid-ossp" + ], "sql-name": "uuid-ossp" }, { @@ -1539,6 +1774,9 @@ "native-component-requirements": [], "native-components": [], "native-module-stem": "vector", + "native-module-stems": [ + "vector" + ], "npm-package": "@oliphaunt/extension-vector", "postgres-major": 18, "release-product": "oliphaunt-extension-vector", @@ -1548,6 +1786,9 @@ "selected-extension-dependencies": [], "shared-preload-libraries": [], "source-kind": "oliphaunt-other-extension", + "sql-members": [ + "vector" + ], "sql-name": "vector" } ], diff --git a/src/extensions/generated/wasix/extensions.json b/src/extensions/generated/wasix/extensions.json index 9b95e1b0f..5b148430b 100644 --- a/src/extensions/generated/wasix/extensions.json +++ b/src/extensions/generated/wasix/extensions.json @@ -675,7 +675,9 @@ }, { "archive": "extensions/postgis.tar.zst", - "dependencies": [], + "dependencies": [ + "fuzzystrmatch" + ], "id": "postgis", "lifecycle": { "create-extension": true, @@ -726,6 +728,12 @@ "build-path": "postgis/raster/rt_pg/postgis_raster-3.so", "name": "postgis_raster", "runtime-path": "lib/postgresql/postgis_raster-3.so" + }, + { + "aot-file": "postgis_topology-llvm-opta.bin.zst", + "build-path": "postgis/topology/postgis_topology-3.so", + "name": "postgis_topology", + "runtime-path": "lib/postgresql/postgis_topology-3.so" } ], "sql-name": "postgis" diff --git a/src/extensions/tools/extension-model.test.mts b/src/extensions/tools/extension-model.test.mts index 64b618a52..6b13f6700 100644 --- a/src/extensions/tools/extension-model.test.mts +++ b/src/extensions/tools/extension-model.test.mts @@ -141,6 +141,21 @@ test('extension projections retain dependency and runtime data while deriving mo JSON.parse(outputs.get('src/extensions/generated/sdk/extensions.json')!), metadata, ); + const postgis = metadata.extensions.find((row: any) => row['sql-name'] === 'postgis'); + assert.deepEqual(postgis['native-module-stems'], [ + 'postgis-3', + 'postgis_raster-3', + 'postgis_topology-3', + ]); + assert.deepEqual(postgis['sql-members'], [ + 'postgis', + 'postgis_raster', + 'postgis_tiger_geocoder', + 'postgis_topology', + ]); + const loadOnly = metadata.extensions.find((row: any) => row['sql-name'] === 'auto_explain'); + assert.deepEqual(loadOnly['sql-members'], []); + assert.deepEqual(loadOnly['native-module-stems'], ['auto_explain']); const extension = source.extensions.find((row: any) => row.id === 'pg_textsearch'); extension.control['default-version'] = '9.8.7'; const updated = extensionProjections(source, releases).get( diff --git a/src/extensions/tools/extension-projections.mts b/src/extensions/tools/extension-projections.mts index da44b182a..fabc28521 100644 --- a/src/extensions/tools/extension-projections.mts +++ b/src/extensions/tools/extension-projections.mts @@ -72,6 +72,22 @@ export function extensionProjections(catalog: Row, releases: Row[]): Map row['sql-name'] === sql); const files = [...(recipe.artifacts?.data_files ?? contribRow?.['data-files'] ?? [])].sort(); + const nativeTargetPath = `${externalRoot}/${sql}/targets/native.toml`; + const nativeTarget = existsSync(nativeTargetPath) ? readToml(nativeTargetPath) : {}; + const primaryModule = moduleStem(extension); + const nativeModuleStems = unique( + (nativeTarget.module_files ?? (primaryModule === null ? [] : [primaryModule])).map( + (file: string) => file.replace(/^lib\/postgresql\//, '').replace(/\.(so|dylib|dll)$/, ''), + ), + ); + const sqlMembers = unique( + ( + recipe.artifacts?.control_files ?? + (extension.lifecycle?.['create-extension'] ? [`${sql}.control`] : []) + ).map((file: string) => + file.replace(/^share\/postgresql\/extension\//, '').replace(/\.control$/, ''), + ), + ); const requirements = resolutions(sql); const preload = unique( (extension.lifecycle?.['startup-config'] ?? []).flatMap((assignment: string) => @@ -122,7 +138,9 @@ export function extensionProjections(catalog: Row, releases: Row[]): Map names.has(name)) @@ -198,6 +216,8 @@ export function extensionProjections(catalog: Row, releases: Row[]): Map ` public static let ${row.id.replace(/[-_]([a-z]) mavenArtifact: row['maven-artifact'], runtimeBound: row['runtime-bound'], dependencies: row['selected-extension-dependencies'].join(','), + sqlMembers: row['sql-members'].join(','), }).map(([key, value]) => `extension.${row['sql-name']}.${key}=${value}`), ), '', @@ -521,6 +542,8 @@ function rustModule(rows: Row[]): string { option(row['shared-preload-libraries'][0] ?? null), ), ...[ + ['native_module_stems', 'native-module-stems'], + ['sql_members', 'sql-members'], ['extension_data_files', 'runtime-share-data-files'], ['extension_sql_file_prefixes', 'extension-sql-file-prefixes'], ['extension_sql_file_names', 'extension-sql-file-names'], diff --git a/src/native/runtime/bin/build-postgres18-linux.sh b/src/native/runtime/bin/build-postgres18-linux.sh index 4744211af..75160e7cd 100755 --- a/src/native/runtime/bin/build-postgres18-linux.sh +++ b/src/native/runtime/bin/build-postgres18-linux.sh @@ -155,6 +155,8 @@ required_extension_controls=( pgcrypto postgis postgis_raster + postgis_topology + postgis_tiger_geocoder pg_surgery pg_textsearch pg_trgm @@ -198,6 +200,7 @@ required_extension_modules=( pgcrypto postgis-3 postgis_raster-3 + postgis_topology-3 pg_surgery pg_textsearch pg_trgm @@ -262,8 +265,8 @@ filter_native_extension_selection() { postgis) selected_external+=(postgis) module_file="postgis-3.so" - selected_controls+=(postgis_raster) - selected_modules+=(postgis_raster-3) + selected_controls+=(postgis_raster postgis_topology postgis_tiger_geocoder) + selected_modules+=(postgis_raster-3 postgis_topology-3) ;; *) external_id="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$pgxs_plan" || true)" @@ -1645,10 +1648,8 @@ build_postgis_extension() { --with-pgconfig="$install_dir/bin/pg_config" \ "${postgis_configure_args[@]}" \ --without-protobuf \ - --without-topology \ --without-sfcgal \ --without-address-standardizer \ - --without-tiger \ --disable-nls >> "$make_log" 2>&1 patch_postgis_generated_makefiles "$postgis_build_dir" make postgis_revision.h >> "$make_log" 2>&1 @@ -1658,16 +1659,20 @@ build_postgis_extension() { make -j"$jobs" -C postgis CC="$cc_string" "${postgis_make_args[@]}" install >> "$make_log" 2>&1 make -j"$jobs" -C raster/rt_core CC="$cc_string" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 make -j"$jobs" -C raster/rt_pg CC="$cc_string" BE_DLLLIBS="$normal_module_be_dllibs" "${postgis_make_args[@]}" install >> "$make_log" 2>&1 + make -j"$jobs" -C topology CC="$cc_string" BE_DLLLIBS="$normal_module_be_dllibs" "${postgis_make_args[@]}" install >> "$make_log" 2>&1 make -j1 -C extensions CC="$cc_string" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 make -j1 -C extensions CC="$cc_string" "${postgis_make_args[@]}" install >> "$make_log" 2>&1 make -C postgis clean >> "$make_log" 2>&1 || true make postgis_revision.h >> "$make_log" 2>&1 make -j"$jobs" -C postgis CC="$cc_string" CFLAGS="$native_cflags" BE_DLLLIBS="$embedded_module_be_dllibs" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 make -C raster/rt_pg clean >> "$make_log" 2>&1 || true + make -C topology clean >> "$make_log" 2>&1 || true make -j"$jobs" -C raster/rt_pg CC="$cc_string" CFLAGS="$native_cflags" BE_DLLLIBS="$embedded_module_be_dllibs" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 + make -j"$jobs" -C topology CC="$cc_string" CFLAGS="$native_cflags" BE_DLLLIBS="$embedded_module_be_dllibs" "${postgis_make_args[@]}" all >> "$make_log" 2>&1 ) copy_embedded_postgis_module "$postgis_build_dir/postgis" postgis-3 copy_embedded_postgis_module "$postgis_build_dir/raster/rt_pg" postgis_raster-3 + copy_embedded_postgis_module "$postgis_build_dir/topology" postgis_topology-3 stage_postgis_data_files "$postgis_build_dir" } diff --git a/src/native/runtime/bin/build-postgres18-macos.sh b/src/native/runtime/bin/build-postgres18-macos.sh index 1195586f2..7f53bd4f4 100755 --- a/src/native/runtime/bin/build-postgres18-macos.sh +++ b/src/native/runtime/bin/build-postgres18-macos.sh @@ -139,6 +139,8 @@ required_extension_controls=( pgcrypto postgis postgis_raster + postgis_topology + postgis_tiger_geocoder pg_surgery pg_textsearch pg_trgm @@ -182,6 +184,7 @@ required_extension_modules=( pgcrypto postgis-3 postgis_raster-3 + postgis_topology-3 pg_surgery pg_textsearch pg_trgm @@ -246,8 +249,8 @@ filter_native_extension_selection() { postgis) selected_external+=(postgis) module_file="postgis-3.so" - selected_controls+=(postgis_raster) - selected_modules+=(postgis_raster-3) + selected_controls+=(postgis_raster postgis_topology postgis_tiger_geocoder) + selected_modules+=(postgis_raster-3 postgis_topology-3) ;; *) external_id="$(awk -F '\t' -v sql="$sql" 'NR > 1 && $2 == sql { print $1; found = 1; exit } END { exit found ? 0 : 1 }' "$pgxs_plan" || true)" @@ -2220,10 +2223,8 @@ build_postgis_extension() { --with-pgconfig="$install_dir/bin/pg_config" \ "${postgis_configure_args[@]}" \ --without-protobuf \ - --without-topology \ --without-sfcgal \ --without-address-standardizer \ - --without-tiger \ --disable-nls patch_postgis_generated_makefiles "$postgis_build_dir" @@ -2237,6 +2238,7 @@ build_postgis_extension() { make -j"$jobs" -C postgis CC="$postgis_cc_string" BE_DLLLIBS="$normal_module_be_dllibs" "${postgis_make_args[@]}" install make -j"$jobs" -C raster/rt_core CC="$postgis_cc_string" "${postgis_make_args[@]}" all make -j"$jobs" -C raster/rt_pg CC="$postgis_cc_string" BE_DLLLIBS="$normal_module_be_dllibs" "${postgis_make_args[@]}" install + make -j"$jobs" -C topology CC="$postgis_cc_string" BE_DLLLIBS="$normal_module_be_dllibs" "${postgis_make_args[@]}" install # PostGIS extension SQL generation shares raster helper outputs, so keep # this packaging phase serial. make -j1 -C extensions CC="$postgis_cc_string" "${postgis_make_args[@]}" all @@ -2245,13 +2247,17 @@ build_postgis_extension() { make postgis_revision.h make -j"$jobs" -C postgis CC="$postgis_cc_string" CFLAGS="$native_cflags" BE_DLLLIBS="$embedded_module_be_dllibs" "${embedded_postgis_make_args[@]}" all make -C raster/rt_pg clean || true + make -C topology clean || true make -j"$jobs" -C raster/rt_pg CC="$postgis_cc_string" CFLAGS="$native_cflags" BE_DLLLIBS="$embedded_module_be_dllibs" "${embedded_postgis_make_args[@]}" all + make -j"$jobs" -C topology CC="$postgis_cc_string" CFLAGS="$native_cflags" BE_DLLLIBS="$embedded_module_be_dllibs" "${embedded_postgis_make_args[@]}" all ) normalize_installed_module_suffix postgis-3 normalize_installed_module_suffix postgis_raster-3 + normalize_installed_module_suffix postgis_topology-3 copy_embedded_postgis_module "$postgis_build_dir/postgis" postgis-3 copy_embedded_postgis_module "$postgis_build_dir/raster/rt_pg" postgis_raster-3 + copy_embedded_postgis_module "$postgis_build_dir/topology" postgis_topology-3 stage_postgis_data_files "$postgis_build_dir" } diff --git a/src/native/runtime/bin/build-postgres18-windows.sh b/src/native/runtime/bin/build-postgres18-windows.sh index 3399fe7ef..c83ce7b72 100755 --- a/src/native/runtime/bin/build-postgres18-windows.sh +++ b/src/native/runtime/bin/build-postgres18-windows.sh @@ -116,9 +116,16 @@ extension_catalog() { } windows_extension_modules() { [ "$build_extensions" != 0 ] || return 0 - local sql_name stem + local sql_name stem postgis_stems while IFS=$'\t' read -r sql_name stem; do - if selected "$sql_name" && [ "$stem" != - ]; then printf '%s\t%s\n' "$sql_name" "$stem"; fi + if selected "$sql_name" && [ "$stem" != - ]; then + if [ "$sql_name" = postgis ]; then + postgis_stems="$(windows_extension_generate postgis-module-stems)" || return + while IFS= read -r stem; do printf '%s\t%s\n' "$sql_name" "$stem"; done <<<"$postgis_stems" + else + printf '%s\t%s\n' "$sql_name" "$stem" + fi + fi done < <(awk -F '\t' 'NR>1 { print $1 "\t" $2 }' "$catalog") } windows_extension_prune() { @@ -294,7 +301,7 @@ desired_hash="$( while IFS= read -r -d '' input; do printf '%s\n' "$input" sha256 "$input" - done < <(find extensions/external src/third-party/icu src/third-party/openssl -type f ! -name '*.test.*' ! -path '*/fixtures/*' ! -name '*.md' -print0 | sort -z) + done < <(find src/extensions/external src/third-party/icu src/third-party/openssl -type f ! -name '*.test.*' ! -path '*/fixtures/*' ! -name '*.md' -print0 | sort -z) fi } | sha256sum | awk '{print $1}' )" diff --git a/src/native/runtime/bin/mobile-postgis-extensions.sh b/src/native/runtime/bin/mobile-postgis-extensions.sh index 2ddbbd5ff..5106cff89 100644 --- a/src/native/runtime/bin/mobile-postgis-extensions.sh +++ b/src/native/runtime/bin/mobile-postgis-extensions.sh @@ -44,6 +44,8 @@ oliphaunt_postgis_output_fingerprint() { local archive="$5" local control_file="$install_dir/share/postgresql/extension/postgis.control" local raster_control_file="$install_dir/share/postgresql/extension/postgis_raster.control" + local topology_control_file="$install_dir/share/postgresql/extension/postgis_topology.control" + local tiger_control_file="$install_dir/share/postgresql/extension/postgis_tiger_geocoder.control" local proj_database="$install_dir/share/postgresql/proj/proj.db" local gdal_schema="$install_dir/share/postgresql/gdal/gdalvrt.xsd" local sql_source_root @@ -51,12 +53,12 @@ oliphaunt_postgis_output_fingerprint() { local sql_count=0 local -a outputs - outputs=("$archive" "$objects_file" "$alias_file" "$control_file" "$raster_control_file" "$proj_database" "$gdal_schema") + outputs=("$archive" "$objects_file" "$alias_file" "$control_file" "$raster_control_file" "$topology_control_file" "$tiger_control_file" "$proj_database" "$gdal_schema") while IFS= read -r object; do [ -n "$object" ] || continue outputs+=("$object") done < "$objects_file" - for sql_source_root in "$postgis_build/extensions/postgis/sql" "$postgis_build/extensions/postgis_raster/sql"; do + for sql_source_root in "$postgis_build/extensions/postgis/sql" "$postgis_build/extensions/postgis_raster/sql" "$postgis_build/extensions/postgis_topology/sql" "$postgis_build/extensions/postgis_tiger_geocoder/sql"; do while IFS= read -r sql_source; do [ -n "$sql_source" ] || continue installed_sql="$install_dir/share/postgresql/extension/$(basename "$sql_source")" @@ -653,17 +655,18 @@ oliphaunt_postgis_stage_runtime_sql() { "$install_dir/share/postgresql/extension" \ "$install_dir/share/postgresql/proj" \ "$install_dir/share/postgresql/gdal" - cp -p "$postgis_build/extensions/postgis/postgis.control" \ - "$install_dir/share/postgresql/extension/postgis.control" - cp -p "$postgis_build/extensions/postgis/sql/"*.sql \ - "$install_dir/share/postgresql/extension/" - cp -p "$postgis_build/extensions/postgis_raster/postgis_raster.control" \ - "$install_dir/share/postgresql/extension/postgis_raster.control" - cp -p "$postgis_build/extensions/postgis_raster/sql/"*.sql \ - "$install_dir/share/postgresql/extension/" - perl -0pi -e 's|\$libdir/postgis_raster-3(?:\.6)?|\$libdir/postgis-3|g' \ - "$install_dir/share/postgresql/extension/postgis_raster.control" \ - "$install_dir/share/postgresql/extension/postgis_raster--"*.sql + local member + for member in postgis postgis_raster postgis_topology postgis_tiger_geocoder; do + cp -p "$postgis_build/extensions/$member/$member.control" \ + "$install_dir/share/postgresql/extension/" + cp -p "$postgis_build/extensions/$member/sql/"*.sql \ + "$install_dir/share/postgresql/extension/" + done + for member in postgis_raster postgis_topology; do + perl -0pi -e 's#\$libdir/postgis_(?:raster|topology)-3(?:\.6)?#\$libdir/postgis-3#g' \ + "$install_dir/share/postgresql/extension/$member.control" \ + "$install_dir/share/postgresql/extension/$member--"*.sql + done cp -p "$mobile_static_dependency_root/proj/share/proj/proj.db" \ "$install_dir/share/postgresql/proj/proj.db" cp -a "$gdal_data/." "$install_dir/share/postgresql/gdal/" @@ -710,6 +713,30 @@ oliphaunt_postgis_patch_raster_makefile() { ' "$makefile" } +oliphaunt_postgis_patch_topology_makefile() { + local postgis_build="$1" + local prefix="$2" + local makefile="$postgis_build/topology/Makefile" + [ -f "$makefile" ] || oliphaunt_postgis_fail "PostGIS topology Makefile is missing: $makefile" + OLIPHAUNT_POSTGIS_TOPOLOGY_PREFIX="$prefix" perl -0pi -e ' + my $prefix = $ENV{OLIPHAUNT_POSTGIS_TOPOLOGY_PREFIX}; + my $defs = " -DPg_magic_func=${prefix}_topology_Pg_magic_func" . + " -D_PG_init=${prefix}_topology_PG_init" . + " -D_PG_fini=${prefix}_topology_PG_fini"; + my $updated = s|^(PG_CPPFLAGS \+= .*)$|$1$defs|m; + die "could not patch PostGIS topology PG_CPPFLAGS\n" unless $updated; + ' "$makefile" +} + +oliphaunt_postgis_build_topology_objects() { + local postgis_build="$1" + if ! make -j"$jobs" -C "$postgis_build/topology" postgis_topology.o >> "$make_log" 2>&1; then + tail -n 200 "$make_log" >&2 || true + oliphaunt_postgis_fail "PostGIS topology object compilation failed" + fi + make -j1 -C "$postgis_build/topology" topology.sql topology_upgrade.sql uninstall_topology.sql >> "$make_log" 2>&1 +} + oliphaunt_postgis_build_combined_init() { local postgis_build="$1" local prefix="$2" @@ -719,9 +746,11 @@ oliphaunt_postgis_build_combined_init() { cat > "$source" <> "$make_log" 2>&1 @@ -790,6 +819,8 @@ build_postgis_mobile_static_extension_objects() { if [ -f "$archive" ] && [ -s "$objects_file" ] && [ -s "$alias_file" ] && [ -f "$install_dir/share/postgresql/extension/postgis.control" ] && [ -f "$install_dir/share/postgresql/extension/postgis_raster.control" ] && + [ -f "$install_dir/share/postgresql/extension/postgis_topology.control" ] && + [ -f "$install_dir/share/postgresql/extension/postgis_tiger_geocoder.control" ] && [ -s "$build_stamp" ] && [ "$(cat "$build_stamp")" = "$build_fingerprint" ] && [ -s "$output_stamp" ] && output_fingerprint="$(oliphaunt_postgis_output_fingerprint "$postgis_build" "$object_dir" "$objects_file" "$alias_file" "$archive")" && @@ -840,10 +871,8 @@ build_postgis_mobile_static_extension_objects() { --with-gdalconfig="$gdal_config" --with-xml2config="$mobile_static_dependency_root/libxml2/bin/xml2-config" --without-protobuf - --without-topology --without-sfcgal --without-address-standardizer - --without-tiger --disable-nls ) case "$oliphaunt_mobile_target" in @@ -882,12 +911,14 @@ build_postgis_mobile_static_extension_objects() { ./configure --build="$build_alias" "${configure_args[@]}" >> "$make_log" 2>&1 oliphaunt_postgis_patch_extension_makefile "$postgis_build" "$prefix" oliphaunt_postgis_patch_raster_makefile "$postgis_build" "$prefix" + oliphaunt_postgis_patch_topology_makefile "$postgis_build" "$prefix" make -j"$jobs" -C liblwgeom liblwgeom.la >> "$make_log" 2>&1 make -j"$jobs" -C libpgcommon libpgcommon.a >> "$make_log" 2>&1 make -j"$jobs" -C deps/flatgeobuf all >> "$make_log" 2>&1 make -j"$jobs" -C postgis all >> "$make_log" 2>&1 || true make -j"$jobs" -C raster/rt_core all >> "$make_log" 2>&1 oliphaunt_postgis_build_raster_objects "$postgis_build" + oliphaunt_postgis_build_topology_objects "$postgis_build" make -j1 -C extensions postgis_extension_helper.sql >> "$make_log" 2>&1 make -j1 -C extensions all >> "$make_log" 2>&1 ) @@ -903,6 +934,7 @@ build_postgis_mobile_static_extension_objects() { [ -n "$object" ] || continue oliphaunt_postgis_stage_object "$object" raster "$object_dir" "$objects_file" done < <(find "$postgis_build/raster/rt_pg" -maxdepth 1 -type f -name '*.o' -print | LC_ALL=C sort) + oliphaunt_postgis_stage_object "$postgis_build/topology/postgis_topology.o" topology "$object_dir" "$objects_file" for object in \ "$postgis_build/deps/flatgeobuf/flatgeobuf_c.o" \ "$postgis_build/deps/flatgeobuf/geometrywriter.o" \ diff --git a/src/native/runtime/bin/mobile-postgis-extensions.test.sh b/src/native/runtime/bin/mobile-postgis-extensions.test.sh index 6e492e8fa..48c641faf 100755 --- a/src/native/runtime/bin/mobile-postgis-extensions.test.sh +++ b/src/native/runtime/bin/mobile-postgis-extensions.test.sh @@ -117,4 +117,71 @@ if (oliphaunt_postgis_build_raster_objects "$postgis_build") > "$test_root/failu exit 1 fi +mkdir -p "$postgis_build/topology" +cat > "$postgis_build/topology/Makefile" <<'MAKEFILE' +PG_CPPFLAGS += -Ifixture +.PHONY: all print-flags +all: + @exit 1 +postgis_topology.o topology.sql topology_upgrade.sql uninstall_topology.sql: + @: > $@ +print-flags: + @printf '%s\n' '$(PG_CPPFLAGS)' +MAKEFILE +oliphaunt_postgis_patch_topology_makefile "$postgis_build" "$prefix" +topology_flags="$(make -s -f "$postgis_build/topology/Makefile" print-flags)" +for expected in \ + "-DPg_magic_func=${prefix}_topology_Pg_magic_func" \ + "-D_PG_init=${prefix}_topology_PG_init" \ + "-D_PG_fini=${prefix}_topology_PG_fini"; do + case " $topology_flags " in + *" $expected "*) ;; + *) echo "missing topology compile definition: $expected" >&2; exit 1 ;; + esac +done +oliphaunt_postgis_build_topology_objects "$postgis_build" +for output in postgis_topology.o topology.sql topology_upgrade.sql uninstall_topology.sql; do + [ -f "$postgis_build/topology/$output" ] +done + +# The merged carrier must initialize all three C modules in dependency order. +cc=("${CC:-cc}") +native_cflags="" +object_dir="$test_root/objects" +mkdir -p "$object_dir" +combined_object="$(oliphaunt_postgis_build_combined_init "$postgis_build" "$prefix" "$object_dir")" +cat > "$test_root/init-test.c" < +static int initialized; +void ${prefix}_core_PG_init(void) { assert(initialized++ == 0); } +void ${prefix}_raster_PG_init(void) { assert(initialized++ == 1); } +void ${prefix}_topology_PG_init(void) { assert(initialized++ == 2); } +extern void ${prefix}__PG_init(void); +int main(void) { ${prefix}__PG_init(); assert(initialized == 3); } +EOF +"${cc[@]}" "$test_root/init-test.c" "$combined_object" -o "$test_root/init-test" +"$test_root/init-test" + +install_dir="$test_root/install" +mkdir -p "$mobile_static_dependency_root/proj/share/proj" +printf 'database\n' > "$mobile_static_dependency_root/proj/share/proj/proj.db" +for member in postgis postgis_raster postgis_topology postgis_tiger_geocoder; do + mkdir -p "$postgis_build/extensions/$member/sql" + printf 'module_pathname = '\''$libdir/%s-3'\''\n' "$member" > "$postgis_build/extensions/$member/$member.control" + printf 'SELECT '\''$libdir/%s-3.6'\'';\n' "$member" > "$postgis_build/extensions/$member/sql/$member--3.6.4.sql" +done +oliphaunt_postgis_stage_runtime_sql "$postgis_build" +for member in postgis postgis_raster postgis_topology postgis_tiger_geocoder; do + [ -s "$install_dir/share/postgresql/extension/$member.control" ] + [ -s "$install_dir/share/postgresql/extension/$member--3.6.4.sql" ] +done +for member in postgis_raster postgis_topology; do + grep -F '$libdir/postgis-3' "$install_dir/share/postgresql/extension/$member.control" >/dev/null + grep -F '$libdir/postgis-3' "$install_dir/share/postgresql/extension/$member--3.6.4.sql" >/dev/null + if grep -F "$member-3" "$install_dir/share/postgresql/extension/$member--3.6.4.sql"; then + echo "mobile SQL retained a separate module identity" >&2 + exit 1 + fi +done + echo "mobile PostGIS build contracts passed" diff --git a/src/native/rust-bindings/src/extension.rs b/src/native/rust-bindings/src/extension.rs index fa4ac3196..df54a22d3 100644 --- a/src/native/rust-bindings/src/extension.rs +++ b/src/native/rust-bindings/src/extension.rs @@ -1,5 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use sha2::{Digest, Sha256}; + use crate::error::{Error, Result}; #[path = "generated/extensions.rs"] @@ -42,6 +44,19 @@ impl Extension { .map(|stem| format!("{}{}", stem, std::env::consts::DLL_SUFFIX)) } + /// Every native PostgreSQL module shipped by this extension product. + pub fn native_module_files(self) -> Vec { + generated_extensions::native_module_stems(self) + .iter() + .map(|stem| format!("{stem}{}", std::env::consts::DLL_SUFFIX)) + .collect() + } + + /// SQL extensions installed from this product's shared artifact. + pub const fn sql_members(self) -> &'static [&'static str] { + generated_extensions::sql_members(self) + } + pub const fn creates_extension(self) -> bool { generated_extensions::creates_extension(self) } @@ -155,17 +170,17 @@ fn visit_extension( extension.sql_name() ))); } - for dependency in extension.dependencies() { - let inherited = if generated_extensions::artifact_product(*dependency) + for dependency in package_dependencies(extension)? { + let inherited = if generated_extensions::artifact_product(dependency) == generated_extensions::artifact_product(extension) { { - let mut inherited = *dependency; + let mut inherited = dependency; inherited.package = extension.package; inherited } } else { - *dependency + dependency }; let selected = requested .get(dependency.sql_name()) @@ -185,6 +200,83 @@ fn visit_extension( Ok(()) } +// Independent packages retain the dependency contract of the artifact that +// produced them, even when the current catalog adds a bundled SQL companion. +fn package_dependencies(extension: Extension) -> Result> { + let Some(package) = extension.package else { + return Ok(extension.dependencies().to_vec()); + }; + let path = format!( + "extension/{}/.oliphaunt/extensions/{}.properties", + package.product, + extension.sql_name() + ); + let mut manifests = package + .resources + .iter() + .filter(|resource| resource.0 == path); + let Some((_, bytes, checksum, _)) = manifests.next() else { + // Historical PostGIS artifacts supplied core SQL only, before tiger's + // fuzzystrmatch prerequisite became part of the current product. + return Ok(if extension.sql_name() == "postgis" { + Vec::new() + } else { + extension.dependencies().to_vec() + }); + }; + let invalid = || { + Error::InvalidConfig(format!( + "invalid native extension dependency manifest for {}", + extension.sql_name() + )) + }; + if manifests.next().is_some() || format!("{:x}", Sha256::digest(bytes)) != *checksum { + return Err(invalid()); + } + let text = std::str::from_utf8(bytes).map_err(|_| invalid())?; + let mut fields = BTreeMap::new(); + for line in text.lines().filter(|line| !line.is_empty()) { + let (key, value) = line.split_once('=').ok_or_else(invalid)?; + if fields.insert(key, value).is_some() { + return Err(invalid()); + } + } + if fields.get("sqlName") != Some(&extension.sql_name()) { + return Err(invalid()); + } + let dependencies = fields.get("dependencies").ok_or_else(invalid)?; + if extension.sql_name() == "postgis" + && fields.get("sqlMembers").is_some_and(|members| { + members + .split(',') + .any(|member| member == "postgis_tiger_geocoder") + }) + && !dependencies + .split(',') + .any(|dependency| dependency == "fuzzystrmatch") + { + return Err(invalid()); + } + if dependencies.is_empty() { + return Ok(Vec::new()); + } + let mut seen = BTreeSet::new(); + dependencies + .split(',') + .map(|name| { + if !seen.insert(name) || name == extension.sql_name() { + return Err(invalid()); + } + extension + .dependencies() + .iter() + .copied() + .find(|dependency| dependency.sql_name() == name) + .ok_or_else(invalid) + }) + .collect() +} + pub fn extension_sql_file_belongs(sql_name: &str, file_name: &str) -> bool { file_name == format!("{sql_name}.control") || file_name == format!("{sql_name}.sql") @@ -241,9 +333,83 @@ pub const fn extension_data_files(extension: Extension) -> &'static [&'static st } #[cfg(test)] -mod package_tests { +pub(crate) mod package_tests { use super::*; + pub(crate) fn postgis_package(manifest: Option<&str>) -> &'static ExtensionPackage { + let (path, bytes) = match manifest { + Some(text) => ( + "extension/oliphaunt-extension-postgis/.oliphaunt/extensions/postgis.properties", + text.as_bytes().to_vec(), + ), + None => ( + "extension/oliphaunt-extension-postgis/legacy-fixture", + Vec::new(), + ), + }; + let checksum = Box::leak(format!("{:x}", Sha256::digest(&bytes)).into_boxed_str()); + let resources = Box::leak( + vec![( + path, + Box::leak(bytes.into_boxed_slice()) as &'static [u8], + checksum as &'static str, + false, + )] + .into_boxed_slice(), + ); + Box::leak(Box::new(ExtensionPackage { + product: "oliphaunt-extension-postgis", + version: "3.6.4", + runtime_version: "0.2.0", + resources, + })) + } + + #[test] + fn separately_packaged_postgis_resolves_its_frozen_dependencies() { + for manifest in [None, Some("sqlName=postgis\ndependencies=\n")] { + let selected = Extension::POSTGIS.with_package(postgis_package(manifest)); + assert_eq!(resolve_extensions(&[selected]).unwrap(), [selected]); + } + let selected = Extension::POSTGIS.with_package(postgis_package(Some( + "sqlName=postgis\ndependencies=fuzzystrmatch\nsqlMembers=postgis,postgis_tiger_geocoder\nnativeModuleStems=postgis-3\n", + ))); + assert_eq!( + resolve_extensions(&[selected]).unwrap(), + [Extension::FUZZYSTRMATCH, selected] + ); + assert_eq!( + resolve_extensions(&[Extension::POSTGIS]).unwrap(), + [Extension::FUZZYSTRMATCH, Extension::POSTGIS] + ); + for manifest in [ + "sqlName=postgis\n", + "sqlName=hstore\ndependencies=\n", + "sqlName=postgis\ndependencies=unknown\n", + "sqlName=postgis\ndependencies=hstore\n", + "sqlName=postgis\ndependencies=postgis\n", + "sqlName=postgis\ndependencies=\nsqlMembers=postgis,postgis_tiger_geocoder\nnativeModuleStems=postgis-3\n", + "sqlName=postgis\ndependencies=fuzzystrmatch,fuzzystrmatch\n", + "sqlName=postgis\ndependencies=\ndependencies=fuzzystrmatch\n", + ] { + assert!( + resolve_extensions(&[ + Extension::POSTGIS.with_package(postgis_package(Some(manifest))) + ]) + .is_err(), + "accepted {manifest}" + ); + } + let package = postgis_package(Some("sqlName=postgis\ndependencies=\n")); + let mut resource = package.resources[0]; + resource.2 = "invalid-checksum"; + let corrupted = Box::leak(Box::new(ExtensionPackage { + resources: Box::leak(vec![resource].into_boxed_slice()), + ..*package + })); + assert!(resolve_extensions(&[Extension::POSTGIS.with_package(corrupted)]).is_err()); + } + #[test] fn selected_packages_follow_dependencies_and_reject_identity_conflicts() { static PACKAGE: ExtensionPackage = ExtensionPackage { diff --git a/src/native/rust-bindings/src/generated/extensions.rs b/src/native/rust-bindings/src/generated/extensions.rs index 2e7352ab0..0f35270fa 100644 --- a/src/native/rust-bindings/src/generated/extensions.rs +++ b/src/native/rust-bindings/src/generated/extensions.rs @@ -546,7 +546,7 @@ pub(super) const fn dependencies(extension: Extension) -> &'static [Extension] { ExtensionId::PgWalinspect => &[], ExtensionId::Pgcrypto => &[], ExtensionId::Pgtap => &[], - ExtensionId::Postgis => &[], + ExtensionId::Postgis => &[Extension::FUZZYSTRMATCH], ExtensionId::Seg => &[], ExtensionId::Tablefunc => &[], ExtensionId::Tcn => &[], @@ -603,6 +603,101 @@ pub(super) const fn required_shared_preload_library(extension: Extension) -> Opt } } +/// Generated extension metadata accessor. +pub(super) const fn native_module_stems(extension: Extension) -> &'static [&'static str] { + match extension.id { + ExtensionId::Amcheck => &["amcheck"], + ExtensionId::AutoExplain => &["auto_explain"], + ExtensionId::Bloom => &["bloom"], + ExtensionId::BtreeGin => &["btree_gin"], + ExtensionId::BtreeGist => &["btree_gist"], + ExtensionId::Citext => &["citext"], + ExtensionId::Cube => &["cube"], + ExtensionId::DictInt => &["dict_int"], + ExtensionId::DictXsyn => &["dict_xsyn"], + ExtensionId::Earthdistance => &["earthdistance"], + ExtensionId::FileFdw => &["file_fdw"], + ExtensionId::Fuzzystrmatch => &["fuzzystrmatch"], + ExtensionId::Hstore => &["hstore"], + ExtensionId::Intarray => &["_int"], + ExtensionId::Isn => &["isn"], + ExtensionId::Lo => &["lo"], + ExtensionId::Ltree => &["ltree"], + ExtensionId::Pageinspect => &["pageinspect"], + ExtensionId::PgBuffercache => &["pg_buffercache"], + ExtensionId::PgFreespacemap => &["pg_freespacemap"], + ExtensionId::PgHashids => &["pg_hashids"], + ExtensionId::PgIvm => &["pg_ivm"], + ExtensionId::PgSurgery => &["pg_surgery"], + ExtensionId::PgTextsearch => &["pg_textsearch"], + ExtensionId::PgTrgm => &["pg_trgm"], + ExtensionId::PgUuidv7 => &["pg_uuidv7"], + ExtensionId::PgVisibility => &["pg_visibility"], + ExtensionId::PgWalinspect => &["pg_walinspect"], + ExtensionId::Pgcrypto => &["pgcrypto"], + ExtensionId::Pgtap => &[], + ExtensionId::Postgis => &["postgis-3", "postgis_raster-3", "postgis_topology-3"], + ExtensionId::Seg => &["seg"], + ExtensionId::Tablefunc => &["tablefunc"], + ExtensionId::Tcn => &["tcn"], + ExtensionId::TsmSystemRows => &["tsm_system_rows"], + ExtensionId::TsmSystemTime => &["tsm_system_time"], + ExtensionId::Unaccent => &["unaccent"], + ExtensionId::UuidOssp => &["uuid-ossp"], + ExtensionId::Vector => &["vector"], + } +} + +/// Generated extension metadata accessor. +pub(super) const fn sql_members(extension: Extension) -> &'static [&'static str] { + match extension.id { + ExtensionId::Amcheck => &["amcheck"], + ExtensionId::AutoExplain => &[], + ExtensionId::Bloom => &["bloom"], + ExtensionId::BtreeGin => &["btree_gin"], + ExtensionId::BtreeGist => &["btree_gist"], + ExtensionId::Citext => &["citext"], + ExtensionId::Cube => &["cube"], + ExtensionId::DictInt => &["dict_int"], + ExtensionId::DictXsyn => &["dict_xsyn"], + ExtensionId::Earthdistance => &["earthdistance"], + ExtensionId::FileFdw => &["file_fdw"], + ExtensionId::Fuzzystrmatch => &["fuzzystrmatch"], + ExtensionId::Hstore => &["hstore"], + ExtensionId::Intarray => &["intarray"], + ExtensionId::Isn => &["isn"], + ExtensionId::Lo => &["lo"], + ExtensionId::Ltree => &["ltree"], + ExtensionId::Pageinspect => &["pageinspect"], + ExtensionId::PgBuffercache => &["pg_buffercache"], + ExtensionId::PgFreespacemap => &["pg_freespacemap"], + ExtensionId::PgHashids => &["pg_hashids"], + ExtensionId::PgIvm => &["pg_ivm"], + ExtensionId::PgSurgery => &["pg_surgery"], + ExtensionId::PgTextsearch => &["pg_textsearch"], + ExtensionId::PgTrgm => &["pg_trgm"], + ExtensionId::PgUuidv7 => &["pg_uuidv7"], + ExtensionId::PgVisibility => &["pg_visibility"], + ExtensionId::PgWalinspect => &["pg_walinspect"], + ExtensionId::Pgcrypto => &["pgcrypto"], + ExtensionId::Pgtap => &["pgtap"], + ExtensionId::Postgis => &[ + "postgis", + "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology", + ], + ExtensionId::Seg => &["seg"], + ExtensionId::Tablefunc => &["tablefunc"], + ExtensionId::Tcn => &["tcn"], + ExtensionId::TsmSystemRows => &["tsm_system_rows"], + ExtensionId::TsmSystemTime => &["tsm_system_time"], + ExtensionId::Unaccent => &["unaccent"], + ExtensionId::UuidOssp => &["uuid-ossp"], + ExtensionId::Vector => &["vector"], + } +} + /// Generated extension metadata accessor. pub(super) const fn extension_data_files(extension: Extension) -> &'static [&'static str] { match extension.id { @@ -721,6 +816,8 @@ pub(super) const fn extension_sql_file_prefixes(extension: Extension) -> &'stati "postgis_comments", "postgis_proc_set_search_path", "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology", "rtpostgis", ], ExtensionId::Seg => &[], diff --git a/src/native/rust-bindings/src/liboliphaunt/root/extensions.rs b/src/native/rust-bindings/src/liboliphaunt/root/extensions.rs index 783e60b0f..37e480849 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/extensions.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/extensions.rs @@ -5,9 +5,9 @@ use std::path::Path; use super::files::copy_file_preserving_permissions; use crate::error::{Error, Result}; -#[cfg(test)] -use crate::extension::extension_install_sql_file_belongs; -use crate::extension::{Extension, extension_data_files, extension_sql_file_belongs}; +use crate::extension::{ + Extension, extension_data_files, extension_install_sql_file_belongs, extension_sql_file_belongs, +}; pub(super) fn selected_extension_names(extensions: &[Extension]) -> Vec<&'static str> { let mut names = extensions @@ -27,16 +27,16 @@ pub(super) fn core_share_file(relative: &Path) -> bool { { return false; } - !matches!( - relative.to_str(), - Some("tsearch_data/unaccent.rules" | "tsearch_data/xsyn_sample.rules") - ) + !Extension::ALL + .iter() + .flat_map(|extension| extension_data_files(*extension)) + .any(|file| relative == Path::new(file)) } pub(super) fn packaged_extension_module_files() -> BTreeSet { Extension::ALL .iter() - .filter_map(|extension| extension.native_module_file()) + .flat_map(|extension| extension.native_module_files()) .collect() } @@ -47,10 +47,6 @@ pub(super) fn embedded_core_module_files() -> BTreeSet { .collect() } -pub(super) fn data_files(extension: Extension) -> &'static [&'static str] { - extension_data_files(extension) -} - pub(super) fn copy_extension_sql_files( source_share: &Path, target_share: &Path, @@ -92,7 +88,7 @@ pub(super) fn copy_named_extension_sql_files( if extension_sql_file_belongs(sql_name, &file_name) { if extension_sql_file_is_control(sql_name, &file_name) { copied_control = true; - } else if extension_sql_file_is_sql(&file_name) { + } else if extension_install_sql_file_belongs(sql_name, &file_name) { copied_sql = true; } copy_file_preserving_permissions(&entry.path(), &target_dir.join(&file_name))?; @@ -115,20 +111,6 @@ pub(super) fn copy_named_extension_sql_files( Ok(()) } -pub(super) fn copy_extension_data_files( - source_share: &Path, - target_share: &Path, - extension: Extension, -) -> Result<()> { - for relative in extension_data_files(extension) { - copy_file_preserving_permissions( - &source_share.join(relative), - &target_share.join(relative), - )?; - } - Ok(()) -} - pub(super) fn copy_embedded_module( embedded_modules: &Path, target_lib: &Path, diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs index db6b5d129..76ea7a7f6 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs @@ -1,4 +1,5 @@ mod cache_key; +mod extension_files; mod install; mod locate; diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs index 056ec98e6..432579162 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use super::super::NativeRuntimeProfile; use super::super::extensions::{ - core_share_file, data_files, embedded_core_module_files, extension_sql_file_is_control, + core_share_file, embedded_core_module_files, extension_sql_file_is_control, extension_sql_file_is_sql, packaged_extension_module_files, selected_extension_names, }; use super::super::files::sorted_read_dir; @@ -14,10 +14,13 @@ use super::super::fingerprint::{ }; use super::super::{NATIVE_RUNTIME_TOOLS, existing_native_tool_path, native_tool_path}; use super::extension_artifact_root_for; +use super::extension_files::{cache_extension_files, extension_files, manifest_path}; use crate::error::{Error, Result}; use crate::extension::Extension; +#[cfg(test)] +use crate::extension::extension_data_files as data_files; -const RUNTIME_CACHE_VERSION: &str = "pg18-runtime-cache-v7"; +const RUNTIME_CACHE_VERSION: &str = "pg18-runtime-cache-v8"; #[cfg(test)] pub(super) fn runtime_cache_key( @@ -90,8 +93,20 @@ pub(super) fn runtime_cache_key_with_icu( let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); let extension_share = extension_root.join("share/postgresql"); + let files = extension_files(install_dir, extension_root, *extension)?; + fingerprint_optional_file( + &mut state, + extension_root, + &manifest_path(extension_root, *extension), + )?; fingerprint_named_extension_sql_files(&mut state, &extension_share, extension.sql_name())?; - for relative in data_files(*extension) { + for member in &files.sql_members { + hash_str(&mut state, member); + if member != extension.sql_name() { + fingerprint_named_extension_sql_files(&mut state, &extension_share, member)?; + } + } + for relative in &files.data_files { fingerprint_optional_file( &mut state, &extension_share, @@ -139,14 +154,12 @@ pub(super) fn runtime_cache_key_with_icu( )?; } for extension in extensions { - if let Some(module) = extension.native_module_file() { - let extension_root = extension_artifact_root_for( - install_dir, - extension_artifact_dirs, - *extension, - ); + let extension_root = + extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); + for module in extension_files(install_dir, extension_root, *extension)?.module_files + { let extension_lib = extension_root.join("lib/modules"); - if extension_lib.join(&module).is_file() { + if extension_root != install_dir || extension_lib.join(&module).is_file() { fingerprint_optional_file( &mut state, &extension_lib, @@ -164,12 +177,10 @@ pub(super) fn runtime_cache_key_with_icu( } NativeRuntimeProfile::PostgresServer => { for extension in extensions { - if let Some(module) = extension.native_module_file() { - let extension_root = extension_artifact_root_for( - install_dir, - extension_artifact_dirs, - *extension, - ); + let extension_root = + extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); + for module in extension_files(install_dir, extension_root, *extension)?.module_files + { let extension_lib = extension_root.join("lib/postgresql"); fingerprint_optional_file( &mut state, @@ -244,21 +255,25 @@ pub(super) fn cached_runtime_is_valid_with_icu( } for extension in extensions { - if extension.creates_extension() - && (!cache_dir + let Ok(files) = cache_extension_files(cache_dir, *extension) else { + return false; + }; + for sql_name in &files.sql_members { + if !cache_dir .join("share/postgresql/extension") - .join(format!("{}.control", extension.sql_name())) + .join(format!("{sql_name}.control")) .is_file() - || !cache_contains_extension_sql_file(cache_dir, *extension)) - { - return false; + || !cache_contains_extension_sql_file(cache_dir, sql_name) + { + return false; + } } - if let Some(module) = extension.native_module_file() - && !cache_dir.join("lib/postgresql").join(module).is_file() - { - return false; + for module in &files.module_files { + if !cache_dir.join("lib/postgresql").join(module).is_file() { + return false; + } } - for relative in data_files(*extension) { + for relative in &files.data_files { if !cache_dir.join("share/postgresql").join(relative).is_file() { return false; } @@ -267,16 +282,16 @@ pub(super) fn cached_runtime_is_valid_with_icu( true } -fn cache_contains_extension_sql_file(cache_dir: &Path, extension: Extension) -> bool { +fn cache_contains_extension_sql_file(cache_dir: &Path, sql_name: &str) -> bool { let extension_dir = cache_dir.join("share/postgresql/extension"); let Ok(entries) = fs::read_dir(extension_dir) else { return false; }; entries.filter_map(|entry| entry.ok()).any(|entry| { let file_name = entry.file_name().to_string_lossy().into_owned(); - !extension_sql_file_is_control(extension.sql_name(), &file_name) + !extension_sql_file_is_control(sql_name, &file_name) && extension_sql_file_is_sql(&file_name) - && crate::extension::extension_sql_file_belongs(extension.sql_name(), &file_name) + && crate::extension::extension_install_sql_file_belongs(sql_name, &file_name) && entry.path().is_file() }) } @@ -550,6 +565,144 @@ mod tests { ); } + #[test] + fn postgis_companion_profiles_and_runtime_files_invalidate_only_selected_caches() { + let temp = TempTree::new("postgis-companion-cache"); + let install_dir = temp.path().join("install"); + let artifact_dir = temp.path().join("extension/postgis"); + let embedded_modules = temp.path().join("embedded-modules"); + write_fake_install(&install_dir); + write_fake_postgis_extension(&artifact_dir); + write_fake_postgis_extension(&install_dir); + for module in embedded_core_module_files() { + write_file(&embedded_modules.join(module), b"embedded-core"); + } + let key = |profile| { + runtime_cache_key( + profile, + &install_dir, + Some(&embedded_modules), + std::slice::from_ref(&artifact_dir), + &[Extension::POSTGIS], + ) + .unwrap() + }; + let server = key(NativeRuntimeProfile::PostgresServer); + let embedded = key(NativeRuntimeProfile::OliphauntEmbedded); + let companion = format!("postgis_raster-3{}", std::env::consts::DLL_SUFFIX); + write_file( + &artifact_dir.join("lib/postgresql").join(&companion), + b"changed server companion", + ); + assert_ne!(server, key(NativeRuntimeProfile::PostgresServer)); + assert_eq!(embedded, key(NativeRuntimeProfile::OliphauntEmbedded)); + write_file( + &artifact_dir.join("lib/modules").join(&companion), + b"changed embedded companion", + ); + let changed_module = key(NativeRuntimeProfile::OliphauntEmbedded); + assert_ne!(embedded, changed_module); + write_file( + &artifact_dir.join("share/postgresql/extension/postgis_topology.control"), + b"changed topology control", + ); + let changed_control = key(NativeRuntimeProfile::OliphauntEmbedded); + assert_ne!(changed_module, changed_control); + write_file( + &artifact_dir.join("share/postgresql/extension/postgis_tiger_geocoder--1.0.sql"), + b"changed tiger install", + ); + let changed_sql = key(NativeRuntimeProfile::OliphauntEmbedded); + assert_ne!(changed_control, changed_sql); + write_file( + &artifact_dir.join("share/postgresql/proj/proj.db"), + b"changed projection database", + ); + assert_ne!(changed_sql, key(NativeRuntimeProfile::OliphauntEmbedded)); + + let unselected = runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[], + &[], + ) + .unwrap(); + for module in Extension::POSTGIS.native_module_files() { + write_file( + &install_dir.join("lib/postgresql").join(module), + b"unselected changed module", + ); + } + for relative in data_files(Extension::POSTGIS) { + write_file( + &install_dir.join("share/postgresql").join(relative), + b"unselected changed data", + ); + } + assert_eq!( + unselected, + runtime_cache_key( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[], + &[] + ) + .unwrap() + ); + } + + #[test] + fn cached_postgis_runtime_requires_every_companion_module_control_and_install_script() { + let temp = TempTree::new("postgis-cache-completeness"); + let cache_dir = temp.path().join("cache"); + write_minimal_cache_dir(&cache_dir, "cache-key"); + + write_fake_postgis_extension(&cache_dir); + let valid = || { + cached_runtime_is_valid( + NativeRuntimeProfile::PostgresServer, + &cache_dir, + "cache-key", + &[Extension::POSTGIS], + ) + }; + assert!(valid()); + let mut required = Extension::POSTGIS + .native_module_files() + .into_iter() + .map(|module| cache_dir.join("lib/postgresql").join(module)) + .collect::>(); + for member in Extension::POSTGIS.sql_members() { + required.push( + cache_dir + .join("share/postgresql/extension") + .join(format!("{member}.control")), + ); + required.push( + cache_dir + .join("share/postgresql/extension") + .join(format!("{member}--1.0.sql")), + ); + write_file( + &cache_dir + .join("share/postgresql/extension") + .join(format!("{member}--ANY--1.0.sql")), + b"upgrade alone is insufficient", + ); + } + required.push(cache_dir.join("share/postgresql/proj/proj.db")); + required.push(manifest_path(&cache_dir, Extension::POSTGIS)); + for file in required { + let bytes = fs::read(&file).unwrap(); + fs::remove_file(&file).unwrap(); + assert!(!valid(), "cache accepted missing {}", file.display()); + write_file(&file, &bytes); + } + assert!(valid()); + } + #[test] fn icu_data_content_does_not_participate_in_base_runtime_cache_key() { let temp = TempTree::new("icu-data"); @@ -678,6 +831,13 @@ mod tests { let temp = TempTree::new("validation"); let cache_dir = temp.path().join("cache"); write_minimal_cache_dir(&cache_dir, "cache-key"); + let files = extension_files(&cache_dir, &cache_dir, Extension::HSTORE).unwrap(); + super::super::extension_files::write_cache_extension_files( + &cache_dir, + Extension::HSTORE, + &files, + ) + .unwrap(); assert!( cached_runtime_is_valid( @@ -700,6 +860,12 @@ mod tests { let sql_without_module = temp.path().join("cache-sql-without-module"); write_minimal_cache_dir(&sql_without_module, "cache-key"); + super::super::extension_files::write_cache_extension_files( + &sql_without_module, + Extension::HSTORE, + &files, + ) + .unwrap(); write_file( &sql_without_module.join("share/postgresql/extension/hstore.control"), b"comment = 'hstore'\n", @@ -859,6 +1025,40 @@ mod tests { ); } + fn write_fake_postgis_extension(root: &Path) { + for member in Extension::POSTGIS.sql_members() { + write_file( + &root + .join("share/postgresql/extension") + .join(format!("{member}.control")), + b"default_version = '1.0'\n", + ); + write_file( + &root + .join("share/postgresql/extension") + .join(format!("{member}--1.0.sql")), + b"select 1;\n", + ); + } + for module in Extension::POSTGIS.native_module_files() { + write_file(&root.join("lib/postgresql").join(&module), b"server module"); + write_file(&root.join("lib/modules").join(&module), b"embedded module"); + } + for relative in data_files(Extension::POSTGIS) { + write_file( + &root.join("share/postgresql").join(relative), + b"runtime data", + ); + } + let files = extension_files(root, root, Extension::POSTGIS).unwrap(); + super::super::extension_files::write_cache_extension_files( + root, + Extension::POSTGIS, + &files, + ) + .unwrap(); + } + fn write_minimal_cache_dir(cache_dir: &Path, key: &str) { write_file(&cache_dir.join(".complete"), b"ok\n"); write_file( diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs new file mode 100644 index 000000000..0557269e8 --- /dev/null +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs @@ -0,0 +1,218 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use crate::error::{Error, Result}; +use crate::extension::{Extension, extension_data_files}; + +pub(super) struct ExtensionFiles { + pub sql_members: Vec, + pub module_files: Vec, + pub data_files: Vec, +} + +pub(super) fn manifest_path(root: &Path, extension: Extension) -> PathBuf { + root.join(".oliphaunt/extensions") + .join(format!("{}.properties", extension.sql_name())) +} + +pub(super) fn extension_files( + install_dir: &Path, + root: &Path, + extension: Extension, +) -> Result { + let manifest = manifest_path(root, extension); + match fs::read_to_string(&manifest) { + Ok(text) => parse(&text, extension), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let current = root == install_dir; + Ok(ExtensionFiles { + sql_members: if current { + extension + .sql_members() + .iter() + .map(|value| (*value).to_owned()) + .collect() + } else if extension.creates_extension() { + vec![extension.sql_name().to_owned()] + } else { + Vec::new() + }, + module_files: if current { + extension.native_module_files() + } else { + extension.native_module_file().into_iter().collect() + }, + data_files: extension_data_files(extension) + .iter() + .map(|value| (*value).to_owned()) + .collect(), + }) + } + Err(error) => Err(Error::Engine(format!( + "read extension manifest {}: {error}", + manifest.display() + ))), + } +} + +fn parse(text: &str, extension: Extension) -> Result { + let invalid = || { + Error::Engine(format!( + "invalid native extension file manifest for {}", + extension.sql_name() + )) + }; + let mut fields = BTreeMap::new(); + for line in text.lines().filter(|line| !line.is_empty()) { + let (key, value) = line.split_once('=').ok_or_else(invalid)?; + if fields.insert(key, value).is_some() { + return Err(invalid()); + } + } + if fields.get("sqlName") != Some(&extension.sql_name()) + || fields.contains_key("sqlMembers") != fields.contains_key("nativeModuleStems") + { + return Err(invalid()); + } + let csv = |key: &str, fallback: Vec| -> Result> { + let Some(value) = fields.get(key) else { + return Ok(fallback); + }; + if value.is_empty() { + return Ok(Vec::new()); + } + let values = value.split(',').map(str::to_owned).collect::>(); + if values.iter().any(String::is_empty) + || values.iter().collect::>().len() != values.len() + { + return Err(invalid()); + } + Ok(values) + }; + let portable = |value: &str| { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + }; + let sql_members = csv( + "sqlMembers", + if extension.creates_extension() { + vec![extension.sql_name().to_owned()] + } else { + Vec::new() + }, + )?; + let stems = csv( + "nativeModuleStems", + extension + .native_module_stem() + .map(str::to_owned) + .into_iter() + .collect(), + )?; + if sql_members + .iter() + .chain(&stems) + .any(|value| !portable(value)) + || (extension.creates_extension() + && !sql_members.iter().any(|name| name == extension.sql_name())) + || extension + .native_module_stem() + .is_some_and(|primary| !stems.iter().any(|stem| stem == primary)) + { + return Err(invalid()); + } + let data_files = csv( + "dataFiles", + extension_data_files(extension) + .iter() + .map(|value| (*value).to_owned()) + .collect(), + )?; + if data_files.iter().any(|file| { + file.contains(['\\', ':']) + || Path::new(file) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + }) { + return Err(invalid()); + } + Ok(ExtensionFiles { + sql_members, + module_files: stems + .into_iter() + .map(|stem| format!("{stem}{}", std::env::consts::DLL_SUFFIX)) + .collect(), + data_files, + }) +} + +pub(super) fn cache_extension_files(root: &Path, extension: Extension) -> Result { + let path = manifest_path(root, extension); + let text = fs::read_to_string(&path).map_err(|error| { + Error::Engine(format!( + "read cached extension manifest {}: {error}", + path.display() + )) + })?; + parse(&text, extension) +} + +pub(super) fn write_cache_extension_files( + root: &Path, + extension: Extension, + files: &ExtensionFiles, +) -> Result<()> { + let path = manifest_path(root, extension); + fs::create_dir_all(path.parent().unwrap()).map_err(|error| { + Error::Engine(format!( + "create cached extension manifest directory: {error}" + )) + })?; + let stems = files + .module_files + .iter() + .map(|file| file.strip_suffix(std::env::consts::DLL_SUFFIX).unwrap()) + .collect::>(); + fs::write( + &path, + format!( + "sqlName={}\nsqlMembers={}\nnativeModuleStems={}\ndataFiles={}\n", + extension.sql_name(), + files.sql_members.join(","), + stems.join(","), + files.data_files.join(",") + ), + ) + .map_err(|error| { + Error::Engine(format!( + "write cached extension manifest {}: {error}", + path.display() + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frozen_extension_contract_rejects_incomplete_or_unsafe_metadata() { + for fields in [ + "sqlMembers=postgis\n", + "sqlMembers=postgis\nnativeModuleStems=../../postgis-3\n", + "sqlMembers=postgis,postgis\nnativeModuleStems=postgis-3\n", + "sqlMembers=postgis_topology\nnativeModuleStems=postgis-3\n", + "sqlMembers=postgis\nnativeModuleStems=postgis_topology-3\n", + "sqlMembers=postgis\nnativeModuleStems=postgis-3\ndataFiles=../outside\n", + "sqlMembers=postgis\nnativeModuleStems=postgis-3\nsqlMembers=postgis\n", + ] { + assert!( + parse(&format!("sqlName=postgis\n{fields}"), Extension::POSTGIS).is_err(), + "accepted {fields}" + ); + } + } +} diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs index 6bc185c5b..ef68e76f8 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs @@ -3,9 +3,8 @@ use std::path::{Path, PathBuf}; use super::super::NativeRuntimeProfile; use super::super::extensions::{ - copy_embedded_module, copy_extension_data_files, copy_extension_sql_files, - copy_named_extension_sql_files, core_share_file, embedded_core_module_files, - packaged_extension_module_files, + copy_embedded_module, copy_extension_sql_files, copy_named_extension_sql_files, + core_share_file, embedded_core_module_files, packaged_extension_module_files, }; use super::super::files::{ copy_directory_filtered, copy_directory_tree, copy_file_preserving_permissions, @@ -13,6 +12,7 @@ use super::super::files::{ }; use super::super::{NATIVE_RUNTIME_TOOLS, existing_native_tool_path, native_tool_path}; use super::extension_artifact_root_for; +use super::extension_files::{extension_files, write_cache_extension_files}; use crate::error::{Error, Result}; use crate::extension::Extension; @@ -159,8 +159,20 @@ fn install_native_share_tree( let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); let extension_share = extension_root.join("share/postgresql"); + let files = extension_files(install_dir, extension_root, *extension)?; copy_extension_sql_files(&extension_share, &target_share, *extension)?; - copy_extension_data_files(&extension_share, &target_share, *extension)?; + for member in &files.sql_members { + if member != extension.sql_name() { + copy_named_extension_sql_files(&extension_share, &target_share, member, true)?; + } + } + for relative in &files.data_files { + copy_file_preserving_permissions( + &extension_share.join(relative), + &target_share.join(relative), + )?; + } + write_cache_extension_files(runtime_dir, *extension, &files)?; } Ok(()) } @@ -502,6 +514,237 @@ mod tests { assert!(!runtime_dir.join("share/icu").exists()); } + #[test] + fn separate_postgis_artifacts_supply_every_module_profile_and_sql_member() { + let temp = TempTree::new("separate-postgis-artifact"); + let install_dir = temp.path().join("install"); + let artifact_dir = temp.path().join("extension/postgis"); + let contrib_dir = temp.path().join("extension/contrib"); + let embedded_modules = temp.path().join("embedded-modules"); + write_minimal_install(&install_dir); + write_extension_assets(&artifact_dir, Extension::POSTGIS); + write_extension_assets(&contrib_dir, Extension::FUZZYSTRMATCH); + write_extension_assets(&artifact_dir, Extension::HSTORE); + for extension in [Extension::POSTGIS, Extension::FUZZYSTRMATCH] { + let root = if extension == Extension::POSTGIS { + &artifact_dir + } else { + &contrib_dir + }; + for module in extension.native_module_files() { + write_file( + &root.join("lib/modules").join(&module), + b"selected embedded module", + ); + write_file( + &install_dir.join("lib/postgresql").join(&module), + b"unselected provider module", + ); + write_file( + &embedded_modules.join(&module), + b"unselected provider embedded module", + ); + } + } + for module in embedded_core_module_files() { + write_file(&embedded_modules.join(module), b"core embedded module"); + } + let artifacts = [artifact_dir.clone(), contrib_dir]; + let selected = Extension::POSTGIS.with_package(crate::extension::package_tests::postgis_package(Some( + "sqlName=postgis\ndependencies=fuzzystrmatch\nsqlMembers=postgis,postgis_raster,postgis_tiger_geocoder,postgis_topology\nnativeModuleStems=postgis-3,postgis_raster-3,postgis_topology-3\n", + ))); + let extensions = resolve_extensions(&[selected]).unwrap(); + let missing_dependency = install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + std::slice::from_ref(&artifact_dir), + &temp.path().join("missing-frozen-dependency"), + &extensions, + ) + .unwrap_err(); + assert!(missing_dependency.to_string().contains("fuzzystrmatch")); + for profile in [ + NativeRuntimeProfile::PostgresServer, + NativeRuntimeProfile::OliphauntEmbedded, + ] { + let runtime_dir = temp.path().join(profile.cache_id()); + install_cached_runtime( + profile, + &install_dir, + Some(&embedded_modules), + &artifacts, + &runtime_dir, + &extensions, + ) + .unwrap(); + for module in Extension::POSTGIS.native_module_files() { + let expected: &[u8] = if profile.needs_embedded_modules() { + b"selected embedded module" + } else { + b"postgis module\n" + }; + assert_eq!( + fs::read(runtime_dir.join("lib/postgresql").join(module)).unwrap(), + expected + ); + } + for member in Extension::POSTGIS.sql_members() { + assert!( + runtime_dir + .join("share/postgresql/extension") + .join(format!("{member}.control")) + .is_file() + ); + assert!( + runtime_dir + .join("share/postgresql/extension") + .join(format!("{member}--1.0.sql")) + .is_file() + ); + } + assert!( + !runtime_dir + .join("lib/postgresql") + .join(Extension::HSTORE.native_module_file().unwrap()) + .exists() + ); + } + + let companion = format!("postgis_topology-3{}", std::env::consts::DLL_SUFFIX); + for (profile, directory) in [ + (NativeRuntimeProfile::PostgresServer, "lib/postgresql"), + (NativeRuntimeProfile::OliphauntEmbedded, "lib/modules"), + ] { + fs::remove_file(artifact_dir.join(directory).join(&companion)).unwrap(); + let error = install_cached_runtime( + profile, + &install_dir, + Some(&embedded_modules), + &artifacts, + &temp.path().join("missing-module"), + &extensions, + ) + .unwrap_err(); + assert!(error.to_string().contains(&companion), "{error}"); + write_file( + &artifact_dir.join(directory).join(&companion), + b"restored selected companion", + ); + } + fs::remove_file( + artifact_dir.join("share/postgresql/extension/postgis_tiger_geocoder.control"), + ) + .unwrap(); + let error = install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &artifacts, + &temp.path().join("missing-control"), + &extensions, + ) + .unwrap_err(); + assert!( + error.to_string().contains("postgis_tiger_geocoder"), + "{error}" + ); + } + + #[test] + fn all_in_one_provider_does_not_install_unselected_companion_modules_or_data() { + let temp = TempTree::new("unselected-postgis-provider"); + let install_dir = temp.path().join("install"); + let runtime_dir = temp.path().join("runtime"); + write_minimal_install(&install_dir); + write_extension_assets(&install_dir, Extension::POSTGIS); + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[], + &runtime_dir, + &[], + ) + .unwrap(); + for module in Extension::POSTGIS.native_module_files() { + assert!(!runtime_dir.join("lib/postgresql").join(module).exists()); + } + for relative in crate::extension::extension_data_files(Extension::POSTGIS) { + assert!(!runtime_dir.join("share/postgresql").join(relative).exists()); + } + } + + #[test] + fn legacy_separate_postgis_artifact_keeps_its_primary_only_contract() { + let temp = TempTree::new("legacy-postgis-artifact"); + let install_dir = temp.path().join("install"); + let artifact_dir = temp.path().join("extension/postgis"); + write_minimal_install(&install_dir); + write_file( + &artifact_dir.join("share/postgresql/extension/postgis.control"), + b"default_version = '3.5.0'\n", + ); + write_file( + &artifact_dir.join("share/postgresql/extension/postgis--3.5.0.sql"), + b"select 'legacy postgis';\n", + ); + write_file( + &artifact_dir + .join("lib/postgresql") + .join(Extension::POSTGIS.native_module_file().unwrap()), + b"legacy primary module", + ); + for relative in crate::extension::extension_data_files(Extension::POSTGIS) { + write_file( + &artifact_dir.join("share/postgresql").join(relative), + b"legacy data", + ); + } + for preserved_manifest in [false, true] { + let manifest = + preserved_manifest.then_some("sqlName=postgis\ndependencies=\ndataFiles=\n"); + let selected = Extension::POSTGIS + .with_package(crate::extension::package_tests::postgis_package(manifest)); + let extensions = resolve_extensions(&[selected]).unwrap(); + assert_eq!(extensions, [selected]); + if preserved_manifest { + write_file( + &super::super::extension_files::manifest_path( + &artifact_dir, + Extension::POSTGIS, + ), + manifest.unwrap().as_bytes(), + ); + } + let runtime_dir = temp.path().join(format!("legacy-{preserved_manifest}")); + install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + std::slice::from_ref(&artifact_dir), + &runtime_dir, + &extensions, + ) + .unwrap(); + let files = super::super::extension_files::cache_extension_files( + &runtime_dir, + Extension::POSTGIS, + ) + .unwrap(); + assert_eq!(files.sql_members, ["postgis"]); + assert_eq!( + files.module_files, + [Extension::POSTGIS.native_module_file().unwrap()] + ); + assert!( + !runtime_dir + .join("share/postgresql/extension/postgis_topology.control") + .exists() + ); + } + } + struct TempTree { path: PathBuf, } @@ -555,24 +798,34 @@ mod tests { } fn write_extension_assets(install_dir: &Path, extension: Extension) { - write_file( - &install_dir - .join("share/postgresql/extension") - .join(format!("{}.control", extension.sql_name())), - format!("comment = '{}'\n", extension.sql_name()).as_bytes(), - ); - write_file( - &install_dir - .join("share/postgresql/extension") - .join(format!("{}--1.0.sql", extension.sql_name())), - format!("select '{} install';\n", extension.sql_name()).as_bytes(), - ); - if let Some(module) = extension.native_module_file() { + for member in extension.sql_members() { + write_file( + &install_dir + .join("share/postgresql/extension") + .join(format!("{member}.control")), + format!("comment = '{member}'\n").as_bytes(), + ); + write_file( + &install_dir + .join("share/postgresql/extension") + .join(format!("{member}--1.0.sql")), + format!("select '{member} install';\n").as_bytes(), + ); + } + for module in extension.native_module_files() { write_file( &install_dir.join("lib/postgresql").join(module), format!("{} module\n", extension.sql_name()).as_bytes(), ); } + for relative in crate::extension::extension_data_files(extension) { + write_file( + &install_dir.join("share/postgresql").join(relative), + b"extension data", + ); + } + let files = extension_files(install_dir, install_dir, extension).unwrap(); + write_cache_extension_files(install_dir, extension, &files).unwrap(); } fn write_file(path: &Path, contents: &[u8]) { @@ -639,35 +892,36 @@ fn install_native_library_tree( } } for extension in extensions { - let Some(module) = extension.native_module_file() else { - continue; - }; let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); let extension_lib = extension_root.join("lib/postgresql"); - match profile { - NativeRuntimeProfile::OliphauntEmbedded => { - let embedded_extension_lib = extension_root.join("lib/modules"); - if embedded_extension_lib.join(&module).is_file() { - copy_file_preserving_permissions( - &embedded_extension_lib.join(&module), - &target_lib.join(&module), - )?; - } else { - let embedded_modules = embedded_modules.ok_or_else(|| { + for module in extension_files(install_dir, extension_root, *extension)?.module_files { + match profile { + NativeRuntimeProfile::OliphauntEmbedded => { + let embedded_extension_lib = extension_root.join("lib/modules"); + if extension_root != install_dir + || embedded_extension_lib.join(&module).is_file() + { + copy_file_preserving_permissions( + &embedded_extension_lib.join(&module), + &target_lib.join(&module), + )?; + } else { + let embedded_modules = embedded_modules.ok_or_else(|| { Error::Engine( "native liboliphaunt runtime requires embedded PostgreSQL extension modules" .to_owned(), ) })?; - copy_embedded_module(embedded_modules, &target_lib, &module)?; + copy_embedded_module(embedded_modules, &target_lib, &module)?; + } + } + NativeRuntimeProfile::PostgresServer => { + copy_file_preserving_permissions( + &extension_lib.join(&module), + &target_lib.join(&module), + )?; } - } - NativeRuntimeProfile::PostgresServer => { - copy_file_preserving_permissions( - &extension_lib.join(&module), - &target_lib.join(&module), - )?; } } } diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java index 6dd1f2e1e..3e562ea51 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java @@ -211,7 +211,8 @@ static Map load(InputStream stream) { mavenGroup, mavenArtifact, runtimeBound, - dependencies)); + dependencies, + portableList(properties.getProperty(prefix + "sqlMembers", sqlName)))); } for (Entry entry : entries.values()) { for (String dependency : entry.dependencies()) { @@ -292,7 +293,8 @@ record Entry( String mavenGroup, String mavenArtifact, boolean runtimeBound, - List dependencies) { + List dependencies, + List sqlMembers) { String mavenCoordinate() { return mavenGroup + ":" + mavenArtifact; } diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java index bae95737d..849e8f50d 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java @@ -110,6 +110,8 @@ public abstract class ResolveOliphauntAndroidAssetsTask extends DefaultTask { "licenseFiles", "licenseProfile", "files"); + private static final Set OPTIONAL_EXTENSION_CLOSURE_KEYS = + Set.of("sqlMembers", "nativeModuleStems"); private static final List TARGET_INVARIANT_EXTENSION_PROPERTY_KEYS = List.of( "packageLayout", @@ -1201,6 +1203,7 @@ private static Map targetInvariantExtensionProperties( for (String key : TARGET_INVARIANT_EXTENSION_PROPERTY_KEYS) { result.put(key, manifest.getProperty(key, "")); } + for (String key : OPTIONAL_EXTENSION_CLOSURE_KEYS) result.put(key, manifest.getProperty(key, "")); return Collections.unmodifiableMap(result); } @@ -1295,6 +1298,7 @@ private static void validateExtensionManifest( String expectedRuntimeVersion) { TreeSet actualKeys = new TreeSet<>(manifest.stringPropertyNames()); TreeSet expectedKeys = new TreeSet<>(EXTENSION_ARTIFACT_PROPERTY_KEYS); + if (manifest.containsKey("sqlMembers")) expectedKeys.addAll(OPTIONAL_EXTENSION_CLOSURE_KEYS); if (!actualKeys.equals(expectedKeys)) { throw new GradleException( product @@ -1329,7 +1333,13 @@ private static void validateExtensionManifest( + catalogEntry.artifactProduct()); } String expectedDependencies = String.join(",", catalogEntry.dependencies()); - if (!expectedDependencies.equals(manifest.getProperty("dependencies"))) { + // Older primary-only releases predate companion SQL and its dependencies. + // Owner resolution already selects the current dependency closure, so a + // historical subset is safe while undeclared dependencies still fail closed. + boolean legacyCompanionRelease = !manifest.containsKey("sqlMembers") + && catalogEntry.sqlMembers().size() > 1 + && catalogEntry.dependencies().containsAll(strictManifestCsv(manifest, "dependencies", assetName)); + if (!expectedDependencies.equals(manifest.getProperty("dependencies")) && !legacyCompanionRelease) { throw new GradleException( product + " Android artifact " @@ -1440,6 +1450,7 @@ private static void validateExtensionManifestSemantics( String moduleFile = manifest.getProperty("nativeModuleFile"); String mobilePrebuilt = manifest.getProperty("mobilePrebuilt"); String symbolPrefix = manifest.getProperty("staticSymbolPrefix"); + extensionSqlMembers(manifest, assetName); List staticArchiveValues = strictManifestCsv(manifest, "mobileStaticArchives", assetName); List dependencyArchiveValues = @@ -1768,6 +1779,7 @@ private static void validateExtensionArtifactInventory( } String extensionDirectory = "files/share/postgresql/extension/"; + List sqlMembers = extensionSqlMembers(manifest, assetName); String controlFile = extensionDirectory + sqlName + ".control"; boolean hasControl = actualFiles.contains(controlFile); boolean hasInstallSql = false; @@ -1779,7 +1791,7 @@ private static void validateExtensionArtifactInventory( continue; } if (isOwnedExtensionSqlOrControl( - actual, sqlName, extensionSqlFileNames, extensionSqlFilePrefixes)) { + actual, sqlMembers, extensionSqlFileNames, extensionSqlFilePrefixes)) { expectedFiles.add(actual); hasInstallSql |= isInstallSql(actual, sqlName); hasMainExtensionCreationFile |= isMainExtensionCreationFile(actual, sqlName); @@ -1803,6 +1815,13 @@ private static void validateExtensionArtifactInventory( + " declares createsExtension=no but contains extension creation files for " + sqlName); } + for (String member : sqlMembers) { + if (!actualFiles.contains(extensionDirectory + member + ".control") + || actualFiles.stream().noneMatch(file -> file.startsWith(extensionDirectory) && isInstallSql(file, member))) { + throw new GradleException("Oliphaunt extension artifact " + assetName + + " must contain " + member + ".control and a canonical install SQL file for " + member); + } + } TreeSet missing = new TreeSet<>(declaredFiles); missing.removeAll(actualFiles); if (!missing.isEmpty() || !unexpected.isEmpty()) { @@ -1943,9 +1962,32 @@ private static String canonicalDeclaredPath( return normalized; } + private static List extensionSqlMembers(Properties manifest, String source) { + String sqlName = manifest.getProperty("sqlName"); + boolean creates = "yes".equals(manifest.getProperty("createsExtension")); + boolean declared = manifest.containsKey("sqlMembers"); + if (declared != manifest.containsKey("nativeModuleStems")) { + throw new GradleException(source + " sqlMembers and nativeModuleStems must be declared together"); + } + if (!declared) return creates ? List.of(sqlName) : List.of(); + List members = strictManifestCsv(manifest, "sqlMembers", source); + List modules = strictManifestCsv(manifest, "nativeModuleStems", source); + requireSortedManifestList(members, source, "sqlMembers"); + requireSortedManifestList(modules, source, "nativeModuleStems"); + if ((creates ? !members.contains(sqlName) : !members.isEmpty()) + || !OliphauntExtensionCatalog.require(sqlName).sqlMembers().containsAll(members)) { + throw new GradleException(source + " sqlMembers must identify the owned SQL closure"); + } + String stem = manifest.getProperty("nativeModuleStem", ""); + if (!modules.equals(stem.isEmpty() ? List.of() : List.of(stem))) { + throw new GradleException(source + " nativeModuleStems must contain only the merged primary mobile module"); + } + return members; + } + private static boolean isOwnedExtensionSqlOrControl( String path, - String sqlName, + List sqlMembers, List extensionSqlFileNames, List extensionSqlFilePrefixes) { String prefix = "files/share/postgresql/extension/"; @@ -1956,14 +1998,13 @@ private static boolean isOwnedExtensionSqlOrControl( if (name.indexOf('/') >= 0) { return false; } - if (name.equals(sqlName + ".control")) { + if (sqlMembers.stream().anyMatch(member -> name.equals(member + ".control"))) { return true; } if (!name.endsWith(".sql")) { return false; } - return name.equals(sqlName + ".sql") - || name.startsWith(sqlName + "--") + return sqlMembers.stream().anyMatch(member -> name.equals(member + ".sql") || name.startsWith(member + "--")) || extensionSqlFileNames.contains(name) || extensionSqlFilePrefixes.stream().anyMatch(name::startsWith); } @@ -2187,7 +2228,8 @@ private List mergeExtensionRuntimeArtifacts( : dashToNull(row.get("static_symbol_aliases")), "selected " + sqlName + " Android artifact"), splitCsv(dashToNull(row.get("archive_targets"))), - splitCsv(dashToNull(row.get("dependency_archives"))))); + splitCsv(dashToNull(row.get("dependency_archives"))), + extensionSqlMembers(artifactManifest, artifact.getName()))); } validateSelectedExtensionRuntimeFiles(runtimeFiles, artifacts); List nativeArtifacts = @@ -2268,12 +2310,14 @@ private static void validateSelectedExtensionRuntimeFiles(File runtimeFiles, Lis if (!artifact.createsExtension) { continue; } - File control = new File(extensionDir, artifact.sqlName + ".control"); - if (!control.isFile()) { - throw new GradleException( - "selected extension " + artifact.sqlName + " is missing packaged control file " + control); + for (String member : artifact.sqlMembers) { + File control = new File(extensionDir, member + ".control"); + if (!control.isFile()) { + throw new GradleException( + "selected extension " + artifact.sqlName + " is missing packaged control file " + control); + } + extensionSqlFiles(runtimeFiles, member, List.of(member)); } - extensionSqlFiles(runtimeFiles, artifact.sqlName); } } @@ -2767,7 +2811,7 @@ private static List staticRegistryModules( prefix, artifact.createsExtension ? collectExtensionSqlSymbols( - runtimeFiles, artifact.sqlName, artifact.nativeModuleStem) + runtimeFiles, artifact.sqlName, artifact.nativeModuleStem, artifact.sqlMembers) : List.of(), Collections.unmodifiableMap(aliases))); } @@ -2890,7 +2934,8 @@ static String staticRegistrySourceForContractTest( symbolPrefix, parseStaticSymbolAliases(symbolAliases, "contract-test " + sqlName), List.of(), - List.of()); + List.of(), + OliphauntExtensionCatalog.require(sqlName).sqlMembers()); return staticRegistrySourceText(staticRegistryModules(runtimeFiles, List.of(artifact))); } @@ -2913,7 +2958,8 @@ static String staticRegistryManifestForContractTest( symbolPrefix, parseStaticSymbolAliases(symbolAliases, "contract-test " + sqlName), archiveTargets, - dependencyArchives); + dependencyArchives, + OliphauntExtensionCatalog.require(sqlName).sqlMembers()); List modules = staticRegistryModules(runtimeFiles, List.of(artifact)); return staticRegistryManifestText(List.of(artifact), modules); @@ -2941,8 +2987,8 @@ private void copyMobileStaticTree(File source, File target) { } private static List collectExtensionSqlSymbols( - File runtimeFiles, String sqlName, String moduleStem) { - List sqlFiles = extensionSqlFiles(runtimeFiles, sqlName); + File runtimeFiles, String sqlName, String moduleStem, List sqlMembers) { + List sqlFiles = extensionSqlFiles(runtimeFiles, sqlName, sqlMembers); TreeSet symbols = new TreeSet<>(); for (File file : sqlFiles) { try { @@ -2956,11 +3002,12 @@ private static List collectExtensionSqlSymbols( return new ArrayList<>(symbols); } - private static List extensionSqlFiles(File runtimeFiles, String sqlName) { + private static List extensionSqlFiles(File runtimeFiles, String sqlName, List sqlMembers) { File extensionDir = new File(runtimeFiles, "share/postgresql/extension"); File[] sqlFiles = extensionDir.listFiles( - file -> file.isFile() && file.getName().startsWith(sqlName + "--") && file.getName().endsWith(".sql")); + file -> file.isFile() && file.getName().endsWith(".sql") + && sqlMembers.stream().anyMatch(member -> file.getName().startsWith(member + "--"))); if (sqlFiles == null || sqlFiles.length == 0) { throw new GradleException("selected extension " + sqlName + " has no packaged SQL files in " + extensionDir); } @@ -3666,7 +3713,7 @@ private static Properties parseExtensionArtifactProperties(byte[] bytes, String + " has a malformed property key " + key); } - if (!EXTENSION_ARTIFACT_PROPERTY_KEYS.contains(key)) { + if (!EXTENSION_ARTIFACT_PROPERTY_KEYS.contains(key) && !OPTIONAL_EXTENSION_CLOSURE_KEYS.contains(key)) { throw new GradleException( source + " physical line " + lineNumber + " has unknown property key " + key); } @@ -3790,7 +3837,8 @@ private record ExtensionRuntimeArtifact( String staticSymbolPrefix, List staticSymbolAliases, List archiveTargets, - List dependencyArchives) {} + List dependencyArchives, + List sqlMembers) {} private record StaticRegistryModule( String moduleStem, diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extension-legal-catalog.json b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extension-legal-catalog.json index 38a7cedd5..d334f12b6 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extension-legal-catalog.json +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extension-legal-catalog.json @@ -1,6 +1,6 @@ { "schema": "oliphaunt-android-extension-legal-catalog-v1", - "sourceCatalogSha256": "26ce911f641272aa18249e7c7d044dfa5ea7061d07119bb275544e164bb9249a", + "sourceCatalogSha256": "dbe1ab284693ef923cb12e35e1c75bcc7fc1ed42af1fecf9fe9fee0aaf0ef8d0", "contracts": [ { "scope": "aggregate", diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extensions.properties b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extensions.properties index 9d3c2a03a..391ccf6fc 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extensions.properties +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/resources/dev/oliphaunt/android/extensions.properties @@ -1,238 +1,277 @@ # This file is generated by src/extensions/tools/check-extension-model.sh. # Do not edit by hand. schema=oliphaunt-android-extension-catalog-v2 -catalogSha256=26ce911f641272aa18249e7c7d044dfa5ea7061d07119bb275544e164bb9249a +catalogSha256=dbe1ab284693ef923cb12e35e1c75bcc7fc1ed42af1fecf9fe9fee0aaf0ef8d0 extension.amcheck.artifactProduct=oliphaunt-extension-contrib-pg18 extension.amcheck.releaseProduct=liboliphaunt-native extension.amcheck.mavenGroup=dev.oliphaunt.extensions extension.amcheck.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.amcheck.runtimeBound=true extension.amcheck.dependencies= +extension.amcheck.sqlMembers=amcheck extension.auto_explain.artifactProduct=oliphaunt-extension-contrib-pg18 extension.auto_explain.releaseProduct=liboliphaunt-native extension.auto_explain.mavenGroup=dev.oliphaunt.extensions extension.auto_explain.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.auto_explain.runtimeBound=true extension.auto_explain.dependencies= +extension.auto_explain.sqlMembers= extension.bloom.artifactProduct=oliphaunt-extension-contrib-pg18 extension.bloom.releaseProduct=liboliphaunt-native extension.bloom.mavenGroup=dev.oliphaunt.extensions extension.bloom.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.bloom.runtimeBound=true extension.bloom.dependencies= +extension.bloom.sqlMembers=bloom extension.btree_gin.artifactProduct=oliphaunt-extension-contrib-pg18 extension.btree_gin.releaseProduct=liboliphaunt-native extension.btree_gin.mavenGroup=dev.oliphaunt.extensions extension.btree_gin.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.btree_gin.runtimeBound=true extension.btree_gin.dependencies= +extension.btree_gin.sqlMembers=btree_gin extension.btree_gist.artifactProduct=oliphaunt-extension-contrib-pg18 extension.btree_gist.releaseProduct=liboliphaunt-native extension.btree_gist.mavenGroup=dev.oliphaunt.extensions extension.btree_gist.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.btree_gist.runtimeBound=true extension.btree_gist.dependencies= +extension.btree_gist.sqlMembers=btree_gist extension.citext.artifactProduct=oliphaunt-extension-contrib-pg18 extension.citext.releaseProduct=liboliphaunt-native extension.citext.mavenGroup=dev.oliphaunt.extensions extension.citext.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.citext.runtimeBound=true extension.citext.dependencies= +extension.citext.sqlMembers=citext extension.cube.artifactProduct=oliphaunt-extension-contrib-pg18 extension.cube.releaseProduct=liboliphaunt-native extension.cube.mavenGroup=dev.oliphaunt.extensions extension.cube.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.cube.runtimeBound=true extension.cube.dependencies= +extension.cube.sqlMembers=cube extension.dict_int.artifactProduct=oliphaunt-extension-contrib-pg18 extension.dict_int.releaseProduct=liboliphaunt-native extension.dict_int.mavenGroup=dev.oliphaunt.extensions extension.dict_int.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.dict_int.runtimeBound=true extension.dict_int.dependencies= +extension.dict_int.sqlMembers=dict_int extension.dict_xsyn.artifactProduct=oliphaunt-extension-contrib-pg18 extension.dict_xsyn.releaseProduct=liboliphaunt-native extension.dict_xsyn.mavenGroup=dev.oliphaunt.extensions extension.dict_xsyn.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.dict_xsyn.runtimeBound=true extension.dict_xsyn.dependencies= +extension.dict_xsyn.sqlMembers=dict_xsyn extension.earthdistance.artifactProduct=oliphaunt-extension-contrib-pg18 extension.earthdistance.releaseProduct=liboliphaunt-native extension.earthdistance.mavenGroup=dev.oliphaunt.extensions extension.earthdistance.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.earthdistance.runtimeBound=true extension.earthdistance.dependencies=cube +extension.earthdistance.sqlMembers=earthdistance extension.file_fdw.artifactProduct=oliphaunt-extension-contrib-pg18 extension.file_fdw.releaseProduct=liboliphaunt-native extension.file_fdw.mavenGroup=dev.oliphaunt.extensions extension.file_fdw.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.file_fdw.runtimeBound=true extension.file_fdw.dependencies= +extension.file_fdw.sqlMembers=file_fdw extension.fuzzystrmatch.artifactProduct=oliphaunt-extension-contrib-pg18 extension.fuzzystrmatch.releaseProduct=liboliphaunt-native extension.fuzzystrmatch.mavenGroup=dev.oliphaunt.extensions extension.fuzzystrmatch.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.fuzzystrmatch.runtimeBound=true extension.fuzzystrmatch.dependencies= +extension.fuzzystrmatch.sqlMembers=fuzzystrmatch extension.hstore.artifactProduct=oliphaunt-extension-contrib-pg18 extension.hstore.releaseProduct=liboliphaunt-native extension.hstore.mavenGroup=dev.oliphaunt.extensions extension.hstore.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.hstore.runtimeBound=true extension.hstore.dependencies= +extension.hstore.sqlMembers=hstore extension.intarray.artifactProduct=oliphaunt-extension-contrib-pg18 extension.intarray.releaseProduct=liboliphaunt-native extension.intarray.mavenGroup=dev.oliphaunt.extensions extension.intarray.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.intarray.runtimeBound=true extension.intarray.dependencies= +extension.intarray.sqlMembers=intarray extension.isn.artifactProduct=oliphaunt-extension-contrib-pg18 extension.isn.releaseProduct=liboliphaunt-native extension.isn.mavenGroup=dev.oliphaunt.extensions extension.isn.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.isn.runtimeBound=true extension.isn.dependencies= +extension.isn.sqlMembers=isn extension.lo.artifactProduct=oliphaunt-extension-contrib-pg18 extension.lo.releaseProduct=liboliphaunt-native extension.lo.mavenGroup=dev.oliphaunt.extensions extension.lo.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.lo.runtimeBound=true extension.lo.dependencies= +extension.lo.sqlMembers=lo extension.ltree.artifactProduct=oliphaunt-extension-contrib-pg18 extension.ltree.releaseProduct=liboliphaunt-native extension.ltree.mavenGroup=dev.oliphaunt.extensions extension.ltree.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.ltree.runtimeBound=true extension.ltree.dependencies= +extension.ltree.sqlMembers=ltree extension.pageinspect.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pageinspect.releaseProduct=liboliphaunt-native extension.pageinspect.mavenGroup=dev.oliphaunt.extensions extension.pageinspect.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pageinspect.runtimeBound=true extension.pageinspect.dependencies= +extension.pageinspect.sqlMembers=pageinspect extension.pg_buffercache.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pg_buffercache.releaseProduct=liboliphaunt-native extension.pg_buffercache.mavenGroup=dev.oliphaunt.extensions extension.pg_buffercache.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pg_buffercache.runtimeBound=true extension.pg_buffercache.dependencies= +extension.pg_buffercache.sqlMembers=pg_buffercache extension.pg_freespacemap.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pg_freespacemap.releaseProduct=liboliphaunt-native extension.pg_freespacemap.mavenGroup=dev.oliphaunt.extensions extension.pg_freespacemap.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pg_freespacemap.runtimeBound=true extension.pg_freespacemap.dependencies= +extension.pg_freespacemap.sqlMembers=pg_freespacemap extension.pg_hashids.artifactProduct=oliphaunt-extension-pg-hashids extension.pg_hashids.releaseProduct=oliphaunt-extension-pg-hashids extension.pg_hashids.mavenGroup=dev.oliphaunt.extensions extension.pg_hashids.mavenArtifact=oliphaunt-extension-pg-hashids extension.pg_hashids.runtimeBound=false extension.pg_hashids.dependencies= +extension.pg_hashids.sqlMembers=pg_hashids extension.pg_ivm.artifactProduct=oliphaunt-extension-pg-ivm extension.pg_ivm.releaseProduct=oliphaunt-extension-pg-ivm extension.pg_ivm.mavenGroup=dev.oliphaunt.extensions extension.pg_ivm.mavenArtifact=oliphaunt-extension-pg-ivm extension.pg_ivm.runtimeBound=false extension.pg_ivm.dependencies= +extension.pg_ivm.sqlMembers=pg_ivm extension.pg_surgery.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pg_surgery.releaseProduct=liboliphaunt-native extension.pg_surgery.mavenGroup=dev.oliphaunt.extensions extension.pg_surgery.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pg_surgery.runtimeBound=true extension.pg_surgery.dependencies= +extension.pg_surgery.sqlMembers=pg_surgery extension.pg_textsearch.artifactProduct=oliphaunt-extension-pg-textsearch extension.pg_textsearch.releaseProduct=oliphaunt-extension-pg-textsearch extension.pg_textsearch.mavenGroup=dev.oliphaunt.extensions extension.pg_textsearch.mavenArtifact=oliphaunt-extension-pg-textsearch extension.pg_textsearch.runtimeBound=false extension.pg_textsearch.dependencies= +extension.pg_textsearch.sqlMembers=pg_textsearch extension.pg_trgm.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pg_trgm.releaseProduct=liboliphaunt-native extension.pg_trgm.mavenGroup=dev.oliphaunt.extensions extension.pg_trgm.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pg_trgm.runtimeBound=true extension.pg_trgm.dependencies= +extension.pg_trgm.sqlMembers=pg_trgm extension.pg_uuidv7.artifactProduct=oliphaunt-extension-pg-uuidv7 extension.pg_uuidv7.releaseProduct=oliphaunt-extension-pg-uuidv7 extension.pg_uuidv7.mavenGroup=dev.oliphaunt.extensions extension.pg_uuidv7.mavenArtifact=oliphaunt-extension-pg-uuidv7 extension.pg_uuidv7.runtimeBound=false extension.pg_uuidv7.dependencies= +extension.pg_uuidv7.sqlMembers=pg_uuidv7 extension.pg_visibility.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pg_visibility.releaseProduct=liboliphaunt-native extension.pg_visibility.mavenGroup=dev.oliphaunt.extensions extension.pg_visibility.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pg_visibility.runtimeBound=true extension.pg_visibility.dependencies= +extension.pg_visibility.sqlMembers=pg_visibility extension.pg_walinspect.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pg_walinspect.releaseProduct=liboliphaunt-native extension.pg_walinspect.mavenGroup=dev.oliphaunt.extensions extension.pg_walinspect.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pg_walinspect.runtimeBound=true extension.pg_walinspect.dependencies= +extension.pg_walinspect.sqlMembers=pg_walinspect extension.pgcrypto.artifactProduct=oliphaunt-extension-contrib-pg18 extension.pgcrypto.releaseProduct=liboliphaunt-native extension.pgcrypto.mavenGroup=dev.oliphaunt.extensions extension.pgcrypto.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.pgcrypto.runtimeBound=true extension.pgcrypto.dependencies= +extension.pgcrypto.sqlMembers=pgcrypto extension.pgtap.artifactProduct=oliphaunt-extension-pgtap extension.pgtap.releaseProduct=oliphaunt-extension-pgtap extension.pgtap.mavenGroup=dev.oliphaunt.extensions extension.pgtap.mavenArtifact=oliphaunt-extension-pgtap extension.pgtap.runtimeBound=false extension.pgtap.dependencies= +extension.pgtap.sqlMembers=pgtap extension.postgis.artifactProduct=oliphaunt-extension-postgis extension.postgis.releaseProduct=oliphaunt-extension-postgis extension.postgis.mavenGroup=dev.oliphaunt.extensions extension.postgis.mavenArtifact=oliphaunt-extension-postgis extension.postgis.runtimeBound=false -extension.postgis.dependencies= +extension.postgis.dependencies=fuzzystrmatch +extension.postgis.sqlMembers=postgis,postgis_raster,postgis_tiger_geocoder,postgis_topology extension.seg.artifactProduct=oliphaunt-extension-contrib-pg18 extension.seg.releaseProduct=liboliphaunt-native extension.seg.mavenGroup=dev.oliphaunt.extensions extension.seg.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.seg.runtimeBound=true extension.seg.dependencies= +extension.seg.sqlMembers=seg extension.tablefunc.artifactProduct=oliphaunt-extension-contrib-pg18 extension.tablefunc.releaseProduct=liboliphaunt-native extension.tablefunc.mavenGroup=dev.oliphaunt.extensions extension.tablefunc.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.tablefunc.runtimeBound=true extension.tablefunc.dependencies= +extension.tablefunc.sqlMembers=tablefunc extension.tcn.artifactProduct=oliphaunt-extension-contrib-pg18 extension.tcn.releaseProduct=liboliphaunt-native extension.tcn.mavenGroup=dev.oliphaunt.extensions extension.tcn.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.tcn.runtimeBound=true extension.tcn.dependencies= +extension.tcn.sqlMembers=tcn extension.tsm_system_rows.artifactProduct=oliphaunt-extension-contrib-pg18 extension.tsm_system_rows.releaseProduct=liboliphaunt-native extension.tsm_system_rows.mavenGroup=dev.oliphaunt.extensions extension.tsm_system_rows.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.tsm_system_rows.runtimeBound=true extension.tsm_system_rows.dependencies= +extension.tsm_system_rows.sqlMembers=tsm_system_rows extension.tsm_system_time.artifactProduct=oliphaunt-extension-contrib-pg18 extension.tsm_system_time.releaseProduct=liboliphaunt-native extension.tsm_system_time.mavenGroup=dev.oliphaunt.extensions extension.tsm_system_time.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.tsm_system_time.runtimeBound=true extension.tsm_system_time.dependencies= +extension.tsm_system_time.sqlMembers=tsm_system_time extension.unaccent.artifactProduct=oliphaunt-extension-contrib-pg18 extension.unaccent.releaseProduct=liboliphaunt-native extension.unaccent.mavenGroup=dev.oliphaunt.extensions extension.unaccent.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.unaccent.runtimeBound=true extension.unaccent.dependencies= +extension.unaccent.sqlMembers=unaccent extension.uuid-ossp.artifactProduct=oliphaunt-extension-contrib-pg18 extension.uuid-ossp.releaseProduct=liboliphaunt-native extension.uuid-ossp.mavenGroup=dev.oliphaunt.extensions extension.uuid-ossp.mavenArtifact=oliphaunt-extension-contrib-pg18 extension.uuid-ossp.runtimeBound=true extension.uuid-ossp.dependencies= +extension.uuid-ossp.sqlMembers=uuid-ossp extension.vector.artifactProduct=oliphaunt-extension-vector extension.vector.releaseProduct=oliphaunt-extension-vector extension.vector.mavenGroup=dev.oliphaunt.extensions extension.vector.mavenArtifact=oliphaunt-extension-vector extension.vector.runtimeBound=false extension.vector.dependencies= +extension.vector.sqlMembers=vector diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java index 0b6e3b128..4b0350611 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java @@ -860,6 +860,29 @@ private static void validatesPgcryptoAndPostgisLegalArtifacts() throws Exception "1.2.3"); ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest( postgisRoot.toFile(), postgis); + List postgisMembers = OliphauntExtensionCatalog.require("postgis").sqlMembers(); + if (!postgisMembers.contains("postgis_topology") || !postgisMembers.contains("postgis_tiger_geocoder")) { + throw new AssertionError("generated PostGIS SQL closure must include topology and tiger"); + } + postgis.setProperty("sqlMembers", String.join(",", postgisMembers)); + postgis.setProperty("nativeModuleStems", "postgis-3"); + postgis.setProperty("dependencies", String.join(",", OliphauntExtensionCatalog.require("postgis").dependencies())); + for (String member : postgisMembers) { + if (member.equals("postgis")) continue; + writeInventoryFile(postgisRoot, "files/share/postgresql/extension/" + member + ".control", "default_version='3.6.3'\n"); + writeInventoryFile(postgisRoot, "files/share/postgresql/extension/" + member + "--3.6.3.sql", "SELECT 1;\n"); + } + writeInventoryFile(postgisRoot, "manifest.properties", artifactManifestText(postgis)); + ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( + "oliphaunt-extension-postgis", "postgis", "android-arm64-v8a", postgis, "1.2.3"); + ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest(postgisRoot.toFile(), postgis); + Path topologyControl = postgisRoot.resolve("files/share/postgresql/extension/postgis_topology.control"); + Files.delete(topologyControl); + expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest(postgisRoot.toFile(), postgis), "postgis_topology.control and a canonical install SQL file"); + Files.writeString(topologyControl, "default_version='3.6.3'\n"); + Path foreignControl = writeInventoryFile(postgisRoot, "files/share/postgresql/extension/postgis_topology_extra.control", "foreign\n"); + expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest(postgisRoot.toFile(), postgis), "unexpected=[files/share/postgresql/extension/postgis_topology_extra.control]"); + Files.delete(foreignControl); long upstreamFiles; try (var files = Files.walk(postgisRoot.resolve("files/share/licenses"))) { upstreamFiles = files.filter(Files::isRegularFile).count(); @@ -2089,6 +2112,8 @@ private static void validatesStaticRegistrySymbolAliases() throws Exception { root, "share/postgresql/extension/postgis--3.6.3.sql", "CREATE FUNCTION public.difference() RETURNS integer AS 'MODULE_PATHNAME', 'difference' LANGUAGE C;\n"); + writeInventoryFile(root, "share/postgresql/extension/postgis_topology--3.6.3.sql", + "CREATE FUNCTION topology.fixture() RETURNS integer AS '$libdir/postgis-3', 'topology_fixture' LANGUAGE C;\n"); String source = ResolveOliphauntAndroidAssetsTask.staticRegistrySourceForContractTest( root.toFile(), @@ -2101,6 +2126,7 @@ private static void validatesStaticRegistrySymbolAliases() throws Exception { source, "extern void oliphaunt_static_postgis_3_difference(void);", "aliased SQL symbol declaration"); + requireContains(source, ".name = \"topology_fixture\"", "companion SQL symbols enter the merged PostGIS registry"); requireContains( source, ".name = \"difference\", .address = (void *)oliphaunt_static_postgis_3_difference", diff --git a/src/native/sdks/react-native/src/generated/extensions.ts b/src/native/sdks/react-native/src/generated/extensions.ts index 6ae9a6ab3..4570b8df3 100644 --- a/src/native/sdks/react-native/src/generated/extensions.ts +++ b/src/native/sdks/react-native/src/generated/extensions.ts @@ -15,9 +15,11 @@ export type GeneratedExtensionMetadata = { readonly runtimeBound: boolean; readonly createsExtension: boolean; readonly nativeModuleStem: string | null; + readonly nativeModuleStems: readonly string[]; + readonly sqlMembers: readonly string[]; + readonly iosStaticDependencies: readonly string[]; readonly dependencies: readonly string[]; readonly selectedExtensionDependencies: readonly string[]; - readonly iosStaticDependencies: readonly string[]; readonly sharedPreloadLibraries: readonly string[]; readonly dataFiles: readonly string[]; readonly runtimeShareDataFiles: readonly string[]; @@ -26,7 +28,7 @@ export type GeneratedExtensionMetadata = { readonly sourceKind: string; }; -export const GENERATED_EXTENSION_METADATA_SHA256 = "26ce911f641272aa18249e7c7d044dfa5ea7061d07119bb275544e164bb9249a" as const; +export const GENERATED_EXTENSION_METADATA_SHA256 = "dbe1ab284693ef923cb12e35e1c75bcc7fc1ed42af1fecf9fe9fee0aaf0ef8d0" as const; export const GENERATED_EXTENSION_METADATA = [ { @@ -43,6 +45,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "amcheck", + "nativeModuleStems": [ + "amcheck" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -51,6 +56,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "amcheck" + ], "sqlName": "amcheck" }, { @@ -67,6 +75,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "auto_explain", + "nativeModuleStems": [ + "auto_explain" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -75,6 +86,7 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [], "sqlName": "auto_explain" }, { @@ -91,6 +103,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "bloom", + "nativeModuleStems": [ + "bloom" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -99,6 +114,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "bloom" + ], "sqlName": "bloom" }, { @@ -115,6 +133,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "btree_gin", + "nativeModuleStems": [ + "btree_gin" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -123,6 +144,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "btree_gin" + ], "sqlName": "btree_gin" }, { @@ -139,6 +163,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "btree_gist", + "nativeModuleStems": [ + "btree_gist" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -147,6 +174,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "btree_gist" + ], "sqlName": "btree_gist" }, { @@ -163,6 +193,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "citext", + "nativeModuleStems": [ + "citext" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -171,6 +204,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "citext" + ], "sqlName": "citext" }, { @@ -187,6 +223,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "cube", + "nativeModuleStems": [ + "cube" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -195,6 +234,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "cube" + ], "sqlName": "cube" }, { @@ -211,6 +253,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "dict_int", + "nativeModuleStems": [ + "dict_int" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -219,6 +264,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "dict_int" + ], "sqlName": "dict_int" }, { @@ -237,6 +285,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "dict_xsyn", + "nativeModuleStems": [ + "dict_xsyn" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -247,6 +298,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "dict_xsyn" + ], "sqlName": "dict_xsyn" }, { @@ -265,6 +319,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "earthdistance", + "nativeModuleStems": [ + "earthdistance" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -275,6 +332,9 @@ export const GENERATED_EXTENSION_METADATA = [ ], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "earthdistance" + ], "sqlName": "earthdistance" }, { @@ -291,6 +351,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "file_fdw", + "nativeModuleStems": [ + "file_fdw" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -299,6 +362,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "file_fdw" + ], "sqlName": "file_fdw" }, { @@ -315,6 +381,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "fuzzystrmatch", + "nativeModuleStems": [ + "fuzzystrmatch" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -323,6 +392,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "fuzzystrmatch" + ], "sqlName": "fuzzystrmatch" }, { @@ -339,6 +411,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "hstore", + "nativeModuleStems": [ + "hstore" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -347,6 +422,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "hstore" + ], "sqlName": "hstore" }, { @@ -363,6 +441,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "_int", + "nativeModuleStems": [ + "_int" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -371,6 +452,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "intarray" + ], "sqlName": "intarray" }, { @@ -387,6 +471,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "isn", + "nativeModuleStems": [ + "isn" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -395,6 +482,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "isn" + ], "sqlName": "isn" }, { @@ -411,6 +501,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "lo", + "nativeModuleStems": [ + "lo" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -419,6 +512,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "lo" + ], "sqlName": "lo" }, { @@ -435,6 +531,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "ltree", + "nativeModuleStems": [ + "ltree" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -443,6 +542,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "ltree" + ], "sqlName": "ltree" }, { @@ -459,6 +561,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pageinspect", + "nativeModuleStems": [ + "pageinspect" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -467,6 +572,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pageinspect" + ], "sqlName": "pageinspect" }, { @@ -483,6 +591,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_buffercache", + "nativeModuleStems": [ + "pg_buffercache" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -491,6 +602,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_buffercache" + ], "sqlName": "pg_buffercache" }, { @@ -507,6 +621,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_freespacemap", + "nativeModuleStems": [ + "pg_freespacemap" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -515,6 +632,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_freespacemap" + ], "sqlName": "pg_freespacemap" }, { @@ -531,6 +651,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-hashids", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_hashids", + "nativeModuleStems": [ + "pg_hashids" + ], "npmPackage": "@oliphaunt/extension-pg-hashids", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-hashids", @@ -539,6 +662,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_hashids" + ], "sqlName": "pg_hashids" }, { @@ -555,6 +681,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-ivm", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_ivm", + "nativeModuleStems": [ + "pg_ivm" + ], "npmPackage": "@oliphaunt/extension-pg-ivm", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-ivm", @@ -563,6 +692,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_ivm" + ], "sqlName": "pg_ivm" }, { @@ -579,6 +711,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_surgery", + "nativeModuleStems": [ + "pg_surgery" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -587,6 +722,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_surgery" + ], "sqlName": "pg_surgery" }, { @@ -603,6 +741,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-textsearch", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_textsearch", + "nativeModuleStems": [ + "pg_textsearch" + ], "npmPackage": "@oliphaunt/extension-pg-textsearch", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-textsearch", @@ -613,6 +754,9 @@ export const GENERATED_EXTENSION_METADATA = [ "pg_textsearch" ], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_textsearch" + ], "sqlName": "pg_textsearch" }, { @@ -629,6 +773,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_trgm", + "nativeModuleStems": [ + "pg_trgm" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -637,6 +784,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_trgm" + ], "sqlName": "pg_trgm" }, { @@ -653,6 +803,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-uuidv7", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_uuidv7", + "nativeModuleStems": [ + "pg_uuidv7" + ], "npmPackage": "@oliphaunt/extension-pg-uuidv7", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-uuidv7", @@ -661,6 +814,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_uuidv7" + ], "sqlName": "pg_uuidv7" }, { @@ -677,6 +833,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_visibility", + "nativeModuleStems": [ + "pg_visibility" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -685,6 +844,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_visibility" + ], "sqlName": "pg_visibility" }, { @@ -701,6 +863,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_walinspect", + "nativeModuleStems": [ + "pg_walinspect" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -709,6 +874,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_walinspect" + ], "sqlName": "pg_walinspect" }, { @@ -727,6 +895,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pgcrypto", + "nativeModuleStems": [ + "pgcrypto" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -735,6 +906,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pgcrypto" + ], "sqlName": "pgcrypto" }, { @@ -758,6 +932,7 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pgtap", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": null, + "nativeModuleStems": [], "npmPackage": "@oliphaunt/extension-pgtap", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pgtap", @@ -766,6 +941,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pgtap" + ], "sqlName": "pgtap" }, { @@ -809,7 +987,9 @@ export const GENERATED_EXTENSION_METADATA = [ "share/postgresql/gdal/tms_NZTM2000.json", "share/postgresql/proj/proj.db" ], - "dependencies": [], + "dependencies": [ + "fuzzystrmatch" + ], "displayName": "PostGIS", "extensionSqlFileNames": [ "uninstall_postgis.sql" @@ -818,6 +998,8 @@ export const GENERATED_EXTENSION_METADATA = [ "postgis_comments", "postgis_proc_set_search_path", "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology", "rtpostgis" ], "id": "postgis", @@ -833,6 +1015,11 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-postgis", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "postgis-3", + "nativeModuleStems": [ + "postgis-3", + "postgis_raster-3", + "postgis_topology-3" + ], "npmPackage": "@oliphaunt/extension-postgis", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-postgis", @@ -874,9 +1061,17 @@ export const GENERATED_EXTENSION_METADATA = [ "gdal/tms_NZTM2000.json", "proj/proj.db" ], - "selectedExtensionDependencies": [], + "selectedExtensionDependencies": [ + "fuzzystrmatch" + ], "sharedPreloadLibraries": [], "sourceKind": "postgis", + "sqlMembers": [ + "postgis", + "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology" + ], "sqlName": "postgis" }, { @@ -893,6 +1088,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "seg", + "nativeModuleStems": [ + "seg" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -901,6 +1099,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "seg" + ], "sqlName": "seg" }, { @@ -917,6 +1118,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tablefunc", + "nativeModuleStems": [ + "tablefunc" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -925,6 +1129,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tablefunc" + ], "sqlName": "tablefunc" }, { @@ -941,6 +1148,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tcn", + "nativeModuleStems": [ + "tcn" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -949,6 +1159,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tcn" + ], "sqlName": "tcn" }, { @@ -965,6 +1178,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tsm_system_rows", + "nativeModuleStems": [ + "tsm_system_rows" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -973,6 +1189,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tsm_system_rows" + ], "sqlName": "tsm_system_rows" }, { @@ -989,6 +1208,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tsm_system_time", + "nativeModuleStems": [ + "tsm_system_time" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -997,6 +1219,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tsm_system_time" + ], "sqlName": "tsm_system_time" }, { @@ -1015,6 +1240,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "unaccent", + "nativeModuleStems": [ + "unaccent" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -1025,6 +1253,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "unaccent" + ], "sqlName": "unaccent" }, { @@ -1043,6 +1274,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "uuid-ossp", + "nativeModuleStems": [ + "uuid-ossp" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -1051,6 +1285,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "uuid-ossp" + ], "sqlName": "uuid-ossp" }, { @@ -1067,6 +1304,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-vector", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "vector", + "nativeModuleStems": [ + "vector" + ], "npmPackage": "@oliphaunt/extension-vector", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-vector", @@ -1075,6 +1315,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "vector" + ], "sqlName": "vector" } ] as const satisfies readonly GeneratedExtensionMetadata[]; diff --git a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts index 10f5775a7..82f64d1ee 100644 --- a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts +++ b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts @@ -50,6 +50,8 @@ const EXTENSION_MEMBER_KEYS = new Set([ 'extensionSqlFileNames', 'extensionSqlFilePrefixes', 'nativeModuleStem', + 'sqlMembers', + 'nativeModuleStems', 'iosNativeDependencies', 'iosRegistration', 'wasixInstall', @@ -166,7 +168,11 @@ function requireExactKeys(value, expected, context) { fail(`${context} must be an object`); } const actual = Object.keys(value).sort(compareText); - const canonical = [...expected].sort(compareText); + const canonical = [...expected] + .filter( + (key) => !['sqlMembers', 'nativeModuleStems'].includes(key) || Object.hasOwn(value, key), + ) + .sort(compareText); if (!isDeepStrictEqual(actual, canonical)) { fail(`${context} fields must be exactly ${canonical.join(',')}; got ${actual.join(',')}`); } @@ -420,6 +426,9 @@ async function loadRepositoryContract(root) { } const canonical = { sqlName, + sqlMembers: row['sql-members'] ?? (row['creates-extension'] ? [sqlName] : []), + nativeModuleStems: + row['native-module-stems'] ?? (nativeModuleStem === null ? [] : [nativeModuleStem]), createsExtension: row['creates-extension'], dependencies, dataFiles: canonicalStringList( @@ -594,6 +603,14 @@ function validateExactMobileRoles(member, target, assets, context) { } function validateMemberContract(member, expected, context) { + if (Object.hasOwn(member, 'sqlMembers') !== Object.hasOwn(member, 'nativeModuleStems')) + fail(`${context} sqlMembers and nativeModuleStems must be declared together`); + if (member.sqlMembers !== undefined) { + for (const field of ['sqlMembers', 'nativeModuleStems']) { + if (!isDeepStrictEqual(member[field], expected[field])) + fail(`${context}.${field} must exactly match generated React Native extension metadata`); + } + } for (const field of [ 'sqlName', 'createsExtension', diff --git a/src/native/sdks/react-native/tools/stage-ios-app.mts b/src/native/sdks/react-native/tools/stage-ios-app.mts index d161a3710..fa78a0b15 100755 --- a/src/native/sdks/react-native/tools/stage-ios-app.mts +++ b/src/native/sdks/react-native/tools/stage-ios-app.mts @@ -65,6 +65,8 @@ const EXTENSION_ARTIFACT_PROPERTY_KEYS = new Set([ 'staticSymbolAliases', 'licenseFiles', 'licenseProfile', + 'sqlMembers', + 'nativeModuleStems', 'files', ]); const GENERATED_EXTENSION_CATALOG = JSON.parse( @@ -696,6 +698,7 @@ function validateExtension(value, label, carriers) { 'sqlName', 'tag', 'version', + ...['sqlMembers', 'nativeModuleStems'].filter((key) => Object.hasOwn(root, key)), ], label, ); @@ -740,6 +743,25 @@ function validateExtension(value, label, carriers) { root.nativeModuleStem === null ? null : portable(root.nativeModuleStem, `${label}.nativeModuleStem`); + if (Object.hasOwn(root, 'sqlMembers') !== Object.hasOwn(root, 'nativeModuleStems')) + fail(`${label} sqlMembers and nativeModuleStems must be declared together`); + const sqlMembers = canonicalPortableList( + root.sqlMembers ?? (root.createsExtension ? [sqlName] : []), + `${label}.sqlMembers`, + ); + const nativeModuleStems = canonicalPortableList( + root.nativeModuleStems ?? (nativeModuleStem === null ? [] : [nativeModuleStem]), + `${label}.nativeModuleStems`, + ); + if (root.createsExtension ? !sqlMembers.includes(sqlName) : sqlMembers.length !== 0) + fail( + `${label}.sqlMembers must contain the primary extension exactly when createsExtension is true`, + ); + if ( + JSON.stringify(nativeModuleStems) !== + JSON.stringify(nativeModuleStem === null ? [] : [nativeModuleStem]) + ) + fail(`${label}.nativeModuleStems must contain only the merged primary mobile module`); const sharedPreloadLibraries = canonicalPortableList( root.sharedPreloadLibraries, `${label}.sharedPreloadLibraries`, @@ -806,6 +828,9 @@ function validateExtension(value, label, carriers) { return { assets: { dependencyFrameworks, extension, runtime }, createsExtension: root.createsExtension, + closureDeclared: Object.hasOwn(root, 'sqlMembers'), + sqlMembers, + nativeModuleStems, dataFiles, dependencies, extensionSqlFileNames, @@ -1797,17 +1822,20 @@ async function validateExactExtensionArtifactInventory( for (const legalFile of carrier.legal.files) expectedFiles.add(legalFile.member); if (carrier.createsExtension) { const extensionRoot = 'files/share/postgresql/extension'; + const sqlMembers = carrier.sqlMembers; const control = `${extensionRoot}/${carrier.sqlName}.control`; if (!actualFiles.has(control)) fail(`${source} is missing canonical control file ${control}`); expectedFiles.add(control); const ownedSqlFiles = [...actualFiles].filter((file) => { if (path.posix.dirname(file) !== extensionRoot) return false; const name = path.posix.basename(file); - if (name === `${carrier.sqlName}.sql`) return true; - const prefix = `${carrier.sqlName}--`; - if (!name.startsWith(prefix) || !name.endsWith('.sql')) return false; - const versionPath = name.slice(prefix.length, -'.sql'.length); - return /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(versionPath); + return sqlMembers.some((member) => { + if (name === `${member}.control` || name === `${member}.sql`) return true; + const prefix = `${member}--`; + if (!name.startsWith(prefix) || !name.endsWith('.sql')) return false; + const versionPath = name.slice(prefix.length, -'.sql'.length); + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(versionPath); + }); }); const installSqlFiles = ownedSqlFiles.filter((file) => { const name = path.posix.basename(file); @@ -1828,6 +1856,22 @@ async function validateExactExtensionArtifactInventory( ); }); for (const file of [...ownedSqlFiles, ...ancillarySqlFiles]) expectedFiles.add(file); + for (const member of sqlMembers) { + if (!actualFiles.has(`${extensionRoot}/${member}.control`)) + fail(`${source} is missing canonical control file ${member}.control`); + if ( + ![...actualFiles].some((file) => { + if (path.posix.dirname(file) !== extensionRoot) return false; + const name = path.posix.basename(file); + if (name === `${member}.sql`) return true; + const prefix = `${member}--`; + if (!name.startsWith(prefix) || !name.endsWith('.sql')) return false; + const version = name.slice(prefix.length, -'.sql'.length); + return !version.includes('--') && /^[0-9][A-Za-z0-9._-]*$/u.test(version); + }) + ) + fail(`${source} is missing an install SQL file owned by ${member}`); + } } for (const dataFile of dataFiles) { expectedFiles.add(`files/share/postgresql/${dataFile}`); @@ -1880,6 +1924,22 @@ async function extensionResourceRoot(carrier, base, cacheDir) { const manifestFile = path.join(root, 'manifest.properties'); const manifest = parseProperties(await fs.readFile(manifestFile, 'utf8'), manifestFile); rejectUnsupportedProperties(manifest, EXTENSION_ARTIFACT_PROPERTY_KEYS, manifestFile); + if ( + manifest.has('sqlMembers') !== manifest.has('nativeModuleStems') || + manifest.has('sqlMembers') !== carrier.closureDeclared + ) + fail( + `${manifestFile} sqlMembers and nativeModuleStems must match the frozen carrier declaration`, + ); + if (carrier.closureDeclared) { + requireProperty(manifest, 'sqlMembers', carrier.sqlMembers.join(','), manifestFile); + requireProperty( + manifest, + 'nativeModuleStems', + carrier.nativeModuleStems.join(','), + manifestFile, + ); + } requireProperty(manifest, 'packageLayout', 'oliphaunt-extension-artifact-v1', manifestFile); requireProperty(manifest, 'pgMajor', '18', manifestFile); requireProperty(manifest, 'sqlName', carrier.sqlName, manifestFile); @@ -1971,7 +2031,13 @@ async function extensionResourceRoot(carrier, base, cacheDir) { `${manifestFile} dataFiles must exactly match the frozen carrier contract for ${carrier.sqlName}`, ); } - requireExactPropertySet(manifest, EXTENSION_ARTIFACT_PROPERTY_KEYS, manifestFile); + requireExactPropertySet( + manifest, + [...EXTENSION_ARTIFACT_PROPERTY_KEYS].filter( + (key) => carrier.closureDeclared || !['sqlMembers', 'nativeModuleStems'].includes(key), + ), + manifestFile, + ); const mobileStaticArchives = mobileStaticArchivePaths(manifest, carrier, manifestFile); const mobileStaticDependencyArchives = mobileStaticDependencyArchivePaths( manifest, @@ -2321,17 +2387,19 @@ async function stage(args, base, selected) { const frozen = { base: { product: base.product, version: base.version, assets: base.assets }, cacheKey: `react-native-ios-${selectionHash.slice(0, 32)}`, - extensions: selected.map((carrier) => ({ - assets: carrier.assets, - createsExtension: carrier.createsExtension, - dependencies: carrier.dependencies, - nativeDependencies: carrier.nativeDependencies, - nativeModuleStem: carrier.nativeModuleStem, - product: carrier.product, - releaseProduct: carrier.releaseProduct, - sqlName: carrier.sqlName, - version: carrier.version, - })), + extensions: selected + .map((carrier) => ({ + assets: carrier.assets, + createsExtension: carrier.createsExtension, + dependencies: carrier.dependencies, + nativeDependencies: carrier.nativeDependencies, + nativeModuleStem: carrier.nativeModuleStem, + product: carrier.product, + releaseProduct: carrier.releaseProduct, + sqlName: carrier.sqlName, + version: carrier.version, + })) + .sort((left, right) => compareText(left.sqlName, right.sqlName)), requestedExtensions: args.extensions, icu: args.icu, seedProfile: args.seedProfile, @@ -2427,7 +2495,9 @@ export async function stageIosApp(options) { ); } } - const selected = selectedClosure(args.extensions, bySqlName); + const selected = selectedClosure(args.extensions, bySqlName).sort((left, right) => + compareText(left.sqlName, right.sqlName), + ); await stage(args, base, selected); return { outputDir: args.outputDir, selected: selected.map(({ sqlName }) => sqlName) }; } diff --git a/src/native/sdks/react-native/tools/stage-ios-app.test.mts b/src/native/sdks/react-native/tools/stage-ios-app.test.mts index a325f8903..b5112ed34 100755 --- a/src/native/sdks/react-native/tools/stage-ios-app.test.mts +++ b/src/native/sdks/react-native/tools/stage-ios-app.test.mts @@ -532,11 +532,27 @@ async function extensionRow(root, config) { `staticSymbolAliases=${staticSymbolAliases.join(',')}`, `licenseFiles=${licenseFiles.join(',')}`, `licenseProfile=${profile}`, + ...(config.sqlMembers === undefined + ? [] + : [ + `sqlMembers=${config.sqlMembers.join(',')}`, + `nativeModuleStems=${config.nativeModuleStem ?? ''}`, + ]), 'files=files', '', ].join('\n'), ); if (createsExtension) { + for (const member of (config.sqlMembers ?? []).filter((name) => name !== config.sqlName)) { + await write( + path.join(source, 'files/share/postgresql/extension', `${member}.control`), + "default_version = '1.0'\n", + ); + await write( + path.join(source, 'files/share/postgresql/extension', `${member}--1.0.sql`), + 'SELECT 1;\n', + ); + } await write( path.join(source, 'files', 'share', 'postgresql', 'extension', `${config.sqlName}.control`), `comment = '${config.sqlName} fixture'\n`, @@ -676,6 +692,12 @@ async function extensionRow(root, config) { extension: { assets: logicalAssets.map(({ locator }) => locator), createsExtension, + ...(config.sqlMembers === undefined + ? {} + : { + sqlMembers: config.sqlMembers, + nativeModuleStems: config.nativeModuleStem === null ? [] : [config.nativeModuleStem], + }), dataFiles, dependencies: config.dependencies, extensionSqlFileNames: config.extensionSqlFileNames, @@ -769,6 +791,7 @@ async function createFixture(root) { { sqlName: 'auto_explain' }, { sqlName: 'cube' }, { sqlName: 'earthdistance' }, + { sqlName: 'fuzzystrmatch' }, { sqlName: 'pgcrypto' }, { sqlName: 'pgtap', includeUpdateSql: true }, { @@ -802,6 +825,7 @@ async function createFixture(root) { const config = { ...fixture, createsExtension: generated['creates-extension'], + ...(fixture.sqlName === 'postgis' ? { sqlMembers: generated['sql-members'] } : {}), dataFiles: generated['runtime-share-data-files'], dependencies: generated['selected-extension-dependencies'], extensionSqlFileNames: generated['extension-sql-file-names'], @@ -905,11 +929,20 @@ async function main() { 'auto_explain', 'cube', 'earthdistance', + 'fuzzystrmatch', 'pgcrypto', 'pgtap', 'postgis', ]); await validateStagedPackage(output, false); + for (const member of GENERATED_EXTENSION_BY_SQL_NAME.get('postgis')['sql-members']) { + const directory = path.join( + output, + 'resources/OliphauntReactNativeResources.bundle/oliphaunt/runtime/files/share/postgresql/extension', + ); + await fs.access(path.join(directory, `${member}.control`)); + await fs.access(path.join(directory, `${member}--1.0.sql`)); + } const payloadPodspec = await fs.readFile( path.join(output, 'OliphauntReactNativePayload.podspec'), @@ -995,6 +1028,7 @@ async function main() { 'liboliphaunt_extension_auto_explain.xcframework', 'liboliphaunt_extension_cube.xcframework', 'liboliphaunt_extension_earthdistance.xcframework', + 'liboliphaunt_extension_fuzzystrmatch.xcframework', 'liboliphaunt_extension_pgcrypto.xcframework', 'liboliphaunt_extension_postgis-3.xcframework', ]); @@ -1063,12 +1097,15 @@ async function main() { ); assert.match( runtimeManifest, - /^selectedExtensions=auto_explain,cube,earthdistance,pgcrypto,pgtap,postgis$/mu, + /^selectedExtensions=auto_explain,cube,earthdistance,fuzzystrmatch,pgcrypto,pgtap,postgis$/mu, + ); + assert.match( + runtimeManifest, + /^extensions=cube,earthdistance,fuzzystrmatch,pgcrypto,pgtap,postgis$/mu, ); - assert.match(runtimeManifest, /^extensions=cube,earthdistance,pgcrypto,pgtap,postgis$/mu); assert.match( runtimeManifest, - /^mobileStaticRegistryRegistered=auto_explain,cube,earthdistance,pgcrypto,postgis$/mu, + /^mobileStaticRegistryRegistered=auto_explain,cube,earthdistance,fuzzystrmatch,pgcrypto,postgis$/mu, ); const packageSize = await fs.readFile( path.join( @@ -1083,7 +1120,7 @@ async function main() { assert.match(packageSize, /^extension\tauto_explain\t-\t0\t0$/mu); assert.match( packageSize, - /^extensions\tselected\tauto_explain,cube,earthdistance,pgcrypto,pgtap,postgis\t/mu, + /^extensions\tselected\tauto_explain,cube,earthdistance,fuzzystrmatch,pgcrypto,pgtap,postgis\t/mu, ); const registry = await fs.readFile( path.join(output, 'generated', 'static-registry', 'oliphaunt_static_registry.c'), @@ -1182,8 +1219,8 @@ async function main() { await fs.writeFile( missingCreateableManifest, (await fs.readFile(missingCreateableManifest, 'utf8')).replace( - /^extensions=cube,earthdistance,pgcrypto,pgtap,postgis$/mu, - 'extensions=cube,earthdistance,pgcrypto,postgis', + /^extensions=cube,earthdistance,fuzzystrmatch,pgcrypto,pgtap,postgis$/mu, + 'extensions=cube,earthdistance,fuzzystrmatch,pgcrypto,postgis', ), ); await assert.rejects( @@ -1204,8 +1241,8 @@ async function main() { await fs.writeFile( missingNativeRegistrationManifest, (await fs.readFile(missingNativeRegistrationManifest, 'utf8')).replace( - /^mobileStaticRegistryRegistered=auto_explain,cube,earthdistance,pgcrypto,postgis$/mu, - 'mobileStaticRegistryRegistered=cube,earthdistance,pgcrypto,postgis', + /^mobileStaticRegistryRegistered=auto_explain,cube,earthdistance,fuzzystrmatch,pgcrypto,postgis$/mu, + 'mobileStaticRegistryRegistered=cube,earthdistance,fuzzystrmatch,pgcrypto,postgis', ), ); await assert.rejects( @@ -1803,6 +1840,28 @@ async function main() { /missing an install SQL file owned by pgtap/u, ); + const missingCompanion = structuredClone(carrier); + replaceExtensionRuntimeAsset( + missingCompanion, + 'postgis', + await mutatedExtensionRuntime( + root, + 'postgis', + 'missing-topology-control.tar.gz', + async (stage) => { + await fs.rm( + path.join(stage, 'files/share/postgresql/extension/postgis_topology.control'), + ); + }, + ), + ); + await expectCarrierFailure( + 'missing-topology-control', + missingCompanion, + ['postgis'], + /missing canonical control file postgis_topology\.control/u, + ); + const wrongDependencyArchive = structuredClone(carrier); replaceExtensionRuntimeAsset( wrongDependencyArchive, diff --git a/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts b/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts index 16454b1fe..092cd092a 100644 --- a/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts +++ b/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts @@ -81,6 +81,11 @@ function extensionContracts(document, label) { } contracts.push({ createsExtension: row['creates-extension'], + sqlMembers: canonicalStringList( + row['sql-members'] ?? (row['creates-extension'] ? [sqlName] : []), + `${rowLabel}.sql-members`, + (value) => PORTABLE_SQL_NAME.test(value), + ), dataFiles: canonicalStringList( row['data-files'], `${rowLabel}.data-files`, @@ -149,11 +154,13 @@ function isBaselineExtensionAsset(fileName) { function ownsExtensionAsset(contract, fileName) { return ( - (contract.createsExtension && fileName === `${contract.sqlName}.control`) || - (contract.createsExtension && fileName === `${contract.sqlName}.sql`) || (contract.createsExtension && - fileName.startsWith(`${contract.sqlName}--`) && - fileName.endsWith('.sql')) || + contract.sqlMembers.some( + (member) => + fileName === `${member}.control` || + fileName === `${member}.sql` || + (fileName.startsWith(`${member}--`) && fileName.endsWith('.sql')), + )) || contract.extensionSqlFileNames.includes(fileName) || (fileName.endsWith('.sql') && contract.extensionSqlFilePrefixes.some((prefix) => fileName.startsWith(prefix))) diff --git a/src/native/sdks/swift/tools/extension-resource-inventory.mts b/src/native/sdks/swift/tools/extension-resource-inventory.mts index beedaf487..1d1f73e46 100644 --- a/src/native/sdks/swift/tools/extension-resource-inventory.mts +++ b/src/native/sdks/swift/tools/extension-resource-inventory.mts @@ -140,6 +140,11 @@ function normalizeCanonicalRow(row, label) { } return { sqlName, + sqlMembers: canonicalList( + row?.['sql-members'] ?? (createsExtension ? [sqlName] : []), + `${label}.sql-members`, + portable, + ), product, releaseProduct, createsExtension, @@ -171,6 +176,29 @@ function normalizeFrozenRuntimeContract(row, label) { if (typeof row?.createsExtension !== 'boolean') fail(label, 'createsExtension must be boolean'); const nativeModuleStem = row?.nativeModuleStem ?? null; if (nativeModuleStem !== null) portable(nativeModuleStem, `${label}.nativeModuleStem`); + const closureDeclared = row.sqlMembers !== undefined; + if (closureDeclared !== (row.nativeModuleStems !== undefined)) + fail(label, 'sqlMembers and nativeModuleStems must be declared together'); + const sqlMembers = canonicalList( + row.sqlMembers ?? (row.createsExtension ? [sqlName] : []), + `${label}.sqlMembers`, + portable, + ); + const nativeModuleStems = canonicalList( + row.nativeModuleStems ?? (nativeModuleStem === null ? [] : [nativeModuleStem]), + `${label}.nativeModuleStems`, + portable, + ); + if (row.createsExtension ? !sqlMembers.includes(sqlName) : sqlMembers.length !== 0) + fail( + label, + 'sqlMembers must contain the primary extension exactly when createsExtension is true', + ); + if ( + JSON.stringify(nativeModuleStems) !== + JSON.stringify(nativeModuleStem === null ? [] : [nativeModuleStem]) + ) + fail(label, 'mobile nativeModuleStems must contain only the merged primary module'); const dependencies = canonicalList(row?.dependencies, `${label}.dependencies`, portable); if (dependencies.includes(sqlName)) fail(label, 'dependencies must not include the extension itself'); @@ -190,6 +218,9 @@ function normalizeFrozenRuntimeContract(row, label) { fail(label, 'extensionSqlFilePrefixes must contain dot-free portable basename prefixes'); } return { + closureDeclared, + sqlMembers, + nativeModuleStems, createsExtension: row.createsExtension, dataFiles: canonicalList(row?.dataFiles, `${label}.dataFiles`, canonicalRelativePath), dependencies, @@ -275,7 +306,13 @@ function parseProperties(text, label) { if (properties.has(key)) fail(label, `repeats property ${key}`); properties.set(key, line.slice(separator + 1)); } - if (JSON.stringify([...properties.keys()]) !== JSON.stringify(PROPERTY_KEYS)) { + const closureKeys = ['sqlMembers', 'nativeModuleStems']; + if (properties.has(closureKeys[0]) !== properties.has(closureKeys[1])) + fail(label, 'sqlMembers and nativeModuleStems must be declared together'); + const expectedKeys = properties.has(closureKeys[0]) + ? [...PROPERTY_KEYS.slice(0, -1), ...closureKeys, 'files'] + : PROPERTY_KEYS; + if (JSON.stringify([...properties.keys()]) !== JSON.stringify(expectedKeys)) { fail( label, `must contain the exact canonical fields in canonical order: ${PROPERTY_KEYS.join(',')}`, @@ -596,11 +633,13 @@ export async function snapshotSafeFileTree( function sqlOwned(fileName, canonical) { return ( - (canonical.createsExtension && fileName === `${canonical.sqlName}.control`) || - (canonical.createsExtension && fileName === `${canonical.sqlName}.sql`) || (canonical.createsExtension && - fileName.startsWith(`${canonical.sqlName}--`) && - fileName.endsWith('.sql')) || + canonical.sqlMembers.some( + (member) => + fileName === `${member}.control` || + fileName === `${member}.sql` || + (fileName.startsWith(`${member}--`) && fileName.endsWith('.sql')), + )) || canonical.extensionSqlFileNames.includes(fileName) || (fileName.endsWith('.sql') && canonical.extensionSqlFilePrefixes.some((prefix) => fileName.startsWith(prefix))) @@ -673,6 +712,12 @@ export async function validateSwiftExtensionResourceArtifact({ ['mobilePrebuilt', contract.nativeModuleStem === null ? 'no' : 'yes'], ['files', 'files'], ]); + if (properties.has('sqlMembers') !== contract.closureDeclared) + fail(label, 'manifest SQL closure declaration differs from the frozen carrier contract'); + if (contract.closureDeclared) { + expected.set('sqlMembers', contract.sqlMembers.join(',')); + expected.set('nativeModuleStems', contract.nativeModuleStems.join(',')); + } for (const [key, value] of expected) { if (properties.get(key) !== value) fail(label, `manifest ${key} must be ${JSON.stringify(value)}`); @@ -730,6 +775,17 @@ export async function validateSwiftExtensionResourceArtifact({ if (contract.createsExtension && (!hasControl || !hasSql)) { fail(label, `must contain ${contract.sqlName}.control and canonical base installation SQL`); } + for (const member of contract.sqlMembers) { + if ( + !byPath.has(`files/share/postgresql/extension/${member}.control`) || + !files.some( + ({ relative }) => + relative.startsWith('files/share/postgresql/extension/') && + isCanonicalInstallSql(relative.slice('files/share/postgresql/extension/'.length), member), + ) + ) + fail(label, `must contain ${member}.control and canonical base installation SQL`); + } for (const dataFile of contract.dataFiles) allowed.add(`files/share/postgresql/${dataFile}`); if (contract.nativeModuleStem !== null) { allowed.add(`files/lib/postgresql/${contract.nativeModuleStem}.dylib`); diff --git a/src/native/sdks/swift/tools/extension-resource-inventory.test.mts b/src/native/sdks/swift/tools/extension-resource-inventory.test.mts index 77cba817d..1b0cb3432 100644 --- a/src/native/sdks/swift/tools/extension-resource-inventory.test.mts +++ b/src/native/sdks/swift/tools/extension-resource-inventory.test.mts @@ -119,6 +119,66 @@ async function main() { const postgis = extensions.get('postgis'); const postgisManifest = path.join(postgis.resourceRoot, 'manifest.properties'); const postgisManifestText = await fs.readFile(postgisManifest, 'utf8'); + const sqlMembers = catalog.get('postgis').sqlMembers; + assert.ok( + sqlMembers.includes('postgis_topology') && sqlMembers.includes('postgis_tiger_geocoder'), + ); + const expandedPostgis = { ...postgis, sqlMembers, nativeModuleStems: ['postgis-3'] }; + await fs.writeFile( + postgisManifest, + postgisManifestText.replace( + 'files=files\n', + `sqlMembers=${sqlMembers.join(',')}\nnativeModuleStems=postgis-3\nfiles=files\n`, + ), + ); + const companionFiles = []; + for (const member of sqlMembers.filter((name) => name !== 'postgis')) { + for (const name of [`${member}.control`, `${member}--1.0.sql`]) { + const file = path.join(postgis.resourceRoot, 'files/share/postgresql/extension', name); + await fs.writeFile(file, '-- companion fixture\n'); + companionFiles.push(file); + } + } + const expandedResources = await validateSwiftExtensionResourceArtifact({ + extension: expandedPostgis, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }); + assert.ok( + expandedResources.files.some( + ({ relative }) => relative === 'extension/postgis_topology.control', + ), + ); + const missingCompanion = companionFiles.find((file) => + file.endsWith('/postgis_topology.control'), + ); + await fs.rm(missingCompanion); + await assert.rejects( + () => + validateSwiftExtensionResourceArtifact({ + extension: expandedPostgis, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }), + /postgis_topology\.control and canonical base installation SQL/u, + ); + await fs.writeFile(missingCompanion, '-- companion fixture\n'); + const foreignControl = path.join( + postgis.resourceRoot, + 'files/share/postgresql/extension/postgis_topology_extra.control', + ); + await fs.writeFile(foreignControl, '-- unowned lookalike\n'); + await assert.rejects( + () => + validateSwiftExtensionResourceArtifact({ + extension: expandedPostgis, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }), + /undeclared extension SQL\/control file/u, + ); + await Promise.all([...companionFiles, foreignControl].map((file) => fs.rm(file))); + await fs.writeFile(postgisManifest, postgisManifestText); await assert.rejects( () => validateSwiftExtensionResourceArtifact({ diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.mts index 7ccaef4ba..63a323b74 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.mts @@ -646,6 +646,8 @@ function extensionCarrier( ); } const sqlName = portableIdentifier(manifest.sqlName, `${manifestPath}.sqlName`); + if ((manifest.sqlMembers === undefined) !== (manifest.nativeModuleStems === undefined)) + throw error(`${manifestPath} sqlMembers and nativeModuleStems must be declared together`); if (typeof manifest.createsExtension !== 'boolean') { throw error(`${manifestPath} ${sqlName}.createsExtension must be boolean`); } @@ -844,6 +846,15 @@ function extensionCarrier( tag, sqlName, createsExtension: manifest.createsExtension, + ...(manifest.sqlMembers === undefined + ? {} + : { + sqlMembers: canonicalStringList( + manifest.sqlMembers, + `${manifestPath} ${sqlName}.sqlMembers`, + ), + nativeModuleStems: nativeModuleStem === null ? [] : [nativeModuleStem], + }), dataFiles, dependencies, extensionSqlFileNames, diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts index b9fb1cc61..e7a9820e3 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts @@ -274,6 +274,8 @@ test('produces exact local and GitHub carrier envelopes ', async () => { ); const postgis = writeManifest(root, 'oliphaunt-extension-postgis', { sqlName: 'postgis', + sqlMembers: ['postgis', 'postgis_raster', 'postgis_tiger_geocoder', 'postgis_topology'], + nativeModuleStems: ['postgis-3', 'postgis_raster-3', 'postgis_topology-3'], nativeModuleStem: 'postgis-3', iosNativeDependencies: ['geos'], iosRegistration: { @@ -296,6 +298,15 @@ test('produces exact local and GitHub carrier envelopes ', async () => { extensionManifests: [postgis, pgtap], localUrls: true, }); + assert.deepEqual(local.extensions.find((row) => row.sqlName === 'postgis').sqlMembers, [ + 'postgis', + 'postgis_raster', + 'postgis_tiger_geocoder', + 'postgis_topology', + ]); + assert.deepEqual(local.extensions.find((row) => row.sqlName === 'postgis').nativeModuleStems, [ + 'postgis-3', + ]); assert.deepEqual( local.base.assets.map(({ role }) => role), ['base-xcframework', 'runtime-resources'], diff --git a/src/native/sdks/swift/tools/render-extension-products.mts b/src/native/sdks/swift/tools/render-extension-products.mts index c9915e835..b13aabf65 100755 --- a/src/native/sdks/swift/tools/render-extension-products.mts +++ b/src/native/sdks/swift/tools/render-extension-products.mts @@ -438,6 +438,7 @@ export function validateSelection( 'sharedPreloadLibraries', 'sqlName', 'version', + ...['sqlMembers', 'nativeModuleStems'].filter((key) => Object.hasOwn(row, key)), ], `extensions[${index}]`, ); @@ -498,6 +499,9 @@ export function validateSelection( cFunction: nativeModuleStem === null ? null : `oliphaunt_extension_${cModuleStem}_descriptor`, cTarget: nativeModuleStem === null ? null : `COliphauntExtension${suffix}`, createsExtension: row.createsExtension, + ...(row.sqlMembers === undefined + ? {} + : { sqlMembers: row.sqlMembers, nativeModuleStems: row.nativeModuleStems }), dataFiles: frozenDataFiles(row.dataFiles, `extensions[${index}].dataFiles`), dependencies, extensionSqlFileNames: frozenSqlFileNames( diff --git a/src/native/sdks/swift/tools/swift-carrier-resolver.mts b/src/native/sdks/swift/tools/swift-carrier-resolver.mts index b0f3eb594..a2028c7bd 100644 --- a/src/native/sdks/swift/tools/swift-carrier-resolver.mts +++ b/src/native/sdks/swift/tools/swift-carrier-resolver.mts @@ -463,6 +463,7 @@ function validateExtension(value, label, carriers) { 'sqlName', 'tag', 'version', + ...['sqlMembers', 'nativeModuleStems'].filter((key) => Object.hasOwn(row, key)), ], label, ); @@ -482,7 +483,15 @@ function validateExtension(value, label, carriers) { const dependencies = canonicalIds(row.dependencies, `${label}.dependencies`); if (dependencies.includes(sqlName)) fail(`${label}.dependencies must not include ${sqlName} itself`); + if (Object.hasOwn(row, 'sqlMembers') !== Object.hasOwn(row, 'nativeModuleStems')) + fail(`${label} sqlMembers and nativeModuleStems must be declared together`); return { + ...(Object.hasOwn(row, 'sqlMembers') + ? { + sqlMembers: canonicalIds(row.sqlMembers, `${label}.sqlMembers`), + nativeModuleStems: canonicalIds(row.nativeModuleStems, `${label}.nativeModuleStems`), + } + : {}), assets: assetLocators(row.assets, `${label}.assets`, carriers), createsExtension: row.createsExtension, dataFiles: canonicalRelativeFiles(row.dataFiles, `${label}.dataFiles`), @@ -1029,6 +1038,12 @@ export async function resolveSwiftCarrierSelection({ version: row.version, sqlName: row.sqlName, createsExtension: row.createsExtension, + ...(row.sqlMembers === undefined + ? {} + : { + sqlMembers: row.sqlMembers, + nativeModuleStems: row.nativeModuleStems, + }), dataFiles: row.dataFiles, dependencies: row.dependencies, extensionSqlFileNames: row.extensionSqlFileNames, diff --git a/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts b/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts index 4a8c4fa24..96fb95003 100755 --- a/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts +++ b/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts @@ -14,12 +14,14 @@ import { metadataZip, } from '../../../../../tools/packaging/testdata/zip-fixture.mts'; import { renderExtensionProducts } from './render-extension-products.mts'; +import { loadSwiftExtensionInventoryCatalog } from './extension-resource-inventory.mts'; import { extractVerifiedZipArchive, resolveSwiftCarrierSelection, } from './swift-carrier-resolver.mts'; const sdk = path.resolve(import.meta.dirname, '..'); +const canonicalCatalog = await loadSwiftExtensionInventoryCatalog(); const root = path.resolve( process.argv[2] ?? path.join(sdk, '../../../../target/liboliphaunt-sdk-check/swift-carrier-test'), ); @@ -284,6 +286,20 @@ async function productionExtensionResource(sqlName, stem, nativeDependencies) { 'mobileStaticDependencyArchives=\n', `mobileStaticDependencyArchives=${mobileStaticDependencyArchives.join(',')}\n`, ); + if (sqlName === 'postgis') { + const sqlMembers = canonicalCatalog.get(sqlName).sqlMembers; + manifest = manifest.replace( + 'files=files\n', + `sqlMembers=${sqlMembers.join(',')}\nnativeModuleStems=${stem}\nfiles=files\n`, + ); + for (const member of sqlMembers.filter((name) => name !== sqlName)) { + for (const name of [`${member}.control`, `${member}--1.0.sql`]) + await fs.writeFile( + path.join(stage, 'files/share/postgresql/extension', name), + '-- companion fixture\n', + ); + } + } await fs.writeFile(manifestFile, manifest); for (const row of [...mobileStaticArchives, ...mobileStaticDependencyArchives]) { const relative = row.slice(row.lastIndexOf(':') + 1); @@ -337,6 +353,9 @@ async function extension(sqlName, stem, dependencies = [], nativeDependencies = tag: `${releaseProduct}-v${version}`, sqlName, createsExtension: true, + ...(sqlName === 'postgis' + ? { sqlMembers: canonicalCatalog.get(sqlName).sqlMembers, nativeModuleStems: [stem] } + : {}), dataFiles: content.dataFiles, dependencies, extensionSqlFileNames: content.extensionSqlFileNames, @@ -609,6 +628,19 @@ async function main() { '0.1.0', ]; await render([...common, '--output-dir', output]); + const renderedFiles = await fs.readdir(output, { recursive: true }); + for (const member of canonicalCatalog.get('postgis').sqlMembers) { + assert.ok( + renderedFiles.some((file) => file.endsWith(`/extension/${member}.control`)), + `clean Swift package must contain ${member}.control`, + ); + assert.ok( + renderedFiles.some( + (file) => file.includes(`/extension/${member}--`) && file.endsWith('.sql'), + ), + `clean Swift package must contain ${member} install SQL`, + ); + } assert.deepEqual( (await fs.readdir(output, { recursive: true })).filter((entry) => entry.endsWith('.a')), [], diff --git a/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts b/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts index d7c315165..b62cad552 100644 --- a/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts +++ b/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts @@ -36,6 +36,8 @@ type FixtureExtensionContract = { sqlName: string; createsExtension: boolean; nativeModuleStem: string | null; + sqlMembers?: string[]; + nativeModuleStems?: string[]; dependencies: string[]; dataFiles: string[]; extensionSqlFileNames: string[]; @@ -653,6 +655,10 @@ async function nodeExtensionMaterializationCopiesPackagePayloads(): Promise @@ -1123,6 +1129,160 @@ async function nodeExtensionMaterializationCopiesPackagePayloads(): Promise { + const target = liboliphauntPackageTarget(platform(), arch()); + const { liboliphauntVersion } = await readTypeScriptPackageVersions(); + const extension = GENERATED_EXTENSION_METADATA.find((row) => row.sqlName === 'postgis')!; + const basePackageName = extension.npmPackage; + const targetPackageName = `${basePackageName}-${target.id}`; + const version = '9.9.7'; + await writeFixturePackage(basePackageName, createdPackageRoots, { + name: basePackageName, + version, + oliphaunt: { + product: extension.artifactProduct, + kind: 'exact-extension', + sqlName: 'postgis', + targetPackageNames: { [target.id]: targetPackageName }, + }, + }); + const targetRoot = await writeFixturePackage(targetPackageName, createdPackageRoots, { + name: targetPackageName, + version, + oliphaunt: { + product: extension.artifactProduct, + kind: 'exact-extension-target', + sqlName: 'postgis', + target: target.id, + liboliphauntVersion, + extensionContract: 'extension-contract.json', + runtimeRelativePath: 'runtime', + moduleRelativePath: 'runtime/lib/modules', + }, + }); + const runtime = join(targetRoot, 'runtime'); + const sqlRoot = join(runtime, 'share/postgresql/extension'); + const suffix = nativeModuleSuffixForTarget(target.id); + const contractPath = join(targetRoot, 'extension-contract.json'); + const roots = new Set(); + const writeContract = async (contract: FixtureExtensionContract) => + writeFile( + contractPath, + JSON.stringify( + fixtureExtensionContractManifest(extension.artifactProduct, version, target.id, [contract]), + ), + ); + const legacy = fixtureExtensionContract(extension, { + dependencies: [], + dataFiles: [], + extensionSqlFileNames: [], + extensionSqlFilePrefixes: [], + }); + await mkdir(sqlRoot, { recursive: true }); + await writeFile(join(sqlRoot, 'postgis.control'), "default_version='3.6.2'\n"); + await writeFile(join(sqlRoot, 'postgis--3.6.2.sql'), 'install'); + for (const profile of ['postgresql', 'modules']) { + await mkdir(join(runtime, 'lib', profile), { recursive: true }); + await writeFile(join(runtime, 'lib', profile, `postgis-3${suffix}`), 'legacy core'); + } + const materialize = async () => { + const result = await nodeAssets.materializeExtensionInstall(install, ['postgis']); + roots.add(dirname(result.runtimeDirectory!)); + return result; + }; + try { + await writeContract(legacy); + const oldInstall = await materialize(); + assert.equal( + await readFile(join(oldInstall.moduleDirectory!, `postgis-3${suffix}`), 'utf8'), + 'legacy core', + ); + await assert.rejects( + readFile(join(oldInstall.moduleDirectory!, `fuzzystrmatch${suffix}`)), + /ENOENT/, + ); + const complete = { + ...legacy, + dependencies: [...extension.selectedExtensionDependencies], + sqlMembers: [...extension.sqlMembers], + nativeModuleStems: [...extension.nativeModuleStems], + }; + await writeContract(complete); + for (const member of extension.sqlMembers) { + await writeFile( + join(sqlRoot, `${member}.control`), + member === 'postgis_tiger_geocoder' + ? "default_version='3.6.4'\nrequires='postgis,fuzzystrmatch'\n" + : "default_version='3.6.4'\n", + ); + await writeFile(join(sqlRoot, `${member}--3.6.4.sql`), 'install'); + } + for (const stem of extension.nativeModuleStems) { + for (const profile of ['postgresql', 'modules']) { + await writeFile(join(runtime, 'lib', profile, `${stem}${suffix}`), `complete:${stem}`); + } + } + const sidecar = join(runtime, '.oliphaunt/extensions/postgis.properties'); + await mkdir(dirname(sidecar), { recursive: true }); + await writeFile( + sidecar, + `sqlName=postgis\nsqlMembers=${complete.sqlMembers.join(',')}\nnativeModuleStems=${complete.nativeModuleStems.join(',')}\n`, + ); + for (const [file, expected] of [ + [join(sqlRoot, 'postgis_topology.control'), /postgis_topology\.control and canonical/], + [join(sqlRoot, 'postgis_topology--3.6.4.sql'), /postgis_topology\.control and canonical/], + [ + join(runtime, 'lib/postgresql', `postgis_topology-3${suffix}`), + /missing declared native module/, + ], + [ + join(runtime, 'lib/modules', `postgis_topology-3${suffix}`), + /missing declared embedded native module/, + ], + ] as const) { + const bytes = await readFile(file); + await rm(file); + await assert.rejects(materialize, expected); + await writeFile(file, bytes); + } + await writeContract({ ...complete, dependencies: [] }); + await assert.rejects(materialize, /requires undeclared dependency fuzzystrmatch/); + await writeContract(complete); + const installed = await materialize(); + assert.notEqual(installed.runtimeDirectory, oldInstall.runtimeDirectory); + assert.equal( + await readFile(join(installed.moduleDirectory!, `postgis_topology-3${suffix}`), 'utf8'), + 'complete:postgis_topology-3', + ); + assert.equal( + await readFile(join(installed.moduleDirectory!, `fuzzystrmatch${suffix}`), 'utf8'), + 'embedded-module:fuzzystrmatch', + ); + assert.equal( + await readFile( + join(installed.runtimeDirectory!, 'share/postgresql/extension/postgis_topology--3.6.4.sql'), + 'utf8', + ), + 'install', + ); + await writeFile( + join(runtime, 'lib/modules', `postgis_topology-3${suffix}`), + 'updated topology', + ); + const updated = await materialize(); + assert.notEqual(updated.runtimeDirectory, installed.runtimeDirectory); + assert.equal( + await readFile(join(updated.moduleDirectory!, `postgis_topology-3${suffix}`), 'utf8'), + 'updated topology', + ); + } finally { + for (const root of roots) await rm(root, { recursive: true, force: true }); + } +} + async function writePreparedHstoreRuntime(runtimeDirectory: string, target: string): Promise { const moduleSuffix = nativeModuleSuffixForTarget(target); await mkdir(join(runtimeDirectory, 'share/postgresql/extension'), { diff --git a/src/native/sdks/ts/src/generated/extensions.ts b/src/native/sdks/ts/src/generated/extensions.ts index 6192662a7..24342e949 100644 --- a/src/native/sdks/ts/src/generated/extensions.ts +++ b/src/native/sdks/ts/src/generated/extensions.ts @@ -15,6 +15,8 @@ export type GeneratedExtensionMetadata = { readonly runtimeBound: boolean; readonly createsExtension: boolean; readonly nativeModuleStem: string | null; + readonly nativeModuleStems: readonly string[]; + readonly sqlMembers: readonly string[]; readonly dependencies: readonly string[]; readonly selectedExtensionDependencies: readonly string[]; readonly sharedPreloadLibraries: readonly string[]; @@ -25,7 +27,7 @@ export type GeneratedExtensionMetadata = { readonly sourceKind: string; }; -export const GENERATED_EXTENSION_METADATA_SHA256 = "26ce911f641272aa18249e7c7d044dfa5ea7061d07119bb275544e164bb9249a" as const; +export const GENERATED_EXTENSION_METADATA_SHA256 = "dbe1ab284693ef923cb12e35e1c75bcc7fc1ed42af1fecf9fe9fee0aaf0ef8d0" as const; export const GENERATED_EXTENSION_METADATA = [ { @@ -41,6 +43,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "amcheck", + "nativeModuleStems": [ + "amcheck" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -49,6 +54,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "amcheck" + ], "sqlName": "amcheck" }, { @@ -64,6 +72,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "auto_explain", + "nativeModuleStems": [ + "auto_explain" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -72,6 +83,7 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [], "sqlName": "auto_explain" }, { @@ -87,6 +99,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "bloom", + "nativeModuleStems": [ + "bloom" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -95,6 +110,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "bloom" + ], "sqlName": "bloom" }, { @@ -110,6 +128,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "btree_gin", + "nativeModuleStems": [ + "btree_gin" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -118,6 +139,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "btree_gin" + ], "sqlName": "btree_gin" }, { @@ -133,6 +157,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "btree_gist", + "nativeModuleStems": [ + "btree_gist" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -141,6 +168,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "btree_gist" + ], "sqlName": "btree_gist" }, { @@ -156,6 +186,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "citext", + "nativeModuleStems": [ + "citext" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -164,6 +197,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "citext" + ], "sqlName": "citext" }, { @@ -179,6 +215,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "cube", + "nativeModuleStems": [ + "cube" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -187,6 +226,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "cube" + ], "sqlName": "cube" }, { @@ -202,6 +244,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "dict_int", + "nativeModuleStems": [ + "dict_int" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -210,6 +255,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "dict_int" + ], "sqlName": "dict_int" }, { @@ -227,6 +275,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "dict_xsyn", + "nativeModuleStems": [ + "dict_xsyn" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -237,6 +288,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "dict_xsyn" + ], "sqlName": "dict_xsyn" }, { @@ -254,6 +308,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "earthdistance", + "nativeModuleStems": [ + "earthdistance" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -264,6 +321,9 @@ export const GENERATED_EXTENSION_METADATA = [ ], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "earthdistance" + ], "sqlName": "earthdistance" }, { @@ -279,6 +339,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "file_fdw", + "nativeModuleStems": [ + "file_fdw" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -287,6 +350,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "file_fdw" + ], "sqlName": "file_fdw" }, { @@ -302,6 +368,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "fuzzystrmatch", + "nativeModuleStems": [ + "fuzzystrmatch" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -310,6 +379,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "fuzzystrmatch" + ], "sqlName": "fuzzystrmatch" }, { @@ -325,6 +397,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "hstore", + "nativeModuleStems": [ + "hstore" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -333,6 +408,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "hstore" + ], "sqlName": "hstore" }, { @@ -348,6 +426,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "_int", + "nativeModuleStems": [ + "_int" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -356,6 +437,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "intarray" + ], "sqlName": "intarray" }, { @@ -371,6 +455,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "isn", + "nativeModuleStems": [ + "isn" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -379,6 +466,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "isn" + ], "sqlName": "isn" }, { @@ -394,6 +484,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "lo", + "nativeModuleStems": [ + "lo" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -402,6 +495,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "lo" + ], "sqlName": "lo" }, { @@ -417,6 +513,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "ltree", + "nativeModuleStems": [ + "ltree" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -425,6 +524,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "ltree" + ], "sqlName": "ltree" }, { @@ -440,6 +542,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pageinspect", + "nativeModuleStems": [ + "pageinspect" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -448,6 +553,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pageinspect" + ], "sqlName": "pageinspect" }, { @@ -463,6 +571,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_buffercache", + "nativeModuleStems": [ + "pg_buffercache" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -471,6 +582,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_buffercache" + ], "sqlName": "pg_buffercache" }, { @@ -486,6 +600,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_freespacemap", + "nativeModuleStems": [ + "pg_freespacemap" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -494,6 +611,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_freespacemap" + ], "sqlName": "pg_freespacemap" }, { @@ -509,6 +629,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-hashids", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_hashids", + "nativeModuleStems": [ + "pg_hashids" + ], "npmPackage": "@oliphaunt/extension-pg-hashids", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-hashids", @@ -517,6 +640,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_hashids" + ], "sqlName": "pg_hashids" }, { @@ -532,6 +658,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-ivm", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_ivm", + "nativeModuleStems": [ + "pg_ivm" + ], "npmPackage": "@oliphaunt/extension-pg-ivm", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-ivm", @@ -540,6 +669,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_ivm" + ], "sqlName": "pg_ivm" }, { @@ -555,6 +687,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_surgery", + "nativeModuleStems": [ + "pg_surgery" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -563,6 +698,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_surgery" + ], "sqlName": "pg_surgery" }, { @@ -578,6 +716,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-textsearch", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_textsearch", + "nativeModuleStems": [ + "pg_textsearch" + ], "npmPackage": "@oliphaunt/extension-pg-textsearch", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-textsearch", @@ -588,6 +729,9 @@ export const GENERATED_EXTENSION_METADATA = [ "pg_textsearch" ], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_textsearch" + ], "sqlName": "pg_textsearch" }, { @@ -603,6 +747,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_trgm", + "nativeModuleStems": [ + "pg_trgm" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -611,6 +758,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_trgm" + ], "sqlName": "pg_trgm" }, { @@ -626,6 +776,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pg-uuidv7", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_uuidv7", + "nativeModuleStems": [ + "pg_uuidv7" + ], "npmPackage": "@oliphaunt/extension-pg-uuidv7", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pg-uuidv7", @@ -634,6 +787,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pg_uuidv7" + ], "sqlName": "pg_uuidv7" }, { @@ -649,6 +805,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_visibility", + "nativeModuleStems": [ + "pg_visibility" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -657,6 +816,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_visibility" + ], "sqlName": "pg_visibility" }, { @@ -672,6 +834,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pg_walinspect", + "nativeModuleStems": [ + "pg_walinspect" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -680,6 +845,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pg_walinspect" + ], "sqlName": "pg_walinspect" }, { @@ -695,6 +863,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "pgcrypto", + "nativeModuleStems": [ + "pgcrypto" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -703,6 +874,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "pgcrypto" + ], "sqlName": "pgcrypto" }, { @@ -725,6 +899,7 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-pgtap", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": null, + "nativeModuleStems": [], "npmPackage": "@oliphaunt/extension-pgtap", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-pgtap", @@ -733,6 +908,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "pgtap" + ], "sqlName": "pgtap" }, { @@ -776,7 +954,9 @@ export const GENERATED_EXTENSION_METADATA = [ "share/postgresql/gdal/tms_NZTM2000.json", "share/postgresql/proj/proj.db" ], - "dependencies": [], + "dependencies": [ + "fuzzystrmatch" + ], "displayName": "PostGIS", "extensionSqlFileNames": [ "uninstall_postgis.sql" @@ -785,12 +965,19 @@ export const GENERATED_EXTENSION_METADATA = [ "postgis_comments", "postgis_proc_set_search_path", "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology", "rtpostgis" ], "id": "postgis", "mavenArtifact": "oliphaunt-extension-postgis", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "postgis-3", + "nativeModuleStems": [ + "postgis-3", + "postgis_raster-3", + "postgis_topology-3" + ], "npmPackage": "@oliphaunt/extension-postgis", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-postgis", @@ -832,9 +1019,17 @@ export const GENERATED_EXTENSION_METADATA = [ "gdal/tms_NZTM2000.json", "proj/proj.db" ], - "selectedExtensionDependencies": [], + "selectedExtensionDependencies": [ + "fuzzystrmatch" + ], "sharedPreloadLibraries": [], "sourceKind": "postgis", + "sqlMembers": [ + "postgis", + "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology" + ], "sqlName": "postgis" }, { @@ -850,6 +1045,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "seg", + "nativeModuleStems": [ + "seg" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -858,6 +1056,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "seg" + ], "sqlName": "seg" }, { @@ -873,6 +1074,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tablefunc", + "nativeModuleStems": [ + "tablefunc" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -881,6 +1085,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tablefunc" + ], "sqlName": "tablefunc" }, { @@ -896,6 +1103,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tcn", + "nativeModuleStems": [ + "tcn" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -904,6 +1114,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tcn" + ], "sqlName": "tcn" }, { @@ -919,6 +1132,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tsm_system_rows", + "nativeModuleStems": [ + "tsm_system_rows" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -927,6 +1143,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tsm_system_rows" + ], "sqlName": "tsm_system_rows" }, { @@ -942,6 +1161,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "tsm_system_time", + "nativeModuleStems": [ + "tsm_system_time" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -950,6 +1172,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "tsm_system_time" + ], "sqlName": "tsm_system_time" }, { @@ -967,6 +1192,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "unaccent", + "nativeModuleStems": [ + "unaccent" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -977,6 +1205,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "unaccent" + ], "sqlName": "unaccent" }, { @@ -992,6 +1223,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-contrib-pg18", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "uuid-ossp", + "nativeModuleStems": [ + "uuid-ossp" + ], "npmPackage": "@oliphaunt/extension-contrib-pg18", "postgresMajor": 18, "releaseProduct": "liboliphaunt-native", @@ -1000,6 +1234,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "postgres-contrib", + "sqlMembers": [ + "uuid-ossp" + ], "sqlName": "uuid-ossp" }, { @@ -1015,6 +1252,9 @@ export const GENERATED_EXTENSION_METADATA = [ "mavenArtifact": "oliphaunt-extension-vector", "mavenGroup": "dev.oliphaunt.extensions", "nativeModuleStem": "vector", + "nativeModuleStems": [ + "vector" + ], "npmPackage": "@oliphaunt/extension-vector", "postgresMajor": 18, "releaseProduct": "oliphaunt-extension-vector", @@ -1023,6 +1263,9 @@ export const GENERATED_EXTENSION_METADATA = [ "selectedExtensionDependencies": [], "sharedPreloadLibraries": [], "sourceKind": "oliphaunt-other-extension", + "sqlMembers": [ + "vector" + ], "sqlName": "vector" } ] as const satisfies readonly GeneratedExtensionMetadata[]; diff --git a/src/native/sdks/ts/src/native/assets.ts b/src/native/sdks/ts/src/native/assets.ts index c90cef39a..cfe5f4af0 100644 --- a/src/native/sdks/ts/src/native/assets.ts +++ b/src/native/sdks/ts/src/native/assets.ts @@ -32,7 +32,6 @@ import { import { nativeModuleSuffixForTarget, type RuntimeFileHost, - selectedExtensionClosure, validatePreparedRuntimeExtensions, } from './extension-runtime.js'; import { syncDirectory, syncRuntimeDirectoryTree } from './filesystem-durability.js'; @@ -100,6 +99,9 @@ type NpmExtensionMemberContract = { sqlName: string; createsExtension: boolean; nativeModuleStem: string | null; + nativeModuleStems: string[]; + sqlMembers: string[]; + frozenClosure: boolean; dependencies: string[]; dataFiles: string[]; extensionSqlFileNames: string[]; @@ -226,28 +228,42 @@ export async function materializeExtensionInstall( extensions: ReadonlyArray = [], descriptors: readonly NativeExtension[] = [], ): Promise { - const selected = selectedExtensionClosure(extensions); - if (selected.length === 0) { + if (extensions.length === 0) { return install; } + for (const sqlName of extensions) { + if (generatedExtensionBySqlName(sqlName) === undefined) { + throw new Error(`unknown Oliphaunt extension id '${sqlName}'`); + } + } if (install.runtimeDirectory === undefined) { throw new Error( - `native extension packages require a package-managed runtime directory; selected extensions: ${selected.join(', ')}`, + `native extension packages require a package-managed runtime directory; selected extensions: ${extensions.join(', ')}`, ); } const installRuntimeDirectory = install.runtimeDirectory; - const versions = await packageVersions(); const target = liboliphauntPackageTarget(platform(), arch()); - const packages = await Promise.all( - selected.map((sqlName) => - resolveExtensionPackage( - sqlName, - target.id, - versions.liboliphauntVersion, - descriptors.find((value) => value.sqlName === sqlName), + const resolved = new Map(); + let pending = [...new Set(extensions)].sort(compareText); + while (pending.length > 0) { + const batch = await Promise.all( + pending.map((sqlName) => + resolveExtensionPackage( + sqlName, + target.id, + versions.liboliphauntVersion, + descriptors.find((value) => value.sqlName === sqlName), + ), ), - ), + ); + for (const entry of batch) resolved.set(entry.sqlName, entry); + pending = [...new Set(batch.flatMap((entry) => entry.contract.dependencies))] + .filter((name) => !resolved.has(name)) + .sort(compareText); + } + const packages = [...resolved.values()].sort((left, right) => + compareText(left.sqlName, right.sqlName), ); const cacheKey = runtimeCacheKey({ libraryPath: install.libraryPath, @@ -257,6 +273,7 @@ export async function materializeExtensionInstall( name: entry.name, version: entry.version, contract: entry.contract, + payloadDigest: entry.payloadDigest, runtimeDirectories: entry.runtimeDirectories, moduleDirectories: entry.moduleDirectories, })), @@ -275,6 +292,7 @@ export async function materializeExtensionInstall( version: entry.version, sqlName: entry.sqlName, contract: entry.contract, + payloadDigest: entry.payloadDigest, })), }, null, @@ -341,6 +359,7 @@ type ResolvedExtensionPackage = { version: string; sqlName: string; contract: NpmExtensionMemberContract; + payloadDigest: string; runtimeDirectories: string[]; moduleDirectories: string[]; }; @@ -474,6 +493,7 @@ async function resolveExtensionPackage( version: packageJson.version, sqlName, contract: selectedContract, + payloadDigest: await extensionPayloadDigest(runtimeDirectories), runtimeDirectories, moduleDirectories, }; @@ -601,8 +621,11 @@ function parseExtensionMemberContract(value: unknown, label: string): NpmExtensi } const row = value as Record; if ( - JSON.stringify(Object.keys(row).sort(compareText)) !== - JSON.stringify(NPM_EXTENSION_CONTRACT_MEMBER_FIELDS) + JSON.stringify( + Object.keys(row) + .filter((key) => key !== 'sqlMembers' && key !== 'nativeModuleStems') + .sort(compareText), + ) !== JSON.stringify(NPM_EXTENSION_CONTRACT_MEMBER_FIELDS) ) { throw new Error( `${label} fields must be exactly ${NPM_EXTENSION_CONTRACT_MEMBER_FIELDS.join(', ')}`, @@ -621,6 +644,40 @@ function parseExtensionMemberContract(value: unknown, label: string): NpmExtensi ) { throw new Error(`${label}.nativeModuleStem must be null or a portable identifier`); } + if ((row.sqlMembers === undefined) !== (row.nativeModuleStems === undefined)) { + throw new Error(`${label} must freeze sqlMembers and nativeModuleStems together`); + } + const frozenClosure = row.sqlMembers !== undefined; + const sqlMembers = + row.sqlMembers === undefined + ? row.createsExtension + ? [row.sqlName] + : [] + : parseExtensionContractStringList(row.sqlMembers, 'sqlMembers', label); + const nativeModuleStems = + row.nativeModuleStems === undefined + ? row.nativeModuleStem === null + ? [] + : [row.nativeModuleStem as string] + : parseExtensionContractStringList(row.nativeModuleStems, 'nativeModuleStems', label); + for (const [field, values] of [ + ['sqlMembers', sqlMembers], + ['nativeModuleStems', nativeModuleStems], + ] as const) { + if (values.some((value) => !/^[A-Za-z0-9._-]{1,128}$/u.test(value))) { + throw new Error(`${label}.${field} contains an invalid identifier`); + } + } + if (row.createsExtension ? !sqlMembers.includes(row.sqlName) : sqlMembers.length !== 0) { + throw new Error(`${label}.sqlMembers must preserve its primary extension identity`); + } + if ( + row.nativeModuleStem === null + ? nativeModuleStems.length !== 0 + : !nativeModuleStems.includes(row.nativeModuleStem as string) + ) { + throw new Error(`${label}.nativeModuleStems must preserve its primary module identity`); + } const dependencies = parseExtensionContractStringList(row.dependencies, 'dependencies', label); if ( dependencies.includes(row.sqlName) || @@ -663,6 +720,9 @@ function parseExtensionMemberContract(value: unknown, label: string): NpmExtensi sqlName: row.sqlName, createsExtension: row.createsExtension, nativeModuleStem: row.nativeModuleStem as string | null, + nativeModuleStems, + sqlMembers, + frozenClosure, dependencies, dataFiles, extensionSqlFileNames, @@ -742,7 +802,9 @@ async function loadExtensionPackageContract(config: { if ( current === undefined || current.artifactProduct !== config.extension.artifactProduct || - JSON.stringify(current.selectedExtensionDependencies) !== JSON.stringify(member.dependencies) + member.dependencies.some( + (dependency) => !current.selectedExtensionDependencies.includes(dependency), + ) ) { throw new Error( `${config.packageName} extension contract member ${member.sqlName} is incompatible with the SDK dependency contract`, @@ -1072,11 +1134,12 @@ function extensionRuntimeSqlFileOwned( fileName: string, ): boolean { return ( - (extension.createsExtension && fileName === `${extension.sqlName}.control`) || - (extension.createsExtension && fileName === `${extension.sqlName}.sql`) || - (extension.createsExtension && - fileName.startsWith(`${extension.sqlName}--`) && - fileName.endsWith('.sql')) || + extension.sqlMembers.some( + (sqlName) => + fileName === `${sqlName}.control` || + fileName === `${sqlName}.sql` || + (fileName.startsWith(`${sqlName}--`) && fileName.endsWith('.sql')), + ) || extension.extensionSqlFileNames.includes(fileName) || (fileName.endsWith('.sql') && extension.extensionSqlFilePrefixes.some((prefix) => fileName.startsWith(prefix))) @@ -1144,6 +1207,18 @@ async function exactRuntimeLeafPaths(root: string, source: string): Promise { + const digest = createHash('sha256'); + for (const root of [...roots].sort(compareText)) { + const files = await exactRuntimeLeafPaths(root, 'extension cache inputs'); + for (const file of files.sort(compareText)) { + const identity = await fileSha256AndBytes(join(root, file)); + digest.update(JSON.stringify([file, identity.sha256, identity.bytes])); + } + } + return digest.digest('hex'); +} + async function requireExactExtensionRuntimeInventory(config: { contract: NpmExtensionMemberContract; runtimeDirectory: string; @@ -1153,32 +1228,76 @@ async function requireExactExtensionRuntimeInventory(config: { const files = await exactRuntimeLeafPaths(config.runtimeDirectory, config.source); const dataFiles = new Set(config.contract.dataFiles.map((file) => `share/postgresql/${file}`)); const licenseFiles = new Map(config.contract.licenseFiles.map((file) => [file.path, file])); - const moduleFile = - config.contract.nativeModuleStem === null - ? undefined - : `lib/postgresql/${config.contract.nativeModuleStem}${nativeModuleSuffixForTarget(config.target)}`; - const embeddedModuleFile = - config.contract.nativeModuleStem === null - ? undefined - : `lib/modules/${config.contract.nativeModuleStem}${nativeModuleSuffixForTarget(config.target)}`; - let hasControl = false; - let hasSql = false; + const moduleFiles = config.contract.nativeModuleStems.map( + (stem) => `lib/postgresql/${stem}${nativeModuleSuffixForTarget(config.target)}`, + ); + const embeddedModuleFiles = config.contract.nativeModuleStems.map( + (stem) => `lib/modules/${stem}${nativeModuleSuffixForTarget(config.target)}`, + ); + const frozenManifest = `.oliphaunt/extensions/${config.contract.sqlName}.properties`; + const controls = new Set(); + const installs = new Set(); for (const file of files) { + if (file === frozenManifest) { + const contents = await readFile(join(config.runtimeDirectory, file), 'utf8'); + const fields = new Map(); + for (const line of contents.split(/\r?\n/u)) { + if (line === '' || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator <= 0 || fields.has(line.slice(0, separator))) { + throw new Error(`${config.source} contains invalid frozen extension metadata`); + } + fields.set(line.slice(0, separator), line.slice(separator + 1)); + } + if ( + fields.get('sqlName') !== config.contract.sqlName || + fields.has('sqlMembers') !== fields.has('nativeModuleStems') || + (config.contract.frozenClosure && !fields.has('sqlMembers')) || + (fields.has('sqlMembers') && + (fields.get('sqlMembers') !== config.contract.sqlMembers.join(',') || + fields.get('nativeModuleStems') !== config.contract.nativeModuleStems.join(','))) + ) { + throw new Error(`${config.source} frozen extension metadata differs from its contract`); + } + continue; + } const extensionPrefix = 'share/postgresql/extension/'; if (file.startsWith(extensionPrefix)) { const fileName = file.slice(extensionPrefix.length); if (fileName.includes('/') || !extensionRuntimeSqlFileOwned(config.contract, fileName)) { throw new Error(`${config.source} contains undeclared extension SQL/control file ${file}`); } - if (fileName === `${config.contract.sqlName}.control`) hasControl = true; - if (isCanonicalExtensionInstallSql(fileName, config.contract.sqlName)) hasSql = true; + for (const sqlName of config.contract.sqlMembers) { + if (fileName === `${sqlName}.control`) { + controls.add(sqlName); + const control = await readFile(join(config.runtimeDirectory, file), 'utf8'); + const requires = + /^\s*requires\s*=\s*'([^']*)'/mu + .exec(control)?.[1] + ?.split(',') + .map((value) => value.trim()) + .filter(Boolean) ?? []; + for (const dependency of requires) { + if ( + dependency !== 'plpgsql' && + !config.contract.sqlMembers.includes(dependency) && + !config.contract.dependencies.includes(dependency) + ) { + throw new Error( + `${config.source} ${sqlName}.control requires undeclared dependency ${dependency}`, + ); + } + } + } + if (isCanonicalExtensionInstallSql(fileName, sqlName)) installs.add(sqlName); + } continue; } if ( dataFiles.has(file) || licenseFiles.has(file) || - file === moduleFile || - file === embeddedModuleFile + moduleFiles.includes(file) || + embeddedModuleFiles.includes(file) ) { continue; } @@ -1223,18 +1342,26 @@ async function requireExactExtensionRuntimeInventory(config: { ); } } - if (moduleFile !== undefined && !files.includes(moduleFile)) { - throw new Error(`${config.source} is missing declared native module ${moduleFile}`); + if (config.contract.frozenClosure && !files.includes(frozenManifest)) { + throw new Error(`${config.source} is missing frozen extension metadata ${frozenManifest}`); } - if (embeddedModuleFile !== undefined && !files.includes(embeddedModuleFile)) { - throw new Error( - `${config.source} is missing declared embedded native module ${embeddedModuleFile}`, - ); + for (const moduleFile of moduleFiles) { + if (!files.includes(moduleFile)) + throw new Error(`${config.source} is missing declared native module ${moduleFile}`); } - if (config.contract.createsExtension && (!hasControl || !hasSql)) { - throw new Error( - `${config.source} must contain ${config.contract.sqlName}.control and canonical base installation SQL`, - ); + for (const embeddedModuleFile of embeddedModuleFiles) { + if (!files.includes(embeddedModuleFile)) { + throw new Error( + `${config.source} is missing declared embedded native module ${embeddedModuleFile}`, + ); + } + } + for (const sqlName of config.contract.sqlMembers) { + if (!controls.has(sqlName) || !installs.has(sqlName)) { + throw new Error( + `${config.source} must contain ${sqlName}.control and canonical base installation SQL`, + ); + } } } diff --git a/src/test-fixtures/extensions/postgis.sql b/src/test-fixtures/extensions/postgis.sql index 289ff1463..4d0a92dfa 100644 --- a/src/test-fixtures/extensions/postgis.sql +++ b/src/test-fixtures/extensions/postgis.sql @@ -1,5 +1,19 @@ CREATE EXTENSION IF NOT EXISTS postgis_raster; -- oliphaunt-statement +CREATE EXTENSION IF NOT EXISTS postgis_topology; +-- oliphaunt-statement +CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; +-- oliphaunt-statement +CREATE EXTENSION IF NOT EXISTS postgis_tiger_geocoder; +-- oliphaunt-statement +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM topology.topology WHERE name = 'oliphaunt_postgis_topology') THEN + PERFORM topology.DropTopology('oliphaunt_postgis_topology'); + END IF; + PERFORM topology.CreateTopology('oliphaunt_postgis_topology', 4326); + PERFORM topology.ST_AddIsoNode('oliphaunt_postgis_topology', NULL, ST_SetSRID(ST_MakePoint(1, 1), 4326)); +END $$; +-- oliphaunt-statement DROP TABLE IF EXISTS oliphaunt_postgis_points; -- oliphaunt-statement CREATE TABLE oliphaunt_postgis_points(id int PRIMARY KEY, geom geometry(Point, 4326)); @@ -21,7 +35,21 @@ DECLARE gdal_drivers int; raster_roundtrip raster; raster_value float8; + topology_nodes int; + normalized tiger.norm_addy; BEGIN + SELECT count(*) INTO topology_nodes FROM oliphaunt_postgis_topology.node + WHERE ST_Equals(geom, ST_SetSRID(ST_MakePoint(1, 1), 4326)); + IF topology_nodes <> 1 OR EXISTS (SELECT 1 FROM topology.ValidateTopology('oliphaunt_postgis_topology')) THEN + RAISE EXCEPTION 'postgis topology persistence or validation failed: % nodes', topology_nodes; + END IF; + normalized := tiger.normalize_address('1 Devonshire Place, Boston, MA 02109'); + IF normalized.address IS DISTINCT FROM 1 OR normalized.streetname IS DISTINCT FROM 'Devonshire' + OR normalized.streettypeabbrev IS DISTINCT FROM 'Pl' OR normalized.location IS DISTINCT FROM 'Boston' + OR normalized.stateabbrev IS DISTINCT FROM 'MA' OR normalized.zip IS DISTINCT FROM '02109' + OR normalized.parsed IS DISTINCT FROM true THEN + RAISE EXCEPTION 'postgis tiger address normalization failed: %', normalized; + END IF; SET LOCAL postgis.gdal_enabled_drivers = 'GTiff'; SELECT ST_Distance(ST_GeomFromText('POINT(0 0)'), ST_GeomFromText('POINT(3 4)')) INTO distance; IF distance <> 5 THEN diff --git a/src/wasix/sdks/rust/src/oliphaunt/generated_extensions.rs b/src/wasix/sdks/rust/src/oliphaunt/generated_extensions.rs index dd1e2bc2e..daea34161 100644 --- a/src/wasix/sdks/rust/src/oliphaunt/generated_extensions.rs +++ b/src/wasix/sdks/rust/src/oliphaunt/generated_extensions.rs @@ -313,10 +313,14 @@ const DEFINITION_POSTGIS: Extension = Extension { runtime_path: "lib/postgresql/postgis_raster-3.so", aot_name: Some("extension:postgis:postgis_raster"), }, + super::ExtensionNativeModule { + runtime_path: "lib/postgresql/postgis_topology-3.so", + aot_name: Some("extension:postgis:postgis_topology"), + }, ], native_module_file: Some("postgis-3.so"), aot_name: Some("extension:postgis"), - dependencies: &[], + dependencies: &["fuzzystrmatch"], startup_config: &[], package: None, }; From 01eed63c20f4e8fddbd7ab877b682d7241b33c1e Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 19 Oct 2026 06:44:34 +0000 Subject: [PATCH 2/3] fix(extensions): retain historical PostGIS data requirements --- .../root/runtime/extension_files.rs | 44 +++++++++++++++---- .../src/liboliphaunt/root/runtime/install.rs | 31 +++++++++++-- src/native/sdks/rust/src/server.rs | 21 +++++++++ .../ts/src/__tests__/asset-resolver.test.ts | 26 ++++++++++- 4 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs index 0557269e8..e4e3daf29 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs @@ -43,10 +43,14 @@ pub(super) fn extension_files( } else { extension.native_module_file().into_iter().collect() }, - data_files: extension_data_files(extension) - .iter() - .map(|value| (*value).to_owned()) - .collect(), + data_files: if current { + extension_data_files(extension) + } else { + legacy_data_files(extension) + } + .iter() + .map(|value| (*value).to_owned()) + .collect(), }) } Err(error) => Err(Error::Engine(format!( @@ -56,6 +60,26 @@ pub(super) fn extension_files( } } +// Independent packages predating frozen manifests shipped core PostGIS and +// PROJ only. Keep their data contract independent of newly added raster assets. +fn legacy_data_files(extension: Extension) -> &'static [&'static str] { + if extension.sql_name() == "postgis" { + &[ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db", + ] + } else { + extension_data_files(extension) + } +} + fn parse(text: &str, extension: Extension) -> Result { let invalid = || { Error::Engine(format!( @@ -126,10 +150,14 @@ fn parse(text: &str, extension: Extension) -> Result { } let data_files = csv( "dataFiles", - extension_data_files(extension) - .iter() - .map(|value| (*value).to_owned()) - .collect(), + if fields.contains_key("sqlMembers") { + extension_data_files(extension) + } else { + legacy_data_files(extension) + } + .iter() + .map(|value| (*value).to_owned()) + .collect(), )?; if data_files.iter().any(|file| { file.contains(['\\', ':']) diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs index ef68e76f8..4169271da 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs @@ -695,15 +695,30 @@ mod tests { .join(Extension::POSTGIS.native_module_file().unwrap()), b"legacy primary module", ); - for relative in crate::extension::extension_data_files(Extension::POSTGIS) { + // Frozen from main 5daf457e, before raster/GDAL joined the product. + let legacy_data = [ + "contrib/postgis-3.6/legacy.sql", + "contrib/postgis-3.6/legacy_gist.sql", + "contrib/postgis-3.6/legacy_minimal.sql", + "contrib/postgis-3.6/postgis.sql", + "contrib/postgis-3.6/postgis_upgrade.sql", + "contrib/postgis-3.6/spatial_ref_sys.sql", + "contrib/postgis-3.6/uninstall_legacy.sql", + "contrib/postgis-3.6/uninstall_postgis.sql", + "proj/proj.db", + ]; + for relative in legacy_data { write_file( &artifact_dir.join("share/postgresql").join(relative), b"legacy data", ); } for preserved_manifest in [false, true] { - let manifest = - preserved_manifest.then_some("sqlName=postgis\ndependencies=\ndataFiles=\n"); + let text = format!( + "sqlName=postgis\ndependencies=\ndataFiles={}\n", + legacy_data.join(",") + ); + let manifest = preserved_manifest.then_some(text.as_str()); let selected = Extension::POSTGIS .with_package(crate::extension::package_tests::postgis_package(manifest)); let extensions = resolve_extensions(&[selected]).unwrap(); @@ -733,6 +748,16 @@ mod tests { ) .unwrap(); assert_eq!(files.sql_members, ["postgis"]); + assert_eq!(files.data_files, legacy_data); + assert!(!runtime_dir.join("share/postgresql/gdal").exists()); + for relative in legacy_data { + assert!( + runtime_dir + .join("share/postgresql") + .join(relative) + .is_file() + ); + } assert_eq!( files.module_files, [Extension::POSTGIS.native_module_file().unwrap()] diff --git a/src/native/sdks/rust/src/server.rs b/src/native/sdks/rust/src/server.rs index f3c962357..71d7ab088 100644 --- a/src/native/sdks/rust/src/server.rs +++ b/src/native/sdks/rust/src/server.rs @@ -848,6 +848,27 @@ mod tests { .map(PathBuf::from), Some(proj_data) ); + assert_eq!( + present + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("GDAL_DATA")), + None, + "legacy core-only PostGIS needs PROJ data without requiring GDAL" + ); + + let gdal_data = runtime_dir.join("share/postgresql/gdal"); + std::fs::create_dir_all(&gdal_data).expect("create GDAL data dir"); + std::fs::write(gdal_data.join("gdalvrt.xsd"), b"fixture").expect("write gdalvrt.xsd"); + let mut raster = Command::new("postgres"); + configure_extension_runtime_env(&mut raster, &runtime_dir, &[Extension::POSTGIS]); + assert_eq!( + raster + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("GDAL_DATA")) + .and_then(|(_, value)| value) + .map(PathBuf::from), + Some(gdal_data) + ); let mut unselected = Command::new("postgres"); configure_extension_runtime_env(&mut unselected, &runtime_dir, &[]); diff --git a/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts b/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts index b62cad552..0e21c85d3 100644 --- a/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts +++ b/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts @@ -1177,13 +1177,29 @@ async function nodePostgisCompanionClosureInstall( ); const legacy = fixtureExtensionContract(extension, { dependencies: [], - dataFiles: [], + // Main 5daf457e's core-only PostGIS contract, before raster/GDAL. + dataFiles: [ + 'contrib/postgis-3.6/legacy.sql', + 'contrib/postgis-3.6/legacy_gist.sql', + 'contrib/postgis-3.6/legacy_minimal.sql', + 'contrib/postgis-3.6/postgis.sql', + 'contrib/postgis-3.6/postgis_upgrade.sql', + 'contrib/postgis-3.6/spatial_ref_sys.sql', + 'contrib/postgis-3.6/uninstall_legacy.sql', + 'contrib/postgis-3.6/uninstall_postgis.sql', + 'proj/proj.db', + ], extensionSqlFileNames: [], extensionSqlFilePrefixes: [], }); await mkdir(sqlRoot, { recursive: true }); await writeFile(join(sqlRoot, 'postgis.control'), "default_version='3.6.2'\n"); await writeFile(join(sqlRoot, 'postgis--3.6.2.sql'), 'install'); + for (const file of legacy.dataFiles) { + const destination = join(runtime, 'share/postgresql', file); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, `legacy data:${file}`); + } for (const profile of ['postgresql', 'modules']) { await mkdir(join(runtime, 'lib', profile), { recursive: true }); await writeFile(join(runtime, 'lib', profile, `postgis-3${suffix}`), 'legacy core'); @@ -1204,6 +1220,14 @@ async function nodePostgisCompanionClosureInstall( readFile(join(oldInstall.moduleDirectory!, `fuzzystrmatch${suffix}`)), /ENOENT/, ); + assert.equal( + await readFile(join(oldInstall.runtimeDirectory!, 'share/postgresql/proj/proj.db'), 'utf8'), + 'legacy data:proj/proj.db', + ); + await assert.rejects( + readFile(join(oldInstall.runtimeDirectory!, 'share/postgresql/gdal/gdalvrt.xsd')), + /ENOENT/, + ); const complete = { ...legacy, dependencies: [...extension.selectedExtensionDependencies], From feca47fcc7b43546dcf0239ef1c6bf9ca66c3878 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Mon, 19 Oct 2026 07:07:14 +0000 Subject: [PATCH 3/3] refactor(extensions): remove legacy PostGIS compatibility paths --- .../native/tools/create-artifact.test.mts | 4 + .../extension-artifact-packager.test.mts | 14 +- .../tools/build-extension-ci-artifacts.mts | 16 +- .../packages/tools/check-carriers.mts | 27 +- .../tools/extension-artifact-inventory.mts | 70 ++--- .../extension-artifact-inventory.test.mts | 78 ++++-- .../extension-wasix-npm-packages.test.mts | 4 +- .../package-extension-release-carriers.mts | 29 +-- src/native/rust-bindings/src/extension.rs | 173 ++---------- .../src/liboliphaunt/root/runtime.rs | 1 - .../liboliphaunt/root/runtime/cache_key.rs | 54 +--- .../root/runtime/extension_files.rs | 246 ------------------ .../src/liboliphaunt/root/runtime/install.rs | 124 +++------ .../android/OliphauntExtensionCatalog.java | 5 +- .../ResolveOliphauntAndroidAssetsTask.java | 27 +- ...OliphauntExtensionCatalogContractTest.java | 44 +++- .../tools/mobile-extension-artifact-paths.mts | 21 +- .../mobile-extension-artifact-paths.test.mts | 2 + .../sdks/react-native/tools/stage-ios-app.mts | 50 ++-- .../react-native/tools/stage-ios-app.test.mts | 57 +++- .../tools/stage-mobile-qualification.test.mts | 2 + .../tools/validate-mobile-runtime-files.mts | 6 +- src/native/sdks/rust/src/server.rs | 22 -- .../cube/manifest.properties | 2 + .../earthdistance/manifest.properties | 2 + .../fuzzystrmatch/LICENSE | 1 + .../THIRD_PARTY_LICENSES/PostgreSQL-COPYRIGHT | 1 + .../fuzzystrmatch/THIRD_PARTY_NOTICES.md | 1 + .../files/lib/postgresql/fuzzystrmatch.dylib | 1 + .../extension/fuzzystrmatch--1.5.sql | 1 + .../extension/fuzzystrmatch.control | 2 + .../fuzzystrmatch/manifest.properties | 24 ++ .../pgtap/manifest.properties | 2 + .../extension/postgis_raster--3.6.1.sql | 2 + .../extension/postgis_raster.control | 1 + .../postgis_tiger_geocoder--3.6.1.sql | 2 + .../extension/postgis_tiger_geocoder.control | 1 + .../extension/postgis_topology--3.6.1.sql | 2 + .../extension/postgis_topology.control | 1 + .../postgis/manifest.properties | 4 +- .../Fixtures/swiftpm-extension-selection.json | 84 +++++- .../tools/extension-resource-inventory.mts | 42 +-- .../extension-resource-inventory.test.mts | 65 +++-- .../sdks/swift/tools/ios-carrier-manifest.mts | 15 +- .../swift/tools/ios-carrier-manifest.test.mts | 4 + .../swift/tools/render-extension-products.mts | 11 +- .../swift/tools/swift-carrier-resolver.mts | 21 +- .../tools/swift-carrier-resolver.test.mts | 28 +- .../ts/src/__tests__/asset-resolver.test.ts | 98 +++---- src/native/sdks/ts/src/native/assets.ts | 109 +++----- tools/release/publication-lock.mts | 12 +- tools/release/publication-lock.test.mts | 6 +- 52 files changed, 603 insertions(+), 1018 deletions(-) delete mode 100644 src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/LICENSE create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_LICENSES/PostgreSQL-COPYRIGHT create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_NOTICES.md create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/lib/postgresql/fuzzystrmatch.dylib create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch--1.5.sql create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch.control create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/manifest.properties create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster--3.6.1.sql create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster.control create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder--3.6.1.sql create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder.control create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology--3.6.1.sql create mode 100644 src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology.control diff --git a/src/extensions/artifacts/native/tools/create-artifact.test.mts b/src/extensions/artifacts/native/tools/create-artifact.test.mts index 7fc1ace03..526084ebc 100644 --- a/src/extensions/artifacts/native/tools/create-artifact.test.mts +++ b/src/extensions/artifacts/native/tools/create-artifact.test.mts @@ -9,6 +9,8 @@ const metadata = { sqlName: 'pgtap', createsExtension: true, nativeModuleStem: null, + sqlMembers: ['pgtap'], + nativeModuleStems: [], dependencies: [], dataFiles: [], extensionSqlFileNames: ['uninstall_pgtap.sql'], @@ -46,6 +48,8 @@ if (phase === 'prepare-binaries') { sqlName: 'auto_explain', createsExtension: false, nativeModuleStem: 'auto_explain', + sqlMembers: [], + nativeModuleStems: ['auto_explain'], extensionSqlFileNames: [], extensionSqlFilePrefixes: [], } diff --git a/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts b/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts index f1d1dd529..da0925a24 100644 --- a/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts +++ b/src/extensions/artifacts/native/tools/extension-artifact-packager.test.mts @@ -4,7 +4,10 @@ import { createHash } from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { validateExtensionArtifactEntries } from '../../packages/tools/extension-artifact-inventory.mts'; +import { + assertExtensionArtifactClosure, + validateExtensionArtifactEntries, +} from '../../packages/tools/extension-artifact-inventory.mts'; import { writeArtifactDirectory } from './extension-artifact-packager.mts'; const repo = path.resolve(import.meta.dir, '../../../../..'); @@ -111,6 +114,15 @@ for (const [target, suffix] of [ assert(manifest.includes(`sqlMembers=${[...controls].sort().join(',')}\n`)); if (mobile) { assert(manifest.includes('nativeModuleStems=postgis-3\n')); + assertExtensionArtifactClosure( + { ...metadata, nativeModuleStems: ['postgis-3'] }, + target, + target, + ); + assert.throws( + () => assertExtensionArtifactClosure(metadata, target, target), + /source-owned closure/, + ); for (const name of controls) assert(entries.has(`files/share/postgresql/extension/${name}.control`)); assert(entries.has(`files/lib/postgresql/postgis-3${suffix}`)); diff --git a/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts b/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts index 7b89b777c..a70f7cce8 100644 --- a/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts +++ b/src/extensions/artifacts/packages/tools/build-extension-ci-artifacts.mts @@ -881,12 +881,8 @@ export function extensionReleasePropertiesText({ `extensionSqlFileNames=${propertiesCsv(manifest.extensionSqlFileNames)}\n`, `extensionSqlFilePrefixes=${propertiesCsv(manifest.extensionSqlFilePrefixes)}\n`, `nativeModuleStem=${manifest.nativeModuleStem || ''}\n`, - ...(manifest.sqlMembers === undefined - ? [] - : [ - `sqlMembers=${propertiesCsv(manifest.sqlMembers)}\n`, - `nativeModuleStems=${propertiesCsv(manifest.nativeModuleStems)}\n`, - ]), + `sqlMembers=${propertiesCsv(manifest.sqlMembers)}\n`, + `nativeModuleStems=${propertiesCsv(manifest.nativeModuleStems)}\n`, `iosNativeDependencies=${propertiesCsv(manifest.iosNativeDependencies)}\n`, `sharedPreloadLibraries=${propertiesCsv(manifest.sharedPreloadLibraries)}\n`, ); @@ -913,12 +909,8 @@ export function extensionReleasePropertiesText({ `${prefix}.extensionSqlFileNames=${propertiesCsv(member.extensionSqlFileNames)}\n`, `${prefix}.extensionSqlFilePrefixes=${propertiesCsv(member.extensionSqlFilePrefixes)}\n`, `${prefix}.nativeModuleStem=${member.nativeModuleStem || ''}\n`, - ...(member.sqlMembers === undefined - ? [] - : [ - `${prefix}.sqlMembers=${propertiesCsv(member.sqlMembers)}\n`, - `${prefix}.nativeModuleStems=${propertiesCsv(member.nativeModuleStems)}\n`, - ]), + `${prefix}.sqlMembers=${propertiesCsv(member.sqlMembers)}\n`, + `${prefix}.nativeModuleStems=${propertiesCsv(member.nativeModuleStems)}\n`, `${prefix}.iosNativeDependencies=${propertiesCsv(member.iosNativeDependencies)}\n`, `${prefix}.sharedPreloadLibraries=${propertiesCsv(member.sharedPreloadLibraries)}\n`, ); diff --git a/src/extensions/artifacts/packages/tools/check-carriers.mts b/src/extensions/artifacts/packages/tools/check-carriers.mts index 0b1debddc..7b3a3faf1 100644 --- a/src/extensions/artifacts/packages/tools/check-carriers.mts +++ b/src/extensions/artifacts/packages/tools/check-carriers.mts @@ -41,6 +41,7 @@ import { assertExtensionUpstreamLicensesInArchive, extensionCarrierLegalContract, } from '../../../tools/extension-upstream-licenses.mts'; +import { assertExtensionArtifactClosure } from './extension-artifact-inventory.mts'; import { extensionRuntimeAssetContract } from './extension-runtime-asset-contract.mts'; export const EXTENSION_ROOT = path.resolve( @@ -67,6 +68,8 @@ const PUBLIC_EXTENSION_RELEASE_MANIFEST_KEYS = new Set([ 'extensionSqlFileNames', 'extensionSqlFilePrefixes', 'nativeModuleStem', + 'sqlMembers', + 'nativeModuleStems', 'iosNativeDependencies', 'iosRegistration', 'wasixInstall', @@ -94,6 +97,8 @@ const EXTENSION_BUNDLE_MEMBER_KEYS = new Set([ 'extensionSqlFileNames', 'extensionSqlFilePrefixes', 'nativeModuleStem', + 'sqlMembers', + 'nativeModuleStems', 'iosNativeDependencies', 'iosRegistration', 'wasixInstall', @@ -242,9 +247,6 @@ function requireExactKeys(value, expected, context) { if (value === null || Array.isArray(value) || typeof value !== 'object') { fail(`${context} must be an object`); } - if (expected.has('nativeModuleStem') && expected.has('sqlName')) { - expected = new Set([...expected, ...Object.keys(optionalFrozenClosure(value, context))]); - } const actual = new Set(Object.keys(value)); if (!setEquals(actual, expected)) { fail( @@ -253,21 +255,13 @@ function requireExactKeys(value, expected, context) { } } -function optionalFrozenClosure(member, context) { - if (member.sqlMembers === undefined && member.nativeModuleStems === undefined) return {}; +function extensionClosure(member, context) { for (const key of ['sqlMembers', 'nativeModuleStems']) { requireSortedUniqueStrings(member[key], `${context}.${key}`); if (member[key].some((name) => !/^[A-Za-z0-9._-]{1,128}$/u.test(name))) fail(`${context}.${key} must contain portable identifiers`); } - if (member.createsExtension && !member.sqlMembers.includes(member.sqlName)) - fail(`${context}.sqlMembers must include its primary SQL extension`); - if ( - member.nativeModuleStem === null - ? member.nativeModuleStems.length > 0 - : !member.nativeModuleStems.includes(member.nativeModuleStem) - ) - fail(`${context}.nativeModuleStems must include its primary native module`); + assertExtensionArtifactClosure(member, 'linux-x64-gnu', context); return { sqlMembers: member.sqlMembers, nativeModuleStems: member.nativeModuleStems }; } @@ -382,6 +376,7 @@ function validateBundleMemberMetadata(member, manifest, stagedTargets) { ); } validateMemberWasixInstall(member, `${rel(manifest)} member ${member.sqlName}`); + extensionClosure(member, `${rel(manifest)} member ${member.sqlName}`); const stagesIos = stagedTargets.has('ios-xcframework'); if (member.nativeModuleStem === null) { if (member.iosNativeDependencies.length > 0 || member.iosRegistration !== null) { @@ -875,7 +870,7 @@ async function checkExtensionBundleProduct( expectedProperties[`${prefix}.extensionSqlFilePrefixes`] = member.extensionSqlFilePrefixes.join(','); expectedProperties[`${prefix}.nativeModuleStem`] = member.nativeModuleStem ?? ''; - for (const [key, values] of Object.entries(optionalFrozenClosure(member, prefix))) + for (const [key, values] of Object.entries(extensionClosure(member, prefix))) expectedProperties[`${prefix}.${key}`] = values.join(','); expectedProperties[`${prefix}.iosNativeDependencies`] = member.iosNativeDependencies.join(','); expectedProperties[`${prefix}.sharedPreloadLibraries`] = @@ -1138,7 +1133,7 @@ async function checkExtensionProductVariant( extensionSqlFileNames: data.extensionSqlFileNames, extensionSqlFilePrefixes: data.extensionSqlFilePrefixes, nativeModuleStem: data.nativeModuleStem, - ...optionalFrozenClosure(data, rel(manifest)), + ...extensionClosure(data, rel(manifest)), iosNativeDependencies: data.iosNativeDependencies, iosRegistration: data.iosRegistration, wasixInstall: data.wasixInstall, @@ -1211,7 +1206,7 @@ async function checkExtensionProductVariant( extensionSqlFilePrefixes: data.extensionSqlFilePrefixes.join(','), nativeModuleStem: data.nativeModuleStem ?? '', ...Object.fromEntries( - Object.entries(optionalFrozenClosure(data, rel(manifest))).map(([key, values]) => [ + Object.entries(extensionClosure(data, rel(manifest))).map(([key, values]) => [ key, values.join(','), ]), diff --git a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts index 11494e127..3854da366 100644 --- a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts +++ b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.mts @@ -42,6 +42,8 @@ export const EXTENSION_ARTIFACT_PROPERTY_KEYS = Object.freeze([ 'staticSymbolAliases', 'licenseFiles', 'licenseProfile', + 'sqlMembers', + 'nativeModuleStems', 'files', ]); @@ -61,7 +63,6 @@ const DESKTOP_NATIVE_TARGETS = new Set([ 'macos-arm64', 'windows-x64-msvc', ]); -const FROZEN_CLOSURE_KEYS = ['sqlMembers', 'nativeModuleStems']; function inventoryError(label, message) { return new Error(`${label}: ${message}`); @@ -146,10 +147,7 @@ export function parseExtensionArtifactProperties(text, label) { } properties.set(key, rawLine.slice(separator + 1)); } - const hasClosure = FROZEN_CLOSURE_KEYS.some((key) => properties.has(key)); - const orderedKeys = hasClosure - ? [...EXTENSION_ARTIFACT_PROPERTY_KEYS.slice(0, -1), ...FROZEN_CLOSURE_KEYS, 'files'] - : EXTENSION_ARTIFACT_PROPERTY_KEYS; + const orderedKeys = EXTENSION_ARTIFACT_PROPERTY_KEYS; const actual = [...properties.keys()].sort(compareText); const expected = [...orderedKeys].sort(compareText); if (JSON.stringify(actual) !== JSON.stringify(expected)) { @@ -444,6 +442,8 @@ function normalizeMetadata(row, label) { sqlName, createsExtension, nativeModuleStem, + sqlMembers: list('sqlMembers', 'sql-members'), + nativeModuleStems: list('nativeModuleStems', 'native-module-stems'), dependencies: (() => { const selected = row?.selectedExtensionDependencies ?? @@ -463,6 +463,8 @@ function normalizeMetadata(row, label) { for (const [field, values] of [ ['selected-extension-dependencies', metadata.dependencies], ['shared-preload-libraries', metadata.sharedPreloadLibraries], + ['sql-members', metadata.sqlMembers], + ['native-module-stems', metadata.nativeModuleStems], ]) { if (values.some((item) => !PORTABLE_ID.test(item))) { throw inventoryError(label, `${field} contains a non-portable identifier`); @@ -529,6 +531,35 @@ export function extensionNativeArtifactClosure(sqlName, target) { }; } +export function assertExtensionArtifactClosure(metadata, target, label) { + const closure = extensionNativeArtifactClosure(metadata.sqlName, target); + const mobile = target === 'ios-xcframework' || target.startsWith('android-'); + const expected = { + sqlMembers: [ + ...new Set([ + ...(metadata.createsExtension ? [metadata.sqlName] : []), + ...closure.controls.map((name) => name.slice(0, -'.control'.length)), + ]), + ].sort(compareText), + nativeModuleStems: [ + ...new Set([ + ...(metadata.nativeModuleStem === null ? [] : [metadata.nativeModuleStem]), + ...(mobile ? [] : closure.modules).map((name) => + name.slice(0, -moduleSuffix(target).length), + ), + ]), + ].sort(compareText), + }; + for (const [field, values] of Object.entries(expected)) { + if (JSON.stringify(metadata[field]) !== JSON.stringify(values)) { + throw inventoryError( + label, + `${field} must match the current source-owned closure ${JSON.stringify(values)}`, + ); + } + } +} + function sqlFileOwned(fileName, metadata) { return ( metadata.companionControls?.includes(fileName) || @@ -788,35 +819,14 @@ export function validateExtensionArtifactEntries({ decodeUtf8(manifestEntry.data, `${label} manifest.properties`), `${label} manifest.properties`, ); - // Old independently versioned artifacts only promised their primary member/module. - // A current SDK catalog must not silently add new requirements to those artifacts. - const sqlMembers = properties.has('sqlMembers') - ? csv(properties.get('sqlMembers'), `${label} sqlMembers`) - : metadata.createsExtension - ? [metadata.sqlName] - : []; - const nativeModuleStems = properties.has('nativeModuleStems') - ? csv(properties.get('nativeModuleStems'), `${label} nativeModuleStems`) - : metadata.nativeModuleStem === null - ? [] - : [metadata.nativeModuleStem]; - if ( - [...sqlMembers, ...nativeModuleStems].some((name) => !PORTABLE_ID.test(name)) || - (metadata.createsExtension && !sqlMembers.includes(metadata.sqlName)) || - (!metadata.createsExtension && sqlMembers.length > 0) || - (metadata.nativeModuleStem === null - ? nativeModuleStems.length > 0 - : !nativeModuleStems.includes(metadata.nativeModuleStem)) - ) - throw inventoryError(label, 'invalid frozen SQL member or native module closure'); + assertExtensionArtifactClosure(metadata, target, `${label} canonical metadata`); + const sqlMembers = csv(properties.get('sqlMembers'), `${label} sqlMembers`); + const nativeModuleStems = csv(properties.get('nativeModuleStems'), `${label} nativeModuleStems`); for (const [key, actual] of [ ['sqlMembers', sqlMembers], ['nativeModuleStems', nativeModuleStems], ]) { - if ( - rawMetadata[key] !== undefined && - (!properties.has(key) || JSON.stringify(rawMetadata[key]) !== JSON.stringify(actual)) - ) { + if (JSON.stringify(metadata[key]) !== JSON.stringify(actual)) { throw inventoryError(label, `manifest ${key} must match the frozen release inventory`); } } diff --git a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts index 5cf59dda4..1c7fe20fe 100644 --- a/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts +++ b/src/extensions/artifacts/packages/tools/extension-artifact-inventory.test.mts @@ -138,18 +138,12 @@ function manifest(overrides = {}) { staticSymbolAliases: '', licenseFiles: legal.licenseFiles.join(','), licenseProfile: legal.profile, + sqlMembers: overrides.createsExtension === 'no' ? '' : sqlName, + nativeModuleStems: overrides.nativeModuleStem ?? '', files: 'files', ...overrides, }; - const keys = - overrides.sqlMembers === undefined - ? EXTENSION_ARTIFACT_PROPERTY_KEYS - : [ - ...EXTENSION_ARTIFACT_PROPERTY_KEYS.slice(0, -1), - 'sqlMembers', - 'nativeModuleStems', - 'files', - ]; + const keys = EXTENSION_ARTIFACT_PROPERTY_KEYS; return `${keys.map((key) => `${key}=${values[key]}`).join('\n')}\n`; } @@ -187,6 +181,8 @@ const pgtapMetadata = { sqlName: 'pgtap', createsExtension: true, nativeModuleStem: null, + sqlMembers: ['pgtap'], + nativeModuleStems: [], dependencies: [], dataFiles: [], extensionSqlFileNames: ['uninstall_pgtap.sql'], @@ -550,6 +546,8 @@ async function main() { sqlName: 'auto_explain', createsExtension: false, nativeModuleStem: 'auto_explain', + sqlMembers: [], + nativeModuleStems: ['auto_explain'], extensionSqlFileNames: [], extensionSqlFilePrefixes: [], }; @@ -655,10 +653,7 @@ async function main() { target: 'linux-x64-gnu', nativeRuntimeVersion: '1.2.3', }); - assert.equal( - await fs.readFile(path.join(extracted, '.oliphaunt/extensions/postgis.properties'), 'utf8'), - postgis.get('manifest.properties'), - ); + await assert.rejects(fs.access(path.join(extracted, '.oliphaunt')), { code: 'ENOENT' }); assert.equal( await fs.readFile(path.join(extracted, 'lib/modules/postgis_topology-3.so'), 'utf8'), 'embedded module', @@ -691,20 +686,14 @@ async function main() { .join('\n'), ); const legacyFile = await writeArchive(root, 'postgis-legacy-core-only.tar.gz', legacyPostgis); - validateExtensionArtifactArchive({ - file: legacyFile, - metadata: { ...postgisMetadata, sqlMembers: undefined, nativeModuleStems: undefined }, - target: 'linux-x64-gnu', - nativeRuntimeVersion: '1.2.3', - label: 'legacy PostGIS', - }); - assert.equal( - frozenExtensionMemberInventory({ - ...postgisMetadata, - sqlMembers: undefined, - nativeModuleStems: undefined, - }).sqlMembers, - undefined, + assert.throws( + () => + frozenExtensionMemberInventory({ + ...postgisMetadata, + sqlMembers: undefined, + nativeModuleStems: undefined, + }), + /sqlMembers/u, ); assert.throws( () => @@ -715,7 +704,40 @@ async function main() { nativeRuntimeVersion: '1.2.3', label: 'new frozen PostGIS rejects incomplete legacy payload', }), - /frozen release inventory/u, + /property fields/u, + ); + const incompleteMetadata = { + ...postgisMetadata, + sqlMembers: ['postgis'], + nativeModuleStems: ['postgis-3'], + }; + assert.throws( + () => frozenExtensionMemberInventory(incompleteMetadata), + /current source-owned closure/u, + ); + assert.throws( + () => + frozenExtensionMemberInventory({ ...postgisMetadata, nativeModuleStems: ['postgis-3'] }), + /nativeModuleStems must match the current source-owned closure/u, + ); + const incompletePostgis = new Map(legacyPostgis); + incompletePostgis.set( + 'manifest.properties', + manifest({ + sqlName: 'postgis', + nativeModuleStem: 'postgis-3', + nativeModuleFile: 'postgis-3.so', + dataFiles: postgisMetadata.dataFiles.join(','), + extensionSqlFileNames: '', + extensionSqlFilePrefixes: postgisMetadata.extensionSqlFilePrefixes.join(','), + }), + ); + await expectArchiveFailure( + root, + 'postgis-incomplete-closure.tar.gz', + incompletePostgis, + incompleteMetadata, + /current source-owned closure/u, ); const missingEmbeddedPostgis = new Map(postgis); diff --git a/src/extensions/artifacts/packages/tools/extension-wasix-npm-packages.test.mts b/src/extensions/artifacts/packages/tools/extension-wasix-npm-packages.test.mts index 0da931765..bb366ff32 100644 --- a/src/extensions/artifacts/packages/tools/extension-wasix-npm-packages.test.mts +++ b/src/extensions/artifacts/packages/tools/extension-wasix-npm-packages.test.mts @@ -23,6 +23,7 @@ import { extensionRegistryPackageTargetSets, extensionReleaseVersion, } from '../../../../../tools/release/release-artifact-targets.mts'; +import { canonicalWasixAotMetadata } from '../../../../wasix/runtime/tools/wasix-aot-manifest.mts'; import { extensionNpmPackageForProduct, extensionNpmWasixPackageForProduct, @@ -33,7 +34,6 @@ import { stageExtensionWasixNpmPackages, writeWasixExtensionAotNpmPackage, } from './package-extension-release-carriers.mts'; -import { canonicalWasixAotMetadata } from '../../../../wasix/runtime/tools/wasix-aot-manifest.mts'; const ROOT = path.resolve(import.meta.dir, '../../../../..'); const directories = []; @@ -119,6 +119,8 @@ function inventory(sqlName, dependencies = []) { sqlName, createsExtension: true, nativeModuleStem: null, + sqlMembers: [sqlName], + nativeModuleStems: [], dependencies, dataFiles: [], extensionSqlFileNames: [`${sqlName}.control`], diff --git a/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts b/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts index f0e88ca0a..b935b2a20 100644 --- a/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts +++ b/src/extensions/artifacts/packages/tools/package-extension-release-carriers.mts @@ -4,7 +4,6 @@ import { chmodSync, closeSync, copyFileSync, - cpSync, existsSync, lstatSync, mkdirSync, @@ -68,7 +67,10 @@ import { extensionUpstreamLicenseFileInventory, stageExtensionUpstreamLicenses, } from '../../../tools/extension-upstream-licenses.mts'; -import { validateExtensionArtifactArchive } from './extension-artifact-inventory.mts'; +import { + assertExtensionArtifactClosure, + validateExtensionArtifactArchive, +} from './extension-artifact-inventory.mts'; import { extensionNpmPackageForProduct, extensionNpmTargetPackageForProduct, @@ -465,14 +467,14 @@ const FROZEN_EXTENSION_INVENTORY_LIST_FIELDS = Object.freeze([ 'extensionSqlFileNames', 'extensionSqlFilePrefixes', 'sharedPreloadLibraries', + 'sqlMembers', + 'nativeModuleStems', ]); /** * Return the exact desktop inventory frozen into one product/member release row. * - * Registry materialization deliberately does not consult the repository-wide - * generated SDK catalog: independently versioned external products must remain - * bound to the metadata that was qualified and versioned with that product. + * The release must include the complete current source-owned companion closure. */ export function frozenExtensionMemberInventory(member, { product, version } = {}) { const owner = [product, version].every((value) => typeof value === 'string' && value.length > 0) @@ -498,11 +500,7 @@ export function frozenExtensionMemberInventory(member, { product, version } = {} createsExtension: member.createsExtension, nativeModuleStem: member.nativeModuleStem, }; - const closureFields = - member.sqlMembers === undefined && member.nativeModuleStems === undefined - ? [] - : ['sqlMembers', 'nativeModuleStems']; - for (const field of [...FROZEN_EXTENSION_INVENTORY_LIST_FIELDS, ...closureFields]) { + for (const field of FROZEN_EXTENSION_INVENTORY_LIST_FIELDS) { const values = member[field]; if ( !Array.isArray(values) || @@ -519,6 +517,7 @@ export function frozenExtensionMemberInventory(member, { product, version } = {} if (inventory.dependencies.includes(sqlName)) { throw new Error(`${TOOL}: ${owner}/${sqlName} frozen dependencies must exclude itself`); } + assertExtensionArtifactClosure(inventory, 'linux-x64-gnu', `${owner}/${sqlName}`); return Object.freeze(inventory); } @@ -1055,16 +1054,6 @@ export function extractExtensionRuntime( writeFileSync(destination, entry.data, { flag: 'wx', mode: entry.mode }); chmodSync(destination, entry.mode); } - const manifestPath = path.join( - runtimeDir, - '.oliphaunt/extensions', - `${metadata.sqlName}.properties`, - ); - mkdirSync(path.dirname(manifestPath), { recursive: true }); - writeFileSync(manifestPath, validated.entries.get('manifest.properties').data, { - flag: 'wx', - mode: 0o644, - }); return validated.runtimeFiles; } diff --git a/src/native/rust-bindings/src/extension.rs b/src/native/rust-bindings/src/extension.rs index df54a22d3..9146ff484 100644 --- a/src/native/rust-bindings/src/extension.rs +++ b/src/native/rust-bindings/src/extension.rs @@ -1,7 +1,5 @@ use std::collections::{BTreeMap, BTreeSet}; -use sha2::{Digest, Sha256}; - use crate::error::{Error, Result}; #[path = "generated/extensions.rs"] @@ -170,17 +168,17 @@ fn visit_extension( extension.sql_name() ))); } - for dependency in package_dependencies(extension)? { - let inherited = if generated_extensions::artifact_product(dependency) + for dependency in extension.dependencies() { + let inherited = if generated_extensions::artifact_product(*dependency) == generated_extensions::artifact_product(extension) { { - let mut inherited = dependency; + let mut inherited = *dependency; inherited.package = extension.package; inherited } } else { - dependency + *dependency }; let selected = requested .get(dependency.sql_name()) @@ -200,83 +198,6 @@ fn visit_extension( Ok(()) } -// Independent packages retain the dependency contract of the artifact that -// produced them, even when the current catalog adds a bundled SQL companion. -fn package_dependencies(extension: Extension) -> Result> { - let Some(package) = extension.package else { - return Ok(extension.dependencies().to_vec()); - }; - let path = format!( - "extension/{}/.oliphaunt/extensions/{}.properties", - package.product, - extension.sql_name() - ); - let mut manifests = package - .resources - .iter() - .filter(|resource| resource.0 == path); - let Some((_, bytes, checksum, _)) = manifests.next() else { - // Historical PostGIS artifacts supplied core SQL only, before tiger's - // fuzzystrmatch prerequisite became part of the current product. - return Ok(if extension.sql_name() == "postgis" { - Vec::new() - } else { - extension.dependencies().to_vec() - }); - }; - let invalid = || { - Error::InvalidConfig(format!( - "invalid native extension dependency manifest for {}", - extension.sql_name() - )) - }; - if manifests.next().is_some() || format!("{:x}", Sha256::digest(bytes)) != *checksum { - return Err(invalid()); - } - let text = std::str::from_utf8(bytes).map_err(|_| invalid())?; - let mut fields = BTreeMap::new(); - for line in text.lines().filter(|line| !line.is_empty()) { - let (key, value) = line.split_once('=').ok_or_else(invalid)?; - if fields.insert(key, value).is_some() { - return Err(invalid()); - } - } - if fields.get("sqlName") != Some(&extension.sql_name()) { - return Err(invalid()); - } - let dependencies = fields.get("dependencies").ok_or_else(invalid)?; - if extension.sql_name() == "postgis" - && fields.get("sqlMembers").is_some_and(|members| { - members - .split(',') - .any(|member| member == "postgis_tiger_geocoder") - }) - && !dependencies - .split(',') - .any(|dependency| dependency == "fuzzystrmatch") - { - return Err(invalid()); - } - if dependencies.is_empty() { - return Ok(Vec::new()); - } - let mut seen = BTreeSet::new(); - dependencies - .split(',') - .map(|name| { - if !seen.insert(name) || name == extension.sql_name() { - return Err(invalid()); - } - extension - .dependencies() - .iter() - .copied() - .find(|dependency| dependency.sql_name() == name) - .ok_or_else(invalid) - }) - .collect() -} - pub fn extension_sql_file_belongs(sql_name: &str, file_name: &str) -> bool { file_name == format!("{sql_name}.control") || file_name == format!("{sql_name}.sql") @@ -333,83 +254,9 @@ pub const fn extension_data_files(extension: Extension) -> &'static [&'static st } #[cfg(test)] -pub(crate) mod package_tests { +mod package_tests { use super::*; - pub(crate) fn postgis_package(manifest: Option<&str>) -> &'static ExtensionPackage { - let (path, bytes) = match manifest { - Some(text) => ( - "extension/oliphaunt-extension-postgis/.oliphaunt/extensions/postgis.properties", - text.as_bytes().to_vec(), - ), - None => ( - "extension/oliphaunt-extension-postgis/legacy-fixture", - Vec::new(), - ), - }; - let checksum = Box::leak(format!("{:x}", Sha256::digest(&bytes)).into_boxed_str()); - let resources = Box::leak( - vec![( - path, - Box::leak(bytes.into_boxed_slice()) as &'static [u8], - checksum as &'static str, - false, - )] - .into_boxed_slice(), - ); - Box::leak(Box::new(ExtensionPackage { - product: "oliphaunt-extension-postgis", - version: "3.6.4", - runtime_version: "0.2.0", - resources, - })) - } - - #[test] - fn separately_packaged_postgis_resolves_its_frozen_dependencies() { - for manifest in [None, Some("sqlName=postgis\ndependencies=\n")] { - let selected = Extension::POSTGIS.with_package(postgis_package(manifest)); - assert_eq!(resolve_extensions(&[selected]).unwrap(), [selected]); - } - let selected = Extension::POSTGIS.with_package(postgis_package(Some( - "sqlName=postgis\ndependencies=fuzzystrmatch\nsqlMembers=postgis,postgis_tiger_geocoder\nnativeModuleStems=postgis-3\n", - ))); - assert_eq!( - resolve_extensions(&[selected]).unwrap(), - [Extension::FUZZYSTRMATCH, selected] - ); - assert_eq!( - resolve_extensions(&[Extension::POSTGIS]).unwrap(), - [Extension::FUZZYSTRMATCH, Extension::POSTGIS] - ); - for manifest in [ - "sqlName=postgis\n", - "sqlName=hstore\ndependencies=\n", - "sqlName=postgis\ndependencies=unknown\n", - "sqlName=postgis\ndependencies=hstore\n", - "sqlName=postgis\ndependencies=postgis\n", - "sqlName=postgis\ndependencies=\nsqlMembers=postgis,postgis_tiger_geocoder\nnativeModuleStems=postgis-3\n", - "sqlName=postgis\ndependencies=fuzzystrmatch,fuzzystrmatch\n", - "sqlName=postgis\ndependencies=\ndependencies=fuzzystrmatch\n", - ] { - assert!( - resolve_extensions(&[ - Extension::POSTGIS.with_package(postgis_package(Some(manifest))) - ]) - .is_err(), - "accepted {manifest}" - ); - } - let package = postgis_package(Some("sqlName=postgis\ndependencies=\n")); - let mut resource = package.resources[0]; - resource.2 = "invalid-checksum"; - let corrupted = Box::leak(Box::new(ExtensionPackage { - resources: Box::leak(vec![resource].into_boxed_slice()), - ..*package - })); - assert!(resolve_extensions(&[Extension::POSTGIS.with_package(corrupted)]).is_err()); - } - #[test] fn selected_packages_follow_dependencies_and_reject_identity_conflicts() { static PACKAGE: ExtensionPackage = ExtensionPackage { @@ -427,6 +274,16 @@ pub(crate) mod package_tests { version: "9.8.8", ..PACKAGE }; + static POSTGIS_PACKAGE: ExtensionPackage = ExtensionPackage { + product: "oliphaunt-extension-postgis", + ..PACKAGE + }; + let postgis = Extension::POSTGIS.with_package(&POSTGIS_PACKAGE); + assert_eq!( + resolve_extensions(&[postgis]).unwrap(), + [Extension::FUZZYSTRMATCH, postgis], + "independent packages use the current catalog dependency contract" + ); let selected = Extension::EARTHDISTANCE.with_package(&PACKAGE); let resolved = resolve_extensions(&[selected]).unwrap(); assert_eq!( diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs index 76ea7a7f6..db6b5d129 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime.rs @@ -1,5 +1,4 @@ mod cache_key; -mod extension_files; mod install; mod locate; diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs index 432579162..20127268c 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/cache_key.rs @@ -14,11 +14,8 @@ use super::super::fingerprint::{ }; use super::super::{NATIVE_RUNTIME_TOOLS, existing_native_tool_path, native_tool_path}; use super::extension_artifact_root_for; -use super::extension_files::{cache_extension_files, extension_files, manifest_path}; use crate::error::{Error, Result}; -use crate::extension::Extension; -#[cfg(test)] -use crate::extension::extension_data_files as data_files; +use crate::extension::{Extension, extension_data_files as data_files}; const RUNTIME_CACHE_VERSION: &str = "pg18-runtime-cache-v8"; @@ -93,20 +90,14 @@ pub(super) fn runtime_cache_key_with_icu( let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); let extension_share = extension_root.join("share/postgresql"); - let files = extension_files(install_dir, extension_root, *extension)?; - fingerprint_optional_file( - &mut state, - extension_root, - &manifest_path(extension_root, *extension), - )?; fingerprint_named_extension_sql_files(&mut state, &extension_share, extension.sql_name())?; - for member in &files.sql_members { + for member in extension.sql_members() { hash_str(&mut state, member); - if member != extension.sql_name() { + if *member != extension.sql_name() { fingerprint_named_extension_sql_files(&mut state, &extension_share, member)?; } } - for relative in &files.data_files { + for relative in data_files(*extension) { fingerprint_optional_file( &mut state, &extension_share, @@ -156,8 +147,7 @@ pub(super) fn runtime_cache_key_with_icu( for extension in extensions { let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); - for module in extension_files(install_dir, extension_root, *extension)?.module_files - { + for module in extension.native_module_files() { let extension_lib = extension_root.join("lib/modules"); if extension_root != install_dir || extension_lib.join(&module).is_file() { fingerprint_optional_file( @@ -179,8 +169,7 @@ pub(super) fn runtime_cache_key_with_icu( for extension in extensions { let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); - for module in extension_files(install_dir, extension_root, *extension)?.module_files - { + for module in extension.native_module_files() { let extension_lib = extension_root.join("lib/postgresql"); fingerprint_optional_file( &mut state, @@ -255,10 +244,7 @@ pub(super) fn cached_runtime_is_valid_with_icu( } for extension in extensions { - let Ok(files) = cache_extension_files(cache_dir, *extension) else { - return false; - }; - for sql_name in &files.sql_members { + for sql_name in extension.sql_members() { if !cache_dir .join("share/postgresql/extension") .join(format!("{sql_name}.control")) @@ -268,12 +254,12 @@ pub(super) fn cached_runtime_is_valid_with_icu( return false; } } - for module in &files.module_files { + for module in extension.native_module_files() { if !cache_dir.join("lib/postgresql").join(module).is_file() { return false; } } - for relative in &files.data_files { + for relative in data_files(*extension) { if !cache_dir.join("share/postgresql").join(relative).is_file() { return false; } @@ -693,7 +679,6 @@ mod tests { ); } required.push(cache_dir.join("share/postgresql/proj/proj.db")); - required.push(manifest_path(&cache_dir, Extension::POSTGIS)); for file in required { let bytes = fs::read(&file).unwrap(); fs::remove_file(&file).unwrap(); @@ -831,14 +816,6 @@ mod tests { let temp = TempTree::new("validation"); let cache_dir = temp.path().join("cache"); write_minimal_cache_dir(&cache_dir, "cache-key"); - let files = extension_files(&cache_dir, &cache_dir, Extension::HSTORE).unwrap(); - super::super::extension_files::write_cache_extension_files( - &cache_dir, - Extension::HSTORE, - &files, - ) - .unwrap(); - assert!( cached_runtime_is_valid( NativeRuntimeProfile::PostgresServer, @@ -860,12 +837,6 @@ mod tests { let sql_without_module = temp.path().join("cache-sql-without-module"); write_minimal_cache_dir(&sql_without_module, "cache-key"); - super::super::extension_files::write_cache_extension_files( - &sql_without_module, - Extension::HSTORE, - &files, - ) - .unwrap(); write_file( &sql_without_module.join("share/postgresql/extension/hstore.control"), b"comment = 'hstore'\n", @@ -1050,13 +1021,6 @@ mod tests { b"runtime data", ); } - let files = extension_files(root, root, Extension::POSTGIS).unwrap(); - super::super::extension_files::write_cache_extension_files( - root, - Extension::POSTGIS, - &files, - ) - .unwrap(); } fn write_minimal_cache_dir(cache_dir: &Path, key: &str) { diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs deleted file mode 100644 index e4e3daf29..000000000 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/extension_files.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fs; -use std::path::{Component, Path, PathBuf}; - -use crate::error::{Error, Result}; -use crate::extension::{Extension, extension_data_files}; - -pub(super) struct ExtensionFiles { - pub sql_members: Vec, - pub module_files: Vec, - pub data_files: Vec, -} - -pub(super) fn manifest_path(root: &Path, extension: Extension) -> PathBuf { - root.join(".oliphaunt/extensions") - .join(format!("{}.properties", extension.sql_name())) -} - -pub(super) fn extension_files( - install_dir: &Path, - root: &Path, - extension: Extension, -) -> Result { - let manifest = manifest_path(root, extension); - match fs::read_to_string(&manifest) { - Ok(text) => parse(&text, extension), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let current = root == install_dir; - Ok(ExtensionFiles { - sql_members: if current { - extension - .sql_members() - .iter() - .map(|value| (*value).to_owned()) - .collect() - } else if extension.creates_extension() { - vec![extension.sql_name().to_owned()] - } else { - Vec::new() - }, - module_files: if current { - extension.native_module_files() - } else { - extension.native_module_file().into_iter().collect() - }, - data_files: if current { - extension_data_files(extension) - } else { - legacy_data_files(extension) - } - .iter() - .map(|value| (*value).to_owned()) - .collect(), - }) - } - Err(error) => Err(Error::Engine(format!( - "read extension manifest {}: {error}", - manifest.display() - ))), - } -} - -// Independent packages predating frozen manifests shipped core PostGIS and -// PROJ only. Keep their data contract independent of newly added raster assets. -fn legacy_data_files(extension: Extension) -> &'static [&'static str] { - if extension.sql_name() == "postgis" { - &[ - "contrib/postgis-3.6/legacy.sql", - "contrib/postgis-3.6/legacy_gist.sql", - "contrib/postgis-3.6/legacy_minimal.sql", - "contrib/postgis-3.6/postgis.sql", - "contrib/postgis-3.6/postgis_upgrade.sql", - "contrib/postgis-3.6/spatial_ref_sys.sql", - "contrib/postgis-3.6/uninstall_legacy.sql", - "contrib/postgis-3.6/uninstall_postgis.sql", - "proj/proj.db", - ] - } else { - extension_data_files(extension) - } -} - -fn parse(text: &str, extension: Extension) -> Result { - let invalid = || { - Error::Engine(format!( - "invalid native extension file manifest for {}", - extension.sql_name() - )) - }; - let mut fields = BTreeMap::new(); - for line in text.lines().filter(|line| !line.is_empty()) { - let (key, value) = line.split_once('=').ok_or_else(invalid)?; - if fields.insert(key, value).is_some() { - return Err(invalid()); - } - } - if fields.get("sqlName") != Some(&extension.sql_name()) - || fields.contains_key("sqlMembers") != fields.contains_key("nativeModuleStems") - { - return Err(invalid()); - } - let csv = |key: &str, fallback: Vec| -> Result> { - let Some(value) = fields.get(key) else { - return Ok(fallback); - }; - if value.is_empty() { - return Ok(Vec::new()); - } - let values = value.split(',').map(str::to_owned).collect::>(); - if values.iter().any(String::is_empty) - || values.iter().collect::>().len() != values.len() - { - return Err(invalid()); - } - Ok(values) - }; - let portable = |value: &str| { - !value.is_empty() - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - }; - let sql_members = csv( - "sqlMembers", - if extension.creates_extension() { - vec![extension.sql_name().to_owned()] - } else { - Vec::new() - }, - )?; - let stems = csv( - "nativeModuleStems", - extension - .native_module_stem() - .map(str::to_owned) - .into_iter() - .collect(), - )?; - if sql_members - .iter() - .chain(&stems) - .any(|value| !portable(value)) - || (extension.creates_extension() - && !sql_members.iter().any(|name| name == extension.sql_name())) - || extension - .native_module_stem() - .is_some_and(|primary| !stems.iter().any(|stem| stem == primary)) - { - return Err(invalid()); - } - let data_files = csv( - "dataFiles", - if fields.contains_key("sqlMembers") { - extension_data_files(extension) - } else { - legacy_data_files(extension) - } - .iter() - .map(|value| (*value).to_owned()) - .collect(), - )?; - if data_files.iter().any(|file| { - file.contains(['\\', ':']) - || Path::new(file) - .components() - .any(|component| !matches!(component, Component::Normal(_))) - }) { - return Err(invalid()); - } - Ok(ExtensionFiles { - sql_members, - module_files: stems - .into_iter() - .map(|stem| format!("{stem}{}", std::env::consts::DLL_SUFFIX)) - .collect(), - data_files, - }) -} - -pub(super) fn cache_extension_files(root: &Path, extension: Extension) -> Result { - let path = manifest_path(root, extension); - let text = fs::read_to_string(&path).map_err(|error| { - Error::Engine(format!( - "read cached extension manifest {}: {error}", - path.display() - )) - })?; - parse(&text, extension) -} - -pub(super) fn write_cache_extension_files( - root: &Path, - extension: Extension, - files: &ExtensionFiles, -) -> Result<()> { - let path = manifest_path(root, extension); - fs::create_dir_all(path.parent().unwrap()).map_err(|error| { - Error::Engine(format!( - "create cached extension manifest directory: {error}" - )) - })?; - let stems = files - .module_files - .iter() - .map(|file| file.strip_suffix(std::env::consts::DLL_SUFFIX).unwrap()) - .collect::>(); - fs::write( - &path, - format!( - "sqlName={}\nsqlMembers={}\nnativeModuleStems={}\ndataFiles={}\n", - extension.sql_name(), - files.sql_members.join(","), - stems.join(","), - files.data_files.join(",") - ), - ) - .map_err(|error| { - Error::Engine(format!( - "write cached extension manifest {}: {error}", - path.display() - )) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn frozen_extension_contract_rejects_incomplete_or_unsafe_metadata() { - for fields in [ - "sqlMembers=postgis\n", - "sqlMembers=postgis\nnativeModuleStems=../../postgis-3\n", - "sqlMembers=postgis,postgis\nnativeModuleStems=postgis-3\n", - "sqlMembers=postgis_topology\nnativeModuleStems=postgis-3\n", - "sqlMembers=postgis\nnativeModuleStems=postgis_topology-3\n", - "sqlMembers=postgis\nnativeModuleStems=postgis-3\ndataFiles=../outside\n", - "sqlMembers=postgis\nnativeModuleStems=postgis-3\nsqlMembers=postgis\n", - ] { - assert!( - parse(&format!("sqlName=postgis\n{fields}"), Extension::POSTGIS).is_err(), - "accepted {fields}" - ); - } - } -} diff --git a/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs b/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs index 4169271da..15eff6de7 100644 --- a/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs +++ b/src/native/rust-bindings/src/liboliphaunt/root/runtime/install.rs @@ -12,9 +12,8 @@ use super::super::files::{ }; use super::super::{NATIVE_RUNTIME_TOOLS, existing_native_tool_path, native_tool_path}; use super::extension_artifact_root_for; -use super::extension_files::{extension_files, write_cache_extension_files}; use crate::error::{Error, Result}; -use crate::extension::Extension; +use crate::extension::{Extension, extension_data_files}; #[cfg(test)] pub(super) fn install_cached_runtime( @@ -159,20 +158,18 @@ fn install_native_share_tree( let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); let extension_share = extension_root.join("share/postgresql"); - let files = extension_files(install_dir, extension_root, *extension)?; copy_extension_sql_files(&extension_share, &target_share, *extension)?; - for member in &files.sql_members { - if member != extension.sql_name() { + for member in extension.sql_members() { + if *member != extension.sql_name() { copy_named_extension_sql_files(&extension_share, &target_share, member, true)?; } } - for relative in &files.data_files { + for relative in extension_data_files(*extension) { copy_file_preserving_permissions( &extension_share.join(relative), &target_share.join(relative), )?; } - write_cache_extension_files(runtime_dir, *extension, &files)?; } Ok(()) } @@ -550,16 +547,13 @@ mod tests { write_file(&embedded_modules.join(module), b"core embedded module"); } let artifacts = [artifact_dir.clone(), contrib_dir]; - let selected = Extension::POSTGIS.with_package(crate::extension::package_tests::postgis_package(Some( - "sqlName=postgis\ndependencies=fuzzystrmatch\nsqlMembers=postgis,postgis_raster,postgis_tiger_geocoder,postgis_topology\nnativeModuleStems=postgis-3,postgis_raster-3,postgis_topology-3\n", - ))); - let extensions = resolve_extensions(&[selected]).unwrap(); + let extensions = resolve_extensions(&[Extension::POSTGIS]).unwrap(); let missing_dependency = install_cached_runtime( NativeRuntimeProfile::PostgresServer, &install_dir, None, std::slice::from_ref(&artifact_dir), - &temp.path().join("missing-frozen-dependency"), + &temp.path().join("missing-dependency"), &extensions, ) .unwrap_err(); @@ -676,98 +670,42 @@ mod tests { } #[test] - fn legacy_separate_postgis_artifact_keeps_its_primary_only_contract() { - let temp = TempTree::new("legacy-postgis-artifact"); + fn separate_core_only_postgis_artifact_is_incomplete() { + let temp = TempTree::new("incomplete-postgis-artifact"); let install_dir = temp.path().join("install"); let artifact_dir = temp.path().join("extension/postgis"); + let contrib_dir = temp.path().join("extension/contrib"); write_minimal_install(&install_dir); + write_extension_assets(&contrib_dir, Extension::FUZZYSTRMATCH); write_file( &artifact_dir.join("share/postgresql/extension/postgis.control"), - b"default_version = '3.5.0'\n", + b"default_version = '3.6.4'\n", ); write_file( - &artifact_dir.join("share/postgresql/extension/postgis--3.5.0.sql"), - b"select 'legacy postgis';\n", + &artifact_dir.join("share/postgresql/extension/postgis--3.6.4.sql"), + b"select 'core-only postgis';\n", ); write_file( &artifact_dir .join("lib/postgresql") .join(Extension::POSTGIS.native_module_file().unwrap()), - b"legacy primary module", + b"primary module", + ); + let extensions = resolve_extensions(&[Extension::POSTGIS]).unwrap(); + let error = install_cached_runtime( + NativeRuntimeProfile::PostgresServer, + &install_dir, + None, + &[artifact_dir, contrib_dir], + &temp.path().join("runtime"), + &extensions, + ) + .unwrap_err(); + assert!(error.to_string().contains("postgis_raster"), "{error}"); + assert!( + error.to_string().contains("missing control file"), + "{error}" ); - // Frozen from main 5daf457e, before raster/GDAL joined the product. - let legacy_data = [ - "contrib/postgis-3.6/legacy.sql", - "contrib/postgis-3.6/legacy_gist.sql", - "contrib/postgis-3.6/legacy_minimal.sql", - "contrib/postgis-3.6/postgis.sql", - "contrib/postgis-3.6/postgis_upgrade.sql", - "contrib/postgis-3.6/spatial_ref_sys.sql", - "contrib/postgis-3.6/uninstall_legacy.sql", - "contrib/postgis-3.6/uninstall_postgis.sql", - "proj/proj.db", - ]; - for relative in legacy_data { - write_file( - &artifact_dir.join("share/postgresql").join(relative), - b"legacy data", - ); - } - for preserved_manifest in [false, true] { - let text = format!( - "sqlName=postgis\ndependencies=\ndataFiles={}\n", - legacy_data.join(",") - ); - let manifest = preserved_manifest.then_some(text.as_str()); - let selected = Extension::POSTGIS - .with_package(crate::extension::package_tests::postgis_package(manifest)); - let extensions = resolve_extensions(&[selected]).unwrap(); - assert_eq!(extensions, [selected]); - if preserved_manifest { - write_file( - &super::super::extension_files::manifest_path( - &artifact_dir, - Extension::POSTGIS, - ), - manifest.unwrap().as_bytes(), - ); - } - let runtime_dir = temp.path().join(format!("legacy-{preserved_manifest}")); - install_cached_runtime( - NativeRuntimeProfile::PostgresServer, - &install_dir, - None, - std::slice::from_ref(&artifact_dir), - &runtime_dir, - &extensions, - ) - .unwrap(); - let files = super::super::extension_files::cache_extension_files( - &runtime_dir, - Extension::POSTGIS, - ) - .unwrap(); - assert_eq!(files.sql_members, ["postgis"]); - assert_eq!(files.data_files, legacy_data); - assert!(!runtime_dir.join("share/postgresql/gdal").exists()); - for relative in legacy_data { - assert!( - runtime_dir - .join("share/postgresql") - .join(relative) - .is_file() - ); - } - assert_eq!( - files.module_files, - [Extension::POSTGIS.native_module_file().unwrap()] - ); - assert!( - !runtime_dir - .join("share/postgresql/extension/postgis_topology.control") - .exists() - ); - } } struct TempTree { @@ -849,8 +787,6 @@ mod tests { b"extension data", ); } - let files = extension_files(install_dir, install_dir, extension).unwrap(); - write_cache_extension_files(install_dir, extension, &files).unwrap(); } fn write_file(path: &Path, contents: &[u8]) { @@ -920,7 +856,7 @@ fn install_native_library_tree( let extension_root = extension_artifact_root_for(install_dir, extension_artifact_dirs, *extension); let extension_lib = extension_root.join("lib/postgresql"); - for module in extension_files(install_dir, extension_root, *extension)?.module_files { + for module in extension.native_module_files() { match profile { NativeRuntimeProfile::OliphauntEmbedded => { let embedded_extension_lib = extension_root.join("lib/modules"); diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java index 3e562ea51..cdb4de417 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/OliphauntExtensionCatalog.java @@ -202,6 +202,9 @@ static Map load(InputStream stream) { "bundled Oliphaunt extension catalog has invalid Maven coordinates for " + sqlName); } List dependencies = portableList(properties.getProperty(prefix + "dependencies", "")); + if (!properties.containsKey(prefix + "sqlMembers")) { + throw new GradleException("bundled Oliphaunt extension catalog is missing " + prefix + "sqlMembers"); + } entries.put( sqlName, new Entry( @@ -212,7 +215,7 @@ static Map load(InputStream stream) { mavenArtifact, runtimeBound, dependencies, - portableList(properties.getProperty(prefix + "sqlMembers", sqlName)))); + portableList(properties.getProperty(prefix + "sqlMembers")))); } for (Entry entry : entries.values()) { for (String dependency : entry.dependencies()) { diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java index 849e8f50d..5c5eddd0d 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/main/java/dev/oliphaunt/android/ResolveOliphauntAndroidAssetsTask.java @@ -109,9 +109,9 @@ public abstract class ResolveOliphauntAndroidAssetsTask extends DefaultTask { "staticSymbolAliases", "licenseFiles", "licenseProfile", + "sqlMembers", + "nativeModuleStems", "files"); - private static final Set OPTIONAL_EXTENSION_CLOSURE_KEYS = - Set.of("sqlMembers", "nativeModuleStems"); private static final List TARGET_INVARIANT_EXTENSION_PROPERTY_KEYS = List.of( "packageLayout", @@ -132,6 +132,8 @@ public abstract class ResolveOliphauntAndroidAssetsTask extends DefaultTask { "staticSymbolAliases", "licenseFiles", "licenseProfile", + "sqlMembers", + "nativeModuleStems", "files"); private final ArchiveOperations archiveOperations; private final FileSystemOperations fileSystemOperations; @@ -1203,7 +1205,6 @@ private static Map targetInvariantExtensionProperties( for (String key : TARGET_INVARIANT_EXTENSION_PROPERTY_KEYS) { result.put(key, manifest.getProperty(key, "")); } - for (String key : OPTIONAL_EXTENSION_CLOSURE_KEYS) result.put(key, manifest.getProperty(key, "")); return Collections.unmodifiableMap(result); } @@ -1298,7 +1299,6 @@ private static void validateExtensionManifest( String expectedRuntimeVersion) { TreeSet actualKeys = new TreeSet<>(manifest.stringPropertyNames()); TreeSet expectedKeys = new TreeSet<>(EXTENSION_ARTIFACT_PROPERTY_KEYS); - if (manifest.containsKey("sqlMembers")) expectedKeys.addAll(OPTIONAL_EXTENSION_CLOSURE_KEYS); if (!actualKeys.equals(expectedKeys)) { throw new GradleException( product @@ -1333,13 +1333,7 @@ private static void validateExtensionManifest( + catalogEntry.artifactProduct()); } String expectedDependencies = String.join(",", catalogEntry.dependencies()); - // Older primary-only releases predate companion SQL and its dependencies. - // Owner resolution already selects the current dependency closure, so a - // historical subset is safe while undeclared dependencies still fail closed. - boolean legacyCompanionRelease = !manifest.containsKey("sqlMembers") - && catalogEntry.sqlMembers().size() > 1 - && catalogEntry.dependencies().containsAll(strictManifestCsv(manifest, "dependencies", assetName)); - if (!expectedDependencies.equals(manifest.getProperty("dependencies")) && !legacyCompanionRelease) { + if (!expectedDependencies.equals(manifest.getProperty("dependencies"))) { throw new GradleException( product + " Android artifact " @@ -1964,18 +1958,11 @@ private static String canonicalDeclaredPath( private static List extensionSqlMembers(Properties manifest, String source) { String sqlName = manifest.getProperty("sqlName"); - boolean creates = "yes".equals(manifest.getProperty("createsExtension")); - boolean declared = manifest.containsKey("sqlMembers"); - if (declared != manifest.containsKey("nativeModuleStems")) { - throw new GradleException(source + " sqlMembers and nativeModuleStems must be declared together"); - } - if (!declared) return creates ? List.of(sqlName) : List.of(); List members = strictManifestCsv(manifest, "sqlMembers", source); List modules = strictManifestCsv(manifest, "nativeModuleStems", source); requireSortedManifestList(members, source, "sqlMembers"); requireSortedManifestList(modules, source, "nativeModuleStems"); - if ((creates ? !members.contains(sqlName) : !members.isEmpty()) - || !OliphauntExtensionCatalog.require(sqlName).sqlMembers().containsAll(members)) { + if (!OliphauntExtensionCatalog.require(sqlName).sqlMembers().equals(members)) { throw new GradleException(source + " sqlMembers must identify the owned SQL closure"); } String stem = manifest.getProperty("nativeModuleStem", ""); @@ -3713,7 +3700,7 @@ private static Properties parseExtensionArtifactProperties(byte[] bytes, String + " has a malformed property key " + key); } - if (!EXTENSION_ARTIFACT_PROPERTY_KEYS.contains(key) && !OPTIONAL_EXTENSION_CLOSURE_KEYS.contains(key)) { + if (!EXTENSION_ARTIFACT_PROPERTY_KEYS.contains(key)) { throw new GradleException( source + " physical line " + lineNumber + " has unknown property key " + key); } diff --git a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java index 4b0350611..9230c9267 100644 --- a/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java +++ b/src/native/sdks/kotlin/oliphaunt-android-gradle-plugin/src/test/java/dev/oliphaunt/android/OliphauntExtensionCatalogContractTest.java @@ -784,7 +784,9 @@ private static void validatesPgcryptoAndPostgisLegalArtifacts() throws Exception Path pgcryptoRoot = Files.createDirectories(root.resolve("pgcrypto")); Properties pgcrypto = cubeArtifactManifest(); pgcrypto.setProperty("sqlName", "pgcrypto"); + pgcrypto.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require("pgcrypto").sqlMembers())); pgcrypto.setProperty("nativeModuleStem", "pgcrypto"); + pgcrypto.setProperty("nativeModuleStems", "pgcrypto"); pgcrypto.setProperty("nativeModuleFile", "pgcrypto.so"); pgcrypto.setProperty("staticSymbolPrefix", "oliphaunt_static_pgcrypto"); pgcrypto.setProperty( @@ -830,7 +832,9 @@ private static void validatesPgcryptoAndPostgisLegalArtifacts() throws Exception Path postgisRoot = Files.createDirectories(root.resolve("postgis")); Properties postgis = cubeArtifactManifest(); postgis.setProperty("sqlName", "postgis"); + postgis.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require("postgis").sqlMembers())); postgis.setProperty("nativeModuleStem", "postgis-3"); + postgis.setProperty("nativeModuleStems", "postgis-3"); postgis.setProperty("nativeModuleFile", "postgis-3.so"); postgis.setProperty("staticSymbolPrefix", "oliphaunt_static_postgis_3"); postgis.setProperty( @@ -852,14 +856,6 @@ private static void validatesPgcryptoAndPostgisLegalArtifacts() throws Exception "mobile-static/android-arm64-v8a/extensions/postgis-3/liboliphaunt_extension_postgis-3.a", "archive\n"); stageCanonicalLegalFiles(postgisRoot, "postgis", "android-arm64-v8a"); - ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( - "oliphaunt-extension-postgis", - "postgis", - "android-arm64-v8a", - postgis, - "1.2.3"); - ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest( - postgisRoot.toFile(), postgis); List postgisMembers = OliphauntExtensionCatalog.require("postgis").sqlMembers(); if (!postgisMembers.contains("postgis_topology") || !postgisMembers.contains("postgis_tiger_geocoder")) { throw new AssertionError("generated PostGIS SQL closure must include topology and tiger"); @@ -876,6 +872,26 @@ private static void validatesPgcryptoAndPostgisLegalArtifacts() throws Exception ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( "oliphaunt-extension-postgis", "postgis", "android-arm64-v8a", postgis, "1.2.3"); ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest(postgisRoot.toFile(), postgis); + for (String field : List.of("sqlMembers", "nativeModuleStems")) { + Properties incomplete = new Properties(); + incomplete.putAll(postgis); + incomplete.remove(field); + expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( + "oliphaunt-extension-postgis", "postgis", "android-arm64-v8a", incomplete, "1.2.3"), "property fields must be exactly"); + } + Properties incompletePostgis = new Properties(); + incompletePostgis.putAll(postgis); + incompletePostgis.setProperty("sqlMembers", "postgis"); + expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( + "oliphaunt-extension-postgis", "postgis", "android-arm64-v8a", incompletePostgis, "1.2.3"), "sqlMembers must identify the owned SQL closure"); + incompletePostgis.setProperty("sqlMembers", String.join(",", postgisMembers)); + incompletePostgis.setProperty("dependencies", ""); + expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( + "oliphaunt-extension-postgis", "postgis", "android-arm64-v8a", incompletePostgis, "1.2.3"), "must declare dependencies=fuzzystrmatch"); + incompletePostgis.setProperty("dependencies", String.join(",", OliphauntExtensionCatalog.require("postgis").dependencies())); + incompletePostgis.setProperty("nativeModuleStems", "postgis-3,postgis_raster-3,postgis_topology-3"); + expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionManifestForContractTest( + "oliphaunt-extension-postgis", "postgis", "android-arm64-v8a", incompletePostgis, "1.2.3"), "nativeModuleStems must contain only the merged primary mobile module"); Path topologyControl = postgisRoot.resolve("files/share/postgresql/extension/postgis_topology.control"); Files.delete(topologyControl); expectFailure(() -> ResolveOliphauntAndroidAssetsTask.validateExtensionArtifactInventoryForContractTest(postgisRoot.toFile(), postgis), "postgis_topology.control and a canonical install SQL file"); @@ -1098,7 +1114,9 @@ private static void validatesCarrierBoundAncillaryExtensionSqlInventory() Path root = Files.createTempDirectory("oliphaunt-ancillary-sql-inventory-"); Properties manifest = cubeArtifactManifest(); manifest.setProperty("sqlName", "pgtap"); + manifest.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require("pgtap").sqlMembers())); manifest.setProperty("nativeModuleStem", ""); + manifest.setProperty("nativeModuleStems", ""); manifest.setProperty("nativeModuleFile", ""); manifest.setProperty("mobilePrebuilt", "no"); manifest.setProperty("mobileStaticArchives", ""); @@ -1955,6 +1973,7 @@ private static byte[] singleMemberExtensionArtifact( throws Exception { Properties manifest = cubeArtifactManifest(); manifest.setProperty("sqlName", sqlName); + manifest.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require(sqlName).sqlMembers())); manifest.setProperty("nativeTarget", target); applyCanonicalLegalManifest(manifest, sqlName, target); manifest.setProperty("extensionSqlFileNames", extensionSqlFileNames); @@ -1962,6 +1981,7 @@ private static byte[] singleMemberExtensionArtifact( List entries = new ArrayList<>(); if (nativeModuleStem == null) { manifest.setProperty("nativeModuleStem", ""); + manifest.setProperty("nativeModuleStems", ""); manifest.setProperty("nativeModuleFile", ""); manifest.setProperty("mobilePrebuilt", "no"); manifest.setProperty("mobileStaticArchives", ""); @@ -1970,6 +1990,7 @@ private static byte[] singleMemberExtensionArtifact( manifest.setProperty("staticSymbolAliases", ""); } else { manifest.setProperty("nativeModuleStem", nativeModuleStem); + manifest.setProperty("nativeModuleStems", nativeModuleStem); manifest.setProperty("nativeModuleFile", nativeModuleStem + ".so"); manifest.setProperty("staticSymbolPrefix", "oliphaunt_static_" + nativeModuleStem); manifest.setProperty( @@ -2055,8 +2076,10 @@ private static void validatesNoCreateExtensionInventoryAndStaticRegistry() throw try { Properties manifest = cubeArtifactManifest(); manifest.setProperty("sqlName", "auto_explain"); + manifest.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require("auto_explain").sqlMembers())); manifest.setProperty("createsExtension", "no"); manifest.setProperty("nativeModuleStem", "auto_explain"); + manifest.setProperty("nativeModuleStems", "auto_explain"); manifest.setProperty("nativeModuleFile", "auto_explain.so"); manifest.setProperty("staticSymbolPrefix", "oliphaunt_static_auto_explain"); manifest.setProperty( @@ -2803,8 +2826,10 @@ private static Properties extensionManifestFor( Properties result = new Properties(); result.putAll(template); result.setProperty("sqlName", sqlName); + result.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require(sqlName).sqlMembers())); result.setProperty("dependencies", dependencies); result.setProperty("nativeModuleStem", sqlName); + result.setProperty("nativeModuleStems", sqlName); result.setProperty("nativeModuleFile", sqlName + ".so"); result.setProperty("staticSymbolPrefix", "oliphaunt_static_" + sqlName); result.setProperty( @@ -2944,8 +2969,10 @@ private static Properties cubeArtifactManifest() { manifest.setProperty("packageLayout", "oliphaunt-extension-artifact-v1"); manifest.setProperty("pgMajor", "18"); manifest.setProperty("sqlName", "cube"); + manifest.setProperty("sqlMembers", String.join(",", OliphauntExtensionCatalog.require("cube").sqlMembers())); manifest.setProperty("createsExtension", "yes"); manifest.setProperty("nativeModuleStem", "cube"); + manifest.setProperty("nativeModuleStems", "cube"); manifest.setProperty("nativeModuleFile", "cube.so"); manifest.setProperty("nativeTarget", "android-arm64-v8a"); manifest.setProperty("nativeRuntimeProduct", "liboliphaunt-native"); @@ -3325,6 +3352,7 @@ private static void rejectsMalformedGeneratedCatalog() { "extension.alpha.mavenGroup=dev.oliphaunt.extensions", "extension.alpha.mavenArtifact=oliphaunt-extension-alpha", "extension.alpha.runtimeBound=false", + "extension.alpha.sqlMembers=alpha", "extension.alpha.dependencies=missing") + "\n"; expectFailure( diff --git a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts index 82f64d1ee..bf73e04f3 100644 --- a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts +++ b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.mts @@ -168,11 +168,7 @@ function requireExactKeys(value, expected, context) { fail(`${context} must be an object`); } const actual = Object.keys(value).sort(compareText); - const canonical = [...expected] - .filter( - (key) => !['sqlMembers', 'nativeModuleStems'].includes(key) || Object.hasOwn(value, key), - ) - .sort(compareText); + const canonical = [...expected].sort(compareText); if (!isDeepStrictEqual(actual, canonical)) { fail(`${context} fields must be exactly ${canonical.join(',')}; got ${actual.join(',')}`); } @@ -426,9 +422,8 @@ async function loadRepositoryContract(root) { } const canonical = { sqlName, - sqlMembers: row['sql-members'] ?? (row['creates-extension'] ? [sqlName] : []), - nativeModuleStems: - row['native-module-stems'] ?? (nativeModuleStem === null ? [] : [nativeModuleStem]), + sqlMembers: row['sql-members'], + nativeModuleStems: row['native-module-stems'], createsExtension: row['creates-extension'], dependencies, dataFiles: canonicalStringList( @@ -603,15 +598,9 @@ function validateExactMobileRoles(member, target, assets, context) { } function validateMemberContract(member, expected, context) { - if (Object.hasOwn(member, 'sqlMembers') !== Object.hasOwn(member, 'nativeModuleStems')) - fail(`${context} sqlMembers and nativeModuleStems must be declared together`); - if (member.sqlMembers !== undefined) { - for (const field of ['sqlMembers', 'nativeModuleStems']) { - if (!isDeepStrictEqual(member[field], expected[field])) - fail(`${context}.${field} must exactly match generated React Native extension metadata`); - } - } for (const field of [ + 'sqlMembers', + 'nativeModuleStems', 'sqlName', 'createsExtension', 'dependencies', diff --git a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts index 3c23f3d5a..9c6505cf1 100644 --- a/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts +++ b/src/native/sdks/react-native/tools/mobile-extension-artifact-paths.test.mts @@ -198,6 +198,8 @@ function extensionMember(sqlName, stagesIos = true) { return { sqlName, createsExtension: row['creates-extension'], + sqlMembers: row['sql-members'], + nativeModuleStems: row['native-module-stems'], dependencies: [...row['selected-extension-dependencies']].sort(), dataFiles: [...row['runtime-share-data-files']].sort(), extensionSqlFileNames: [...row['extension-sql-file-names']].sort(), diff --git a/src/native/sdks/react-native/tools/stage-ios-app.mts b/src/native/sdks/react-native/tools/stage-ios-app.mts index fa78a0b15..575d08ce2 100755 --- a/src/native/sdks/react-native/tools/stage-ios-app.mts +++ b/src/native/sdks/react-native/tools/stage-ios-app.mts @@ -245,6 +245,11 @@ function generatedExtensionCatalog(value) { artifactProduct, releaseProduct, runtimeBound: row['runtime-bound'], + sqlMembers: canonicalPortableList(row['sql-members'], `${sqlName}.sql-members`), + dependencies: canonicalPortableList( + row['selected-extension-dependencies'], + `${sqlName}.selected-extension-dependencies`, + ), }); } return rows; @@ -698,7 +703,8 @@ function validateExtension(value, label, carriers) { 'sqlName', 'tag', 'version', - ...['sqlMembers', 'nativeModuleStems'].filter((key) => Object.hasOwn(root, key)), + 'sqlMembers', + 'nativeModuleStems', ], label, ); @@ -743,14 +749,9 @@ function validateExtension(value, label, carriers) { root.nativeModuleStem === null ? null : portable(root.nativeModuleStem, `${label}.nativeModuleStem`); - if (Object.hasOwn(root, 'sqlMembers') !== Object.hasOwn(root, 'nativeModuleStems')) - fail(`${label} sqlMembers and nativeModuleStems must be declared together`); - const sqlMembers = canonicalPortableList( - root.sqlMembers ?? (root.createsExtension ? [sqlName] : []), - `${label}.sqlMembers`, - ); + const sqlMembers = canonicalPortableList(root.sqlMembers, `${label}.sqlMembers`); const nativeModuleStems = canonicalPortableList( - root.nativeModuleStems ?? (nativeModuleStem === null ? [] : [nativeModuleStem]), + root.nativeModuleStems, `${label}.nativeModuleStems`, ); if (root.createsExtension ? !sqlMembers.includes(sqlName) : sqlMembers.length !== 0) @@ -762,6 +763,12 @@ function validateExtension(value, label, carriers) { JSON.stringify(nativeModuleStem === null ? [] : [nativeModuleStem]) ) fail(`${label}.nativeModuleStems must contain only the merged primary mobile module`); + for (const [field, value] of Object.entries( + generated.sqlMembers.length > 1 ? { sqlMembers, dependencies } : { sqlMembers }, + )) { + if (JSON.stringify(value) !== JSON.stringify(generated[field])) + fail(`${label}.${field} must match generated canonical metadata`); + } const sharedPreloadLibraries = canonicalPortableList( root.sharedPreloadLibraries, `${label}.sharedPreloadLibraries`, @@ -828,7 +835,6 @@ function validateExtension(value, label, carriers) { return { assets: { dependencyFrameworks, extension, runtime }, createsExtension: root.createsExtension, - closureDeclared: Object.hasOwn(root, 'sqlMembers'), sqlMembers, nativeModuleStems, dataFiles, @@ -1924,22 +1930,8 @@ async function extensionResourceRoot(carrier, base, cacheDir) { const manifestFile = path.join(root, 'manifest.properties'); const manifest = parseProperties(await fs.readFile(manifestFile, 'utf8'), manifestFile); rejectUnsupportedProperties(manifest, EXTENSION_ARTIFACT_PROPERTY_KEYS, manifestFile); - if ( - manifest.has('sqlMembers') !== manifest.has('nativeModuleStems') || - manifest.has('sqlMembers') !== carrier.closureDeclared - ) - fail( - `${manifestFile} sqlMembers and nativeModuleStems must match the frozen carrier declaration`, - ); - if (carrier.closureDeclared) { - requireProperty(manifest, 'sqlMembers', carrier.sqlMembers.join(','), manifestFile); - requireProperty( - manifest, - 'nativeModuleStems', - carrier.nativeModuleStems.join(','), - manifestFile, - ); - } + requireProperty(manifest, 'sqlMembers', carrier.sqlMembers.join(','), manifestFile); + requireProperty(manifest, 'nativeModuleStems', carrier.nativeModuleStems.join(','), manifestFile); requireProperty(manifest, 'packageLayout', 'oliphaunt-extension-artifact-v1', manifestFile); requireProperty(manifest, 'pgMajor', '18', manifestFile); requireProperty(manifest, 'sqlName', carrier.sqlName, manifestFile); @@ -2031,13 +2023,7 @@ async function extensionResourceRoot(carrier, base, cacheDir) { `${manifestFile} dataFiles must exactly match the frozen carrier contract for ${carrier.sqlName}`, ); } - requireExactPropertySet( - manifest, - [...EXTENSION_ARTIFACT_PROPERTY_KEYS].filter( - (key) => carrier.closureDeclared || !['sqlMembers', 'nativeModuleStems'].includes(key), - ), - manifestFile, - ); + requireExactPropertySet(manifest, [...EXTENSION_ARTIFACT_PROPERTY_KEYS], manifestFile); const mobileStaticArchives = mobileStaticArchivePaths(manifest, carrier, manifestFile); const mobileStaticDependencyArchives = mobileStaticDependencyArchivePaths( manifest, diff --git a/src/native/sdks/react-native/tools/stage-ios-app.test.mts b/src/native/sdks/react-native/tools/stage-ios-app.test.mts index b5112ed34..da918e62f 100755 --- a/src/native/sdks/react-native/tools/stage-ios-app.test.mts +++ b/src/native/sdks/react-native/tools/stage-ios-app.test.mts @@ -532,18 +532,14 @@ async function extensionRow(root, config) { `staticSymbolAliases=${staticSymbolAliases.join(',')}`, `licenseFiles=${licenseFiles.join(',')}`, `licenseProfile=${profile}`, - ...(config.sqlMembers === undefined - ? [] - : [ - `sqlMembers=${config.sqlMembers.join(',')}`, - `nativeModuleStems=${config.nativeModuleStem ?? ''}`, - ]), + `sqlMembers=${config.sqlMembers.join(',')}`, + `nativeModuleStems=${config.nativeModuleStem ?? ''}`, 'files=files', '', ].join('\n'), ); if (createsExtension) { - for (const member of (config.sqlMembers ?? []).filter((name) => name !== config.sqlName)) { + for (const member of config.sqlMembers.filter((name) => name !== config.sqlName)) { await write( path.join(source, 'files/share/postgresql/extension', `${member}.control`), "default_version = '1.0'\n", @@ -692,12 +688,8 @@ async function extensionRow(root, config) { extension: { assets: logicalAssets.map(({ locator }) => locator), createsExtension, - ...(config.sqlMembers === undefined - ? {} - : { - sqlMembers: config.sqlMembers, - nativeModuleStems: config.nativeModuleStem === null ? [] : [config.nativeModuleStem], - }), + sqlMembers: config.sqlMembers, + nativeModuleStems: config.nativeModuleStem === null ? [] : [config.nativeModuleStem], dataFiles, dependencies: config.dependencies, extensionSqlFileNames: config.extensionSqlFileNames, @@ -825,7 +817,7 @@ async function createFixture(root) { const config = { ...fixture, createsExtension: generated['creates-extension'], - ...(fixture.sqlName === 'postgis' ? { sqlMembers: generated['sql-members'] } : {}), + sqlMembers: generated['sql-members'], dataFiles: generated['runtime-share-data-files'], dependencies: generated['selected-extension-dependencies'], extensionSqlFileNames: generated['extension-sql-file-names'], @@ -1581,6 +1573,43 @@ async function main() { /fields must be exactly bytes,carrier,format,member,path,role,sha256; got bytes,carrier,format,path,role,sha256/u, ); + for (const field of ['sqlMembers', 'nativeModuleStems']) { + const incomplete = structuredClone(carrier); + delete incomplete.extensions.find(({ sqlName }) => sqlName === 'postgis')[field]; + await expectCarrierFailure( + `missing-${field}`, + incomplete, + ['postgis'], + /fields must be exactly/u, + ); + } + for (const [field, value] of [ + ['sqlMembers', ['postgis']], + ['dependencies', []], + ]) { + const incomplete = structuredClone(carrier); + incomplete.extensions.find(({ sqlName }) => sqlName === 'postgis')[field] = value; + await expectCarrierFailure( + `incomplete-postgis-${field}`, + incomplete, + ['postgis'], + /must match generated canonical metadata/u, + ); + } + + const unmergedPostgis = structuredClone(carrier); + unmergedPostgis.extensions.find(({ sqlName }) => sqlName === 'postgis').nativeModuleStems = [ + 'postgis-3', + 'postgis_raster-3', + 'postgis_topology-3', + ]; + await expectCarrierFailure( + 'unmerged-postgis-modules', + unmergedPostgis, + ['postgis'], + /nativeModuleStems must contain only the merged primary mobile module/u, + ); + const missingFrozenContentField = structuredClone(carrier); delete missingFrozenContentField.extensions.find(({ sqlName }) => sqlName === 'pgtap') .dataFiles; diff --git a/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts b/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts index 4b604cb1c..45241100b 100644 --- a/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts +++ b/src/native/sdks/react-native/tools/stage-mobile-qualification.test.mts @@ -79,6 +79,8 @@ test('iOS qualification keeps the Swift base, independent carriers and binary ch }, sqlName: 'pgtap', createsExtension: true, + sqlMembers: ['pgtap'], + nativeModuleStems: [], dependencies: [], dataFiles: [], extensionSqlFileNames: [], diff --git a/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts b/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts index 092cd092a..5af524a82 100644 --- a/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts +++ b/src/native/sdks/react-native/tools/validate-mobile-runtime-files.mts @@ -81,10 +81,8 @@ function extensionContracts(document, label) { } contracts.push({ createsExtension: row['creates-extension'], - sqlMembers: canonicalStringList( - row['sql-members'] ?? (row['creates-extension'] ? [sqlName] : []), - `${rowLabel}.sql-members`, - (value) => PORTABLE_SQL_NAME.test(value), + sqlMembers: canonicalStringList(row['sql-members'], `${rowLabel}.sql-members`, (value) => + PORTABLE_SQL_NAME.test(value), ), dataFiles: canonicalStringList( row['data-files'], diff --git a/src/native/sdks/rust/src/server.rs b/src/native/sdks/rust/src/server.rs index 71d7ab088..15df82307 100644 --- a/src/native/sdks/rust/src/server.rs +++ b/src/native/sdks/rust/src/server.rs @@ -848,28 +848,6 @@ mod tests { .map(PathBuf::from), Some(proj_data) ); - assert_eq!( - present - .get_envs() - .find(|(key, _)| *key == std::ffi::OsStr::new("GDAL_DATA")), - None, - "legacy core-only PostGIS needs PROJ data without requiring GDAL" - ); - - let gdal_data = runtime_dir.join("share/postgresql/gdal"); - std::fs::create_dir_all(&gdal_data).expect("create GDAL data dir"); - std::fs::write(gdal_data.join("gdalvrt.xsd"), b"fixture").expect("write gdalvrt.xsd"); - let mut raster = Command::new("postgres"); - configure_extension_runtime_env(&mut raster, &runtime_dir, &[Extension::POSTGIS]); - assert_eq!( - raster - .get_envs() - .find(|(key, _)| *key == std::ffi::OsStr::new("GDAL_DATA")) - .and_then(|(_, value)| value) - .map(PathBuf::from), - Some(gdal_data) - ); - let mut unselected = Command::new("postgres"); configure_extension_runtime_env(&mut unselected, &runtime_dir, &[]); assert_eq!( diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/cube/manifest.properties b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/cube/manifest.properties index 5f26dce00..e4ed9258f 100644 --- a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/cube/manifest.properties +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/cube/manifest.properties @@ -19,4 +19,6 @@ staticSymbolPrefix=oliphaunt_static_cube staticSymbolAliases= licenseFiles= licenseProfile=contrib-native +sqlMembers=cube +nativeModuleStems=cube files=files diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/earthdistance/manifest.properties b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/earthdistance/manifest.properties index 070c0a87d..621c60044 100644 --- a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/earthdistance/manifest.properties +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/earthdistance/manifest.properties @@ -19,4 +19,6 @@ staticSymbolPrefix=oliphaunt_static_earthdistance staticSymbolAliases= licenseFiles= licenseProfile=contrib-native +sqlMembers=earthdistance +nativeModuleStems=earthdistance files=files diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/LICENSE b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/LICENSE new file mode 100644 index 000000000..92f26b96f --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/LICENSE @@ -0,0 +1 @@ +Oliphaunt license test fixture. diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_LICENSES/PostgreSQL-COPYRIGHT b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_LICENSES/PostgreSQL-COPYRIGHT new file mode 100644 index 000000000..e353489a4 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_LICENSES/PostgreSQL-COPYRIGHT @@ -0,0 +1 @@ +PostgreSQL license test fixture. diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_NOTICES.md b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..045acf78a --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/THIRD_PARTY_NOTICES.md @@ -0,0 +1 @@ +Oliphaunt third-party notices test fixture. diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/lib/postgresql/fuzzystrmatch.dylib b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/lib/postgresql/fuzzystrmatch.dylib new file mode 100644 index 000000000..c878da482 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/lib/postgresql/fuzzystrmatch.dylib @@ -0,0 +1 @@ +fixture fuzzystrmatch native module diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch--1.5.sql b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch--1.5.sql new file mode 100644 index 000000000..e010ec423 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch--1.5.sql @@ -0,0 +1 @@ +CREATE FUNCTION fuzzystrmatch_in(cstring) RETURNS fuzzystrmatch AS 'MODULE_PATHNAME', 'fuzzystrmatch_in' LANGUAGE C IMMUTABLE STRICT; diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch.control b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch.control new file mode 100644 index 000000000..a0ad60ad5 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/files/share/postgresql/extension/fuzzystrmatch.control @@ -0,0 +1,2 @@ +default_version = '1.5' +module_pathname = '$libdir/fuzzystrmatch' diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/manifest.properties b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/manifest.properties new file mode 100644 index 000000000..ccf66dd69 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/fuzzystrmatch/manifest.properties @@ -0,0 +1,24 @@ +packageLayout=oliphaunt-extension-artifact-v1 +pgMajor=18 +sqlName=fuzzystrmatch +createsExtension=yes +nativeModuleStem=fuzzystrmatch +nativeModuleFile=fuzzystrmatch.dylib +nativeTarget=ios-xcframework +nativeRuntimeProduct=liboliphaunt-native +nativeRuntimeVersion=0.1.0 +dependencies= +dataFiles= +extensionSqlFileNames= +extensionSqlFilePrefixes= +sharedPreloadLibraries= +mobilePrebuilt=yes +mobileStaticArchives= +mobileStaticDependencyArchives= +staticSymbolPrefix=oliphaunt_static_fuzzystrmatch +staticSymbolAliases= +licenseFiles= +licenseProfile=contrib-native +sqlMembers=fuzzystrmatch +nativeModuleStems=fuzzystrmatch +files=files diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/pgtap/manifest.properties b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/pgtap/manifest.properties index 55e744fd8..bb647a9b6 100644 --- a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/pgtap/manifest.properties +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/pgtap/manifest.properties @@ -19,4 +19,6 @@ staticSymbolPrefix= staticSymbolAliases= licenseFiles=share/licenses/pgtap/README.md licenseProfile=external-native +sqlMembers=pgtap +nativeModuleStems= files=files diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster--3.6.1.sql b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster--3.6.1.sql new file mode 100644 index 000000000..319974100 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster--3.6.1.sql @@ -0,0 +1,2 @@ +-- Companion SQL package fixture. +SELECT 1; diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster.control b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster.control new file mode 100644 index 000000000..3c4c27033 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_raster.control @@ -0,0 +1 @@ +default_version = '3.6.1' diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder--3.6.1.sql b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder--3.6.1.sql new file mode 100644 index 000000000..319974100 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder--3.6.1.sql @@ -0,0 +1,2 @@ +-- Companion SQL package fixture. +SELECT 1; diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder.control b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder.control new file mode 100644 index 000000000..3c4c27033 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_tiger_geocoder.control @@ -0,0 +1 @@ +default_version = '3.6.1' diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology--3.6.1.sql b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology--3.6.1.sql new file mode 100644 index 000000000..319974100 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology--3.6.1.sql @@ -0,0 +1,2 @@ +-- Companion SQL package fixture. +SELECT 1; diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology.control b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology.control new file mode 100644 index 000000000..3c4c27033 --- /dev/null +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/files/share/postgresql/extension/postgis_topology.control @@ -0,0 +1 @@ +default_version = '3.6.1' diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/manifest.properties b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/manifest.properties index 5d9d6f439..86c07bacd 100644 --- a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/manifest.properties +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-resources/postgis/manifest.properties @@ -7,7 +7,7 @@ nativeModuleFile=postgis-3.dylib nativeTarget=ios-xcframework nativeRuntimeProduct=liboliphaunt-native nativeRuntimeVersion=0.1.0 -dependencies= +dependencies=fuzzystrmatch dataFiles=contrib/postgis-3.6/legacy.sql,contrib/postgis-3.6/legacy_gist.sql,contrib/postgis-3.6/legacy_minimal.sql,contrib/postgis-3.6/postgis.sql,contrib/postgis-3.6/postgis_upgrade.sql,contrib/postgis-3.6/spatial_ref_sys.sql,contrib/postgis-3.6/uninstall_legacy.sql,contrib/postgis-3.6/uninstall_postgis.sql,proj/proj.db extensionSqlFileNames=uninstall_postgis.sql extensionSqlFilePrefixes=postgis_comments,postgis_proc_set_search_path,rtpostgis @@ -19,4 +19,6 @@ staticSymbolPrefix=oliphaunt_static_postgis_3 staticSymbolAliases=difference:oliphaunt_static_postgis_3_difference,pg_finfo_difference:pg_finfo_oliphaunt_static_postgis_3_difference licenseFiles=share/licenses/geos/COPYING,share/licenses/geos/src/deps/ryu/LICENSE,share/licenses/geos/src/deps/ryu/LICENSE-Apache2,share/licenses/geos/src/deps/ryu/LICENSE-Boost,share/licenses/json-c/COPYING,share/licenses/libcharset/COPYING.LIB,share/licenses/libiconv/COPYING.LIB,share/licenses/libxml2/Copyright,share/licenses/postgis/COPYING,share/licenses/postgis/LICENSE.TXT,share/licenses/postgis/deps/flatgeobuf/flatbuffers/LICENSE,share/licenses/postgis/deps/ryu/LICENSE,share/licenses/postgis/deps/ryu/LICENSE-Apache2,share/licenses/postgis/deps/ryu/LICENSE-Boost,share/licenses/proj/COPYING,share/licenses/sqlite/LICENSE.md licenseProfile=external-native +sqlMembers=postgis,postgis_raster,postgis_tiger_geocoder,postgis_topology +nativeModuleStems=postgis-3 files=files diff --git a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-selection.json b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-selection.json index 6bc963bb5..3f453c500 100644 --- a/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-selection.json +++ b/src/native/sdks/swift/Tests/Fixtures/swiftpm-extension-selection.json @@ -17,7 +17,9 @@ "sqlName": "earthdistance", "createsExtension": true, "dataFiles": [], - "dependencies": ["cube"], + "dependencies": [ + "cube" + ], "extensionSqlFileNames": [], "extensionSqlFilePrefixes": [], "nativeModuleStem": "earthdistance", @@ -37,7 +39,13 @@ "address": "geo_distance" } ] - } + }, + "sqlMembers": [ + "earthdistance" + ], + "nativeModuleStems": [ + "earthdistance" + ] }, { "product": "oliphaunt-extension-contrib-pg18", @@ -70,7 +78,13 @@ "address": "pg_finfo_cube_in" } ] - } + }, + "sqlMembers": [ + "cube" + ], + "nativeModuleStems": [ + "cube" + ] }, { "product": "oliphaunt-extension-postgis", @@ -89,8 +103,12 @@ "contrib/postgis-3.6/uninstall_postgis.sql", "proj/proj.db" ], - "dependencies": [], - "extensionSqlFileNames": ["uninstall_postgis.sql"], + "dependencies": [ + "fuzzystrmatch" + ], + "extensionSqlFileNames": [ + "uninstall_postgis.sql" + ], "extensionSqlFilePrefixes": [ "postgis_comments", "postgis_proc_set_search_path", @@ -126,7 +144,16 @@ "address": "pg_finfo_oliphaunt_static_postgis_3_difference" } ] - } + }, + "sqlMembers": [ + "postgis", + "postgis_raster", + "postgis_tiger_geocoder", + "postgis_topology" + ], + "nativeModuleStems": [ + "postgis-3" + ] }, { "product": "oliphaunt-extension-pgtap", @@ -136,14 +163,53 @@ "createsExtension": true, "dataFiles": [], "dependencies": [], - "extensionSqlFileNames": ["uninstall_pgtap.sql"], - "extensionSqlFilePrefixes": ["pgtap-core", "pgtap-schema"], + "extensionSqlFileNames": [ + "uninstall_pgtap.sql" + ], + "extensionSqlFilePrefixes": [ + "pgtap-core", + "pgtap-schema" + ], "nativeModuleStem": null, "nativeDependencies": [], "resourceRoot": "swiftpm-extension-resources/pgtap", "sharedPreloadLibraries": [], "asset": null, - "registration": null + "registration": null, + "sqlMembers": [ + "pgtap" + ], + "nativeModuleStems": [] + }, + { + "product": "oliphaunt-extension-contrib-pg18", + "releaseProduct": "liboliphaunt-native", + "version": "0.1.0", + "sqlName": "fuzzystrmatch", + "createsExtension": true, + "dataFiles": [], + "dependencies": [], + "extensionSqlFileNames": [], + "extensionSqlFilePrefixes": [], + "nativeModuleStem": "fuzzystrmatch", + "nativeDependencies": [], + "resourceRoot": "swiftpm-extension-resources/fuzzystrmatch", + "sharedPreloadLibraries": [], + "asset": { + "name": "liboliphaunt-0.1.0-apple-spm-extension-fuzzystrmatch.zip", + "url": "https://github.com/f0rr0/oliphaunt/releases/download/oliphaunt-extension-contrib-pg18-v0.1.0/liboliphaunt-0.1.0-apple-spm-extension-fuzzystrmatch.zip", + "checksum": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "registration": { + "hasInit": false, + "symbols": [] + }, + "sqlMembers": [ + "fuzzystrmatch" + ], + "nativeModuleStems": [ + "fuzzystrmatch" + ] } ] } diff --git a/src/native/sdks/swift/tools/extension-resource-inventory.mts b/src/native/sdks/swift/tools/extension-resource-inventory.mts index 1d1f73e46..bdea65534 100644 --- a/src/native/sdks/swift/tools/extension-resource-inventory.mts +++ b/src/native/sdks/swift/tools/extension-resource-inventory.mts @@ -26,6 +26,8 @@ const PROPERTY_KEYS = Object.freeze([ 'staticSymbolAliases', 'licenseFiles', 'licenseProfile', + 'sqlMembers', + 'nativeModuleStems', 'files', ]); const LEGAL_MEMBERS_BY_PROFILE = Object.freeze({ @@ -140,11 +142,7 @@ function normalizeCanonicalRow(row, label) { } return { sqlName, - sqlMembers: canonicalList( - row?.['sql-members'] ?? (createsExtension ? [sqlName] : []), - `${label}.sql-members`, - portable, - ), + sqlMembers: canonicalList(row?.['sql-members'], `${label}.sql-members`, portable), product, releaseProduct, createsExtension, @@ -176,16 +174,9 @@ function normalizeFrozenRuntimeContract(row, label) { if (typeof row?.createsExtension !== 'boolean') fail(label, 'createsExtension must be boolean'); const nativeModuleStem = row?.nativeModuleStem ?? null; if (nativeModuleStem !== null) portable(nativeModuleStem, `${label}.nativeModuleStem`); - const closureDeclared = row.sqlMembers !== undefined; - if (closureDeclared !== (row.nativeModuleStems !== undefined)) - fail(label, 'sqlMembers and nativeModuleStems must be declared together'); - const sqlMembers = canonicalList( - row.sqlMembers ?? (row.createsExtension ? [sqlName] : []), - `${label}.sqlMembers`, - portable, - ); + const sqlMembers = canonicalList(row.sqlMembers, `${label}.sqlMembers`, portable); const nativeModuleStems = canonicalList( - row.nativeModuleStems ?? (nativeModuleStem === null ? [] : [nativeModuleStem]), + row.nativeModuleStems, `${label}.nativeModuleStems`, portable, ); @@ -218,7 +209,6 @@ function normalizeFrozenRuntimeContract(row, label) { fail(label, 'extensionSqlFilePrefixes must contain dot-free portable basename prefixes'); } return { - closureDeclared, sqlMembers, nativeModuleStems, createsExtension: row.createsExtension, @@ -306,13 +296,7 @@ function parseProperties(text, label) { if (properties.has(key)) fail(label, `repeats property ${key}`); properties.set(key, line.slice(separator + 1)); } - const closureKeys = ['sqlMembers', 'nativeModuleStems']; - if (properties.has(closureKeys[0]) !== properties.has(closureKeys[1])) - fail(label, 'sqlMembers and nativeModuleStems must be declared together'); - const expectedKeys = properties.has(closureKeys[0]) - ? [...PROPERTY_KEYS.slice(0, -1), ...closureKeys, 'files'] - : PROPERTY_KEYS; - if (JSON.stringify([...properties.keys()]) !== JSON.stringify(expectedKeys)) { + if (JSON.stringify([...properties.keys()]) !== JSON.stringify(PROPERTY_KEYS)) { fail( label, `must contain the exact canonical fields in canonical order: ${PROPERTY_KEYS.join(',')}`, @@ -674,6 +658,12 @@ export async function validateSwiftExtensionResourceArtifact({ }) { assertSwiftExtensionMatchesCanonical(extension, canonical, label); const contract = normalizeFrozenRuntimeContract(extension, `${label} frozen carrier contract`); + for (const field of canonical.sqlMembers.length > 1 + ? ['sqlMembers', 'dependencies'] + : ['sqlMembers']) { + if (JSON.stringify(contract[field]) !== JSON.stringify(canonical[field])) + fail(label, `${field} must match generated canonical metadata`); + } if ( nativeRuntime?.product !== 'liboliphaunt-native' || !STABLE_SEMVER.test(nativeRuntime?.version ?? '') @@ -712,12 +702,8 @@ export async function validateSwiftExtensionResourceArtifact({ ['mobilePrebuilt', contract.nativeModuleStem === null ? 'no' : 'yes'], ['files', 'files'], ]); - if (properties.has('sqlMembers') !== contract.closureDeclared) - fail(label, 'manifest SQL closure declaration differs from the frozen carrier contract'); - if (contract.closureDeclared) { - expected.set('sqlMembers', contract.sqlMembers.join(',')); - expected.set('nativeModuleStems', contract.nativeModuleStems.join(',')); - } + expected.set('sqlMembers', contract.sqlMembers.join(',')); + expected.set('nativeModuleStems', contract.nativeModuleStems.join(',')); for (const [key, value] of expected) { if (properties.get(key) !== value) fail(label, `manifest ${key} must be ${JSON.stringify(value)}`); diff --git a/src/native/sdks/swift/tools/extension-resource-inventory.test.mts b/src/native/sdks/swift/tools/extension-resource-inventory.test.mts index 1b0cb3432..7f5c2f1ea 100644 --- a/src/native/sdks/swift/tools/extension-resource-inventory.test.mts +++ b/src/native/sdks/swift/tools/extension-resource-inventory.test.mts @@ -123,22 +123,55 @@ async function main() { assert.ok( sqlMembers.includes('postgis_topology') && sqlMembers.includes('postgis_tiger_geocoder'), ); - const expandedPostgis = { ...postgis, sqlMembers, nativeModuleStems: ['postgis-3'] }; - await fs.writeFile( - postgisManifest, - postgisManifestText.replace( - 'files=files\n', - `sqlMembers=${sqlMembers.join(',')}\nnativeModuleStems=postgis-3\nfiles=files\n`, - ), - ); - const companionFiles = []; - for (const member of sqlMembers.filter((name) => name !== 'postgis')) { - for (const name of [`${member}.control`, `${member}--1.0.sql`]) { - const file = path.join(postgis.resourceRoot, 'files/share/postgresql/extension', name); - await fs.writeFile(file, '-- companion fixture\n'); - companionFiles.push(file); - } + for (const missing of ['sqlMembers', 'nativeModuleStems']) { + const incomplete = { ...postgis }; + delete incomplete[missing]; + await assert.rejects( + () => + validateSwiftExtensionResourceArtifact({ + extension: incomplete, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }), + /must be a string array/u, + ); } + await assert.rejects( + () => + validateSwiftExtensionResourceArtifact({ + extension: { ...postgis, sqlMembers: ['postgis'] }, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }), + /sqlMembers must match generated canonical metadata/u, + ); + await assert.rejects( + () => + validateSwiftExtensionResourceArtifact({ + extension: { ...postgis, dependencies: [] }, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }), + /dependencies must match generated canonical metadata/u, + ); + await assert.rejects( + () => + validateSwiftExtensionResourceArtifact({ + extension: { + ...postgis, + nativeModuleStems: ['postgis-3', 'postgis_raster-3', 'postgis_topology-3'], + }, + canonical: catalog.get('postgis'), + nativeRuntime: document.nativeRuntime, + }), + /mobile nativeModuleStems must contain only the merged primary module/u, + ); + const expandedPostgis = postgis; + const companionFiles = sqlMembers + .filter((name) => name !== 'postgis') + .map((member) => + path.join(postgis.resourceRoot, 'files/share/postgresql/extension', `${member}.control`), + ); const expandedResources = await validateSwiftExtensionResourceArtifact({ extension: expandedPostgis, canonical: catalog.get('postgis'), @@ -177,7 +210,7 @@ async function main() { }), /undeclared extension SQL\/control file/u, ); - await Promise.all([...companionFiles, foreignControl].map((file) => fs.rm(file))); + await fs.rm(foreignControl); await fs.writeFile(postgisManifest, postgisManifestText); await assert.rejects( () => diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.mts index 63a323b74..752351e9f 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.mts @@ -646,8 +646,8 @@ function extensionCarrier( ); } const sqlName = portableIdentifier(manifest.sqlName, `${manifestPath}.sqlName`); - if ((manifest.sqlMembers === undefined) !== (manifest.nativeModuleStems === undefined)) - throw error(`${manifestPath} sqlMembers and nativeModuleStems must be declared together`); + const sqlMembers = canonicalStringList(manifest.sqlMembers, `${manifestPath}.sqlMembers`); + canonicalStringList(manifest.nativeModuleStems, `${manifestPath}.nativeModuleStems`); if (typeof manifest.createsExtension !== 'boolean') { throw error(`${manifestPath} ${sqlName}.createsExtension must be boolean`); } @@ -846,15 +846,8 @@ function extensionCarrier( tag, sqlName, createsExtension: manifest.createsExtension, - ...(manifest.sqlMembers === undefined - ? {} - : { - sqlMembers: canonicalStringList( - manifest.sqlMembers, - `${manifestPath} ${sqlName}.sqlMembers`, - ), - nativeModuleStems: nativeModuleStem === null ? [] : [nativeModuleStem], - }), + sqlMembers, + nativeModuleStems: nativeModuleStem === null ? [] : [nativeModuleStem], dataFiles, dependencies, extensionSqlFileNames, diff --git a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts index e7a9820e3..52d0a1499 100644 --- a/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts +++ b/src/native/sdks/swift/tools/ios-carrier-manifest.test.mts @@ -121,6 +121,8 @@ function writeManifest(root, product, body) { version: currentProductVersionSync(product, 'ios-carrier-manifest.test'), compatibility: compatibility(), createsExtension: true, + sqlMembers: [body.sqlName], + nativeModuleStems: body.nativeModuleStem === null ? [] : [body.nativeModuleStem], dataFiles: [], dependencies: [], extensionSqlFileNames: [], @@ -526,6 +528,8 @@ test('bundle carriers verify exact nested bytes ', async () => { extensions.push({ sqlName, createsExtension: true, + sqlMembers: [sqlName], + nativeModuleStems: [], dataFiles: [], dependencies: [], extensionSqlFileNames: [], diff --git a/src/native/sdks/swift/tools/render-extension-products.mts b/src/native/sdks/swift/tools/render-extension-products.mts index b13aabf65..ffc2b9292 100755 --- a/src/native/sdks/swift/tools/render-extension-products.mts +++ b/src/native/sdks/swift/tools/render-extension-products.mts @@ -438,7 +438,8 @@ export function validateSelection( 'sharedPreloadLibraries', 'sqlName', 'version', - ...['sqlMembers', 'nativeModuleStems'].filter((key) => Object.hasOwn(row, key)), + 'sqlMembers', + 'nativeModuleStems', ], `extensions[${index}]`, ); @@ -499,9 +500,11 @@ export function validateSelection( cFunction: nativeModuleStem === null ? null : `oliphaunt_extension_${cModuleStem}_descriptor`, cTarget: nativeModuleStem === null ? null : `COliphauntExtension${suffix}`, createsExtension: row.createsExtension, - ...(row.sqlMembers === undefined - ? {} - : { sqlMembers: row.sqlMembers, nativeModuleStems: row.nativeModuleStems }), + sqlMembers: uniquePortableList(row.sqlMembers, `extensions[${index}].sqlMembers`), + nativeModuleStems: uniquePortableList( + row.nativeModuleStems, + `extensions[${index}].nativeModuleStems`, + ), dataFiles: frozenDataFiles(row.dataFiles, `extensions[${index}].dataFiles`), dependencies, extensionSqlFileNames: frozenSqlFileNames( diff --git a/src/native/sdks/swift/tools/swift-carrier-resolver.mts b/src/native/sdks/swift/tools/swift-carrier-resolver.mts index a2028c7bd..4b36785dc 100644 --- a/src/native/sdks/swift/tools/swift-carrier-resolver.mts +++ b/src/native/sdks/swift/tools/swift-carrier-resolver.mts @@ -463,7 +463,8 @@ function validateExtension(value, label, carriers) { 'sqlName', 'tag', 'version', - ...['sqlMembers', 'nativeModuleStems'].filter((key) => Object.hasOwn(row, key)), + 'sqlMembers', + 'nativeModuleStems', ], label, ); @@ -483,15 +484,9 @@ function validateExtension(value, label, carriers) { const dependencies = canonicalIds(row.dependencies, `${label}.dependencies`); if (dependencies.includes(sqlName)) fail(`${label}.dependencies must not include ${sqlName} itself`); - if (Object.hasOwn(row, 'sqlMembers') !== Object.hasOwn(row, 'nativeModuleStems')) - fail(`${label} sqlMembers and nativeModuleStems must be declared together`); return { - ...(Object.hasOwn(row, 'sqlMembers') - ? { - sqlMembers: canonicalIds(row.sqlMembers, `${label}.sqlMembers`), - nativeModuleStems: canonicalIds(row.nativeModuleStems, `${label}.nativeModuleStems`), - } - : {}), + sqlMembers: canonicalIds(row.sqlMembers, `${label}.sqlMembers`), + nativeModuleStems: canonicalIds(row.nativeModuleStems, `${label}.nativeModuleStems`), assets: assetLocators(row.assets, `${label}.assets`, carriers), createsExtension: row.createsExtension, dataFiles: canonicalRelativeFiles(row.dataFiles, `${label}.dataFiles`), @@ -1038,12 +1033,8 @@ export async function resolveSwiftCarrierSelection({ version: row.version, sqlName: row.sqlName, createsExtension: row.createsExtension, - ...(row.sqlMembers === undefined - ? {} - : { - sqlMembers: row.sqlMembers, - nativeModuleStems: row.nativeModuleStems, - }), + sqlMembers: row.sqlMembers, + nativeModuleStems: row.nativeModuleStems, dataFiles: row.dataFiles, dependencies: row.dependencies, extensionSqlFileNames: row.extensionSqlFileNames, diff --git a/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts b/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts index 96fb95003..95dbc99d5 100755 --- a/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts +++ b/src/native/sdks/swift/tools/swift-carrier-resolver.test.mts @@ -286,20 +286,6 @@ async function productionExtensionResource(sqlName, stem, nativeDependencies) { 'mobileStaticDependencyArchives=\n', `mobileStaticDependencyArchives=${mobileStaticDependencyArchives.join(',')}\n`, ); - if (sqlName === 'postgis') { - const sqlMembers = canonicalCatalog.get(sqlName).sqlMembers; - manifest = manifest.replace( - 'files=files\n', - `sqlMembers=${sqlMembers.join(',')}\nnativeModuleStems=${stem}\nfiles=files\n`, - ); - for (const member of sqlMembers.filter((name) => name !== sqlName)) { - for (const name of [`${member}.control`, `${member}--1.0.sql`]) - await fs.writeFile( - path.join(stage, 'files/share/postgresql/extension', name), - '-- companion fixture\n', - ); - } - } await fs.writeFile(manifestFile, manifest); for (const row of [...mobileStaticArchives, ...mobileStaticDependencyArchives]) { const relative = row.slice(row.lastIndexOf(':') + 1); @@ -311,6 +297,7 @@ async function productionExtensionResource(sqlName, stem, nativeDependencies) { } async function extension(sqlName, stem, dependencies = [], nativeDependencies = []) { + dependencies = canonicalCatalog.get(sqlName).dependencies; const resource = await productionExtensionResource(sqlName, stem, nativeDependencies); const runtime = path.join(root, 'archives', `${sqlName}-runtime.tar.gz`); await fs.mkdir(path.dirname(runtime), { recursive: true }); @@ -335,7 +322,7 @@ async function extension(sqlName, stem, dependencies = [], nativeDependencies = } const prefix = stem === null ? null : `oliphaunt_static_${stem.replaceAll(/[^A-Za-z0-9_]/gu, '_')}`; - const product = new Set(['cube', 'earthdistance', 'pg_trgm']).has(sqlName) + const product = new Set(['cube', 'earthdistance', 'fuzzystrmatch', 'pg_trgm']).has(sqlName) ? 'oliphaunt-extension-contrib-pg18' : `oliphaunt-extension-${sqlName.replaceAll('_', '-')}`; const releaseProduct = @@ -353,9 +340,8 @@ async function extension(sqlName, stem, dependencies = [], nativeDependencies = tag: `${releaseProduct}-v${version}`, sqlName, createsExtension: true, - ...(sqlName === 'postgis' - ? { sqlMembers: canonicalCatalog.get(sqlName).sqlMembers, nativeModuleStems: [stem] } - : {}), + sqlMembers: canonicalCatalog.get(sqlName).sqlMembers, + nativeModuleStems: stem === null ? [] : [stem], dataFiles: content.dataFiles, dependencies, extensionSqlFileNames: content.extensionSqlFileNames, @@ -503,6 +489,7 @@ async function main() { await fs.mkdir(root, { recursive: true }); const carrierized = carrierize([ await extension('cube', 'cube'), + await extension('fuzzystrmatch', 'fuzzystrmatch'), await extension('earthdistance', 'earthdistance', ['cube']), await extension('pgtap', null), await extension( @@ -551,6 +538,7 @@ async function main() { 'artifact-product': 'oliphaunt-extension-pgtap', 'release-product': 'oliphaunt-extension-pgtap', 'creates-extension': false, + 'sql-members': ['pgtap'], 'native-module-stem': 'future_pgtap', 'selected-extension-dependencies': ['cube'], 'runtime-share-data-files': ['future/data.dat'], @@ -655,7 +643,7 @@ async function main() { }); assert.deepEqual( products.selected.map(({ sqlName }) => sqlName), - ['cube', 'earthdistance', 'pgtap', 'postgis'], + ['cube', 'earthdistance', 'fuzzystrmatch', 'pgtap', 'postgis'], ); assert.equal(products.selected.find(({ sqlName }) => sqlName === 'pgtap').version, '1.1.0'); const selectedPostgis = products.selected.find(({ sqlName }) => sqlName === 'postgis'); @@ -1342,7 +1330,7 @@ async function main() { 'mismatched-creates-extension', mismatchedCreatesExtension, 'pgtap', - /manifest createsExtension must be "no"/u, + /sqlMembers must contain the primary extension exactly when createsExtension is true/u, ); const duplicateName = structuredClone(manifest); diff --git a/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts b/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts index 0e21c85d3..73b376d55 100644 --- a/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts +++ b/src/native/sdks/ts/src/__tests__/asset-resolver.test.ts @@ -36,8 +36,8 @@ type FixtureExtensionContract = { sqlName: string; createsExtension: boolean; nativeModuleStem: string | null; - sqlMembers?: string[]; - nativeModuleStems?: string[]; + sqlMembers: string[]; + nativeModuleStems: string[]; dependencies: string[]; dataFiles: string[]; extensionSqlFileNames: string[]; @@ -72,6 +72,8 @@ function fixtureExtensionContract( sqlName: extension.sqlName, createsExtension: extension.createsExtension, nativeModuleStem: extension.nativeModuleStem, + sqlMembers: [...extension.sqlMembers], + nativeModuleStems: [...extension.nativeModuleStems], dependencies: [...extension.selectedExtensionDependencies], dataFiles: [...extension.runtimeShareDataFiles], extensionSqlFileNames: [...extension.extensionSqlFileNames], @@ -400,7 +402,7 @@ async function nodeExtensionMaterializationAcceptsBuiltInPostgresDependency(): P nodeAssets.materializeExtensionInstall({ libraryPath, runtimeDirectory: installRuntime }, [ 'pgtap', ]), - /member pgtap is incompatible with the SDK dependency contract/, + /member pgtap is incompatible with the SDK extension contract/, ); } finally { if (installedRuntime !== undefined) { @@ -798,7 +800,7 @@ async function nodeExtensionMaterializationCopiesPackagePayloads(): Promise(); - const writeContract = async (contract: FixtureExtensionContract) => + const writeContract = async (contract: Partial) => writeFile( contractPath, JSON.stringify( - fixtureExtensionContractManifest(extension.artifactProduct, version, target.id, [contract]), + fixtureExtensionContractManifest(extension.artifactProduct, version, target.id, [ + contract as FixtureExtensionContract, + ]), ), ); - const legacy = fixtureExtensionContract(extension, { - dependencies: [], - // Main 5daf457e's core-only PostGIS contract, before raster/GDAL. - dataFiles: [ - 'contrib/postgis-3.6/legacy.sql', - 'contrib/postgis-3.6/legacy_gist.sql', - 'contrib/postgis-3.6/legacy_minimal.sql', - 'contrib/postgis-3.6/postgis.sql', - 'contrib/postgis-3.6/postgis_upgrade.sql', - 'contrib/postgis-3.6/spatial_ref_sys.sql', - 'contrib/postgis-3.6/uninstall_legacy.sql', - 'contrib/postgis-3.6/uninstall_postgis.sql', - 'proj/proj.db', - ], - extensionSqlFileNames: [], - extensionSqlFilePrefixes: [], - }); + const complete = fixtureExtensionContract(extension); await mkdir(sqlRoot, { recursive: true }); - await writeFile(join(sqlRoot, 'postgis.control'), "default_version='3.6.2'\n"); - await writeFile(join(sqlRoot, 'postgis--3.6.2.sql'), 'install'); - for (const file of legacy.dataFiles) { + for (const file of complete.dataFiles) { const destination = join(runtime, 'share/postgresql', file); await mkdir(dirname(destination), { recursive: true }); - await writeFile(destination, `legacy data:${file}`); + await writeFile(destination, `data:${file}`); } for (const profile of ['postgresql', 'modules']) { await mkdir(join(runtime, 'lib', profile), { recursive: true }); - await writeFile(join(runtime, 'lib', profile, `postgis-3${suffix}`), 'legacy core'); } const materialize = async () => { const result = await nodeAssets.materializeExtensionInstall(install, ['postgis']); @@ -1210,30 +1195,24 @@ async function nodePostgisCompanionClosureInstall( return result; }; try { - await writeContract(legacy); - const oldInstall = await materialize(); - assert.equal( - await readFile(join(oldInstall.moduleDirectory!, `postgis-3${suffix}`), 'utf8'), - 'legacy core', - ); - await assert.rejects( - readFile(join(oldInstall.moduleDirectory!, `fuzzystrmatch${suffix}`)), - /ENOENT/, - ); - assert.equal( - await readFile(join(oldInstall.runtimeDirectory!, 'share/postgresql/proj/proj.db'), 'utf8'), - 'legacy data:proj/proj.db', - ); - await assert.rejects( - readFile(join(oldInstall.runtimeDirectory!, 'share/postgresql/gdal/gdalvrt.xsd')), - /ENOENT/, - ); - const complete = { - ...legacy, - dependencies: [...extension.selectedExtensionDependencies], - sqlMembers: [...extension.sqlMembers], - nativeModuleStems: [...extension.nativeModuleStems], - }; + for (const omitted of [ + ['sqlMembers'], + ['nativeModuleStems'], + ['sqlMembers', 'nativeModuleStems'], + ] as const) { + const incomplete: Partial = { ...complete }; + for (const field of omitted) delete incomplete[field]; + await writeContract(incomplete); + await assert.rejects(materialize, /fields must be exactly/); + } + for (const incomplete of [ + { ...complete, dependencies: [] }, + { ...complete, sqlMembers: ['postgis'] }, + { ...complete, nativeModuleStems: ['postgis-3'] }, + ]) { + await writeContract(incomplete); + await assert.rejects(materialize, /incompatible with the SDK extension contract/); + } await writeContract(complete); for (const member of extension.sqlMembers) { await writeFile( @@ -1249,12 +1228,6 @@ async function nodePostgisCompanionClosureInstall( await writeFile(join(runtime, 'lib', profile, `${stem}${suffix}`), `complete:${stem}`); } } - const sidecar = join(runtime, '.oliphaunt/extensions/postgis.properties'); - await mkdir(dirname(sidecar), { recursive: true }); - await writeFile( - sidecar, - `sqlName=postgis\nsqlMembers=${complete.sqlMembers.join(',')}\nnativeModuleStems=${complete.nativeModuleStems.join(',')}\n`, - ); for (const [file, expected] of [ [join(sqlRoot, 'postgis_topology.control'), /postgis_topology\.control and canonical/], [join(sqlRoot, 'postgis_topology--3.6.4.sql'), /postgis_topology\.control and canonical/], @@ -1272,11 +1245,12 @@ async function nodePostgisCompanionClosureInstall( await assert.rejects(materialize, expected); await writeFile(file, bytes); } - await writeContract({ ...complete, dependencies: [] }); - await assert.rejects(materialize, /requires undeclared dependency fuzzystrmatch/); - await writeContract(complete); + const tigerControl = join(sqlRoot, 'postgis_tiger_geocoder.control'); + const controlBytes = await readFile(tigerControl); + await writeFile(tigerControl, "default_version='3.6.4'\nrequires='cube'\n"); + await assert.rejects(materialize, /requires undeclared dependency cube/); + await writeFile(tigerControl, controlBytes); const installed = await materialize(); - assert.notEqual(installed.runtimeDirectory, oldInstall.runtimeDirectory); assert.equal( await readFile(join(installed.moduleDirectory!, `postgis_topology-3${suffix}`), 'utf8'), 'complete:postgis_topology-3', diff --git a/src/native/sdks/ts/src/native/assets.ts b/src/native/sdks/ts/src/native/assets.ts index cfe5f4af0..a2cb5ef2c 100644 --- a/src/native/sdks/ts/src/native/assets.ts +++ b/src/native/sdks/ts/src/native/assets.ts @@ -32,6 +32,7 @@ import { import { nativeModuleSuffixForTarget, type RuntimeFileHost, + selectedExtensionClosure, validatePreparedRuntimeExtensions, } from './extension-runtime.js'; import { syncDirectory, syncRuntimeDirectoryTree } from './filesystem-durability.js'; @@ -101,7 +102,6 @@ type NpmExtensionMemberContract = { nativeModuleStem: string | null; nativeModuleStems: string[]; sqlMembers: string[]; - frozenClosure: boolean; dependencies: string[]; dataFiles: string[]; extensionSqlFileNames: string[]; @@ -144,7 +144,9 @@ const NPM_EXTENSION_CONTRACT_MEMBER_FIELDS = [ 'extensionSqlFilePrefixes', 'licenseFiles', 'nativeModuleStem', + 'nativeModuleStems', 'sharedPreloadLibraries', + 'sqlMembers', 'sqlName', ] as const; @@ -228,42 +230,27 @@ export async function materializeExtensionInstall( extensions: ReadonlyArray = [], descriptors: readonly NativeExtension[] = [], ): Promise { - if (extensions.length === 0) { + const selected = selectedExtensionClosure(extensions); + if (selected.length === 0) { return install; } - for (const sqlName of extensions) { - if (generatedExtensionBySqlName(sqlName) === undefined) { - throw new Error(`unknown Oliphaunt extension id '${sqlName}'`); - } - } if (install.runtimeDirectory === undefined) { throw new Error( - `native extension packages require a package-managed runtime directory; selected extensions: ${extensions.join(', ')}`, + `native extension packages require a package-managed runtime directory; selected extensions: ${selected.join(', ')}`, ); } const installRuntimeDirectory = install.runtimeDirectory; const versions = await packageVersions(); const target = liboliphauntPackageTarget(platform(), arch()); - const resolved = new Map(); - let pending = [...new Set(extensions)].sort(compareText); - while (pending.length > 0) { - const batch = await Promise.all( - pending.map((sqlName) => - resolveExtensionPackage( - sqlName, - target.id, - versions.liboliphauntVersion, - descriptors.find((value) => value.sqlName === sqlName), - ), + const packages = await Promise.all( + selected.map((sqlName) => + resolveExtensionPackage( + sqlName, + target.id, + versions.liboliphauntVersion, + descriptors.find((value) => value.sqlName === sqlName), ), - ); - for (const entry of batch) resolved.set(entry.sqlName, entry); - pending = [...new Set(batch.flatMap((entry) => entry.contract.dependencies))] - .filter((name) => !resolved.has(name)) - .sort(compareText); - } - const packages = [...resolved.values()].sort((left, right) => - compareText(left.sqlName, right.sqlName), + ), ); const cacheKey = runtimeCacheKey({ libraryPath: install.libraryPath, @@ -621,11 +608,8 @@ function parseExtensionMemberContract(value: unknown, label: string): NpmExtensi } const row = value as Record; if ( - JSON.stringify( - Object.keys(row) - .filter((key) => key !== 'sqlMembers' && key !== 'nativeModuleStems') - .sort(compareText), - ) !== JSON.stringify(NPM_EXTENSION_CONTRACT_MEMBER_FIELDS) + JSON.stringify(Object.keys(row).sort(compareText)) !== + JSON.stringify(NPM_EXTENSION_CONTRACT_MEMBER_FIELDS) ) { throw new Error( `${label} fields must be exactly ${NPM_EXTENSION_CONTRACT_MEMBER_FIELDS.join(', ')}`, @@ -644,22 +628,12 @@ function parseExtensionMemberContract(value: unknown, label: string): NpmExtensi ) { throw new Error(`${label}.nativeModuleStem must be null or a portable identifier`); } - if ((row.sqlMembers === undefined) !== (row.nativeModuleStems === undefined)) { - throw new Error(`${label} must freeze sqlMembers and nativeModuleStems together`); - } - const frozenClosure = row.sqlMembers !== undefined; - const sqlMembers = - row.sqlMembers === undefined - ? row.createsExtension - ? [row.sqlName] - : [] - : parseExtensionContractStringList(row.sqlMembers, 'sqlMembers', label); - const nativeModuleStems = - row.nativeModuleStems === undefined - ? row.nativeModuleStem === null - ? [] - : [row.nativeModuleStem as string] - : parseExtensionContractStringList(row.nativeModuleStems, 'nativeModuleStems', label); + const sqlMembers = parseExtensionContractStringList(row.sqlMembers, 'sqlMembers', label); + const nativeModuleStems = parseExtensionContractStringList( + row.nativeModuleStems, + 'nativeModuleStems', + label, + ); for (const [field, values] of [ ['sqlMembers', sqlMembers], ['nativeModuleStems', nativeModuleStems], @@ -722,7 +696,6 @@ function parseExtensionMemberContract(value: unknown, label: string): NpmExtensi nativeModuleStem: row.nativeModuleStem as string | null, nativeModuleStems, sqlMembers, - frozenClosure, dependencies, dataFiles, extensionSqlFileNames, @@ -802,12 +775,15 @@ async function loadExtensionPackageContract(config: { if ( current === undefined || current.artifactProduct !== config.extension.artifactProduct || - member.dependencies.some( - (dependency) => !current.selectedExtensionDependencies.includes(dependency), - ) + member.createsExtension !== current.createsExtension || + member.nativeModuleStem !== current.nativeModuleStem || + JSON.stringify(member.dependencies) !== + JSON.stringify(current.selectedExtensionDependencies) || + JSON.stringify(member.sqlMembers) !== JSON.stringify(current.sqlMembers) || + JSON.stringify(member.nativeModuleStems) !== JSON.stringify(current.nativeModuleStems) ) { throw new Error( - `${config.packageName} extension contract member ${member.sqlName} is incompatible with the SDK dependency contract`, + `${config.packageName} extension contract member ${member.sqlName} is incompatible with the SDK extension contract`, ); } } @@ -1234,33 +1210,9 @@ async function requireExactExtensionRuntimeInventory(config: { const embeddedModuleFiles = config.contract.nativeModuleStems.map( (stem) => `lib/modules/${stem}${nativeModuleSuffixForTarget(config.target)}`, ); - const frozenManifest = `.oliphaunt/extensions/${config.contract.sqlName}.properties`; const controls = new Set(); const installs = new Set(); for (const file of files) { - if (file === frozenManifest) { - const contents = await readFile(join(config.runtimeDirectory, file), 'utf8'); - const fields = new Map(); - for (const line of contents.split(/\r?\n/u)) { - if (line === '' || line.startsWith('#')) continue; - const separator = line.indexOf('='); - if (separator <= 0 || fields.has(line.slice(0, separator))) { - throw new Error(`${config.source} contains invalid frozen extension metadata`); - } - fields.set(line.slice(0, separator), line.slice(separator + 1)); - } - if ( - fields.get('sqlName') !== config.contract.sqlName || - fields.has('sqlMembers') !== fields.has('nativeModuleStems') || - (config.contract.frozenClosure && !fields.has('sqlMembers')) || - (fields.has('sqlMembers') && - (fields.get('sqlMembers') !== config.contract.sqlMembers.join(',') || - fields.get('nativeModuleStems') !== config.contract.nativeModuleStems.join(','))) - ) { - throw new Error(`${config.source} frozen extension metadata differs from its contract`); - } - continue; - } const extensionPrefix = 'share/postgresql/extension/'; if (file.startsWith(extensionPrefix)) { const fileName = file.slice(extensionPrefix.length); @@ -1342,9 +1294,6 @@ async function requireExactExtensionRuntimeInventory(config: { ); } } - if (config.contract.frozenClosure && !files.includes(frozenManifest)) { - throw new Error(`${config.source} is missing frozen extension metadata ${frozenManifest}`); - } for (const moduleFile of moduleFiles) { if (!files.includes(moduleFile)) throw new Error(`${config.source} is missing declared native module ${moduleFile}`); diff --git a/tools/release/publication-lock.mts b/tools/release/publication-lock.mts index acc164ff6..33abb9500 100644 --- a/tools/release/publication-lock.mts +++ b/tools/release/publication-lock.mts @@ -24,12 +24,11 @@ import { validateSelectionNeutralSwiftSourceCarrier, validateSwiftSourceReleaseContract, } from '../../src/native/sdks/swift/tools/swift-source-carrier-contract.mts'; -import { releaseJavaScript } from '../packaging/emit-javascript.mts'; import { assertWasixTypescriptManifest, assertWasixTypescriptNativeCarrier, } from '../../src/wasix/sdks/ts/tools/wasix-typescript-package.mts'; -import { requireMatchingWasixRuntime } from './compatibility-version-policy.mts'; +import { releaseJavaScript } from '../packaging/emit-javascript.mts'; import { parseMavenArtifactManifest } from '../packaging/maven-artifact-manifest.mts'; import { validateMavenCentralPublication } from '../packaging/maven-central-contract.mts'; import { validateNpmTrustedPublishingManifest } from '../packaging/npm-trusted-publishing.mts'; @@ -37,6 +36,7 @@ import { readFileOnlyTarGzipEntries, readPortableArchiveEntries, } from '../packaging/portable-archive.mts'; +import { requireMatchingWasixRuntime } from './compatibility-version-policy.mts'; import { loadPublicationCatalog, PUBLICATION_CATALOG_SCHEMA, @@ -919,6 +919,10 @@ function validateExtensionManifestRow(product, manifestPath, member) { if ( member.nativeModuleStem !== iosContract.nativeModuleStem || member.createsExtension !== (iosContract.metadata['creates-extension'] !== false) || + stableJson(sortedStrings(member.sqlMembers)) !== + stableJson(iosContract.metadata['sql-members']) || + stableJson(sortedStrings(member.nativeModuleStems)) !== + stableJson(iosContract.metadata['native-module-stems']) || stableJson(sortedStrings(member.dependencies)) !== stableJson( [...(iosContract.metadata['selected-extension-dependencies'] ?? [])].sort(compareText), @@ -981,6 +985,8 @@ function publicExtensionMember(member) { extensionSqlFileNames: member.extensionSqlFileNames, extensionSqlFilePrefixes: member.extensionSqlFilePrefixes, nativeModuleStem: member.nativeModuleStem, + sqlMembers: member.sqlMembers, + nativeModuleStems: member.nativeModuleStems, iosNativeDependencies: member.iosNativeDependencies, iosRegistration: member.iosRegistration, wasixInstall, @@ -1525,6 +1531,8 @@ function extensionGithubReleaseArtifacts(files, product) { extensionSqlFileNames: publicMember.extensionSqlFileNames, extensionSqlFilePrefixes: publicMember.extensionSqlFilePrefixes, nativeModuleStem: publicMember.nativeModuleStem, + sqlMembers: publicMember.sqlMembers, + nativeModuleStems: publicMember.nativeModuleStems, iosNativeDependencies: publicMember.iosNativeDependencies, iosRegistration: publicMember.iosRegistration, wasixInstall: publicMember.wasixInstall, diff --git a/tools/release/publication-lock.test.mts b/tools/release/publication-lock.test.mts index 5a49749d8..3cc4d70ca 100644 --- a/tools/release/publication-lock.test.mts +++ b/tools/release/publication-lock.test.mts @@ -17,12 +17,12 @@ import { extensionCarrierLegalContract, stageExtensionUpstreamLicenses, } from '../../src/extensions/tools/extension-upstream-licenses.mts'; -import { extensionDependencyRequirement } from '../../src/wasix/runtime/tools/package_liboliphaunt_wasix_cargo_artifacts.mts'; import { buildSwiftExtensionCarrierManifest, iosBaseLegalMetadata, swiftExtensionCarrierAssetName, } from '../../src/native/sdks/swift/tools/ios-carrier-manifest.mts'; +import { extensionDependencyRequirement } from '../../src/wasix/runtime/tools/package_liboliphaunt_wasix_cargo_artifacts.mts'; import { createDeterministicTar } from '../packaging/cargo-source-package.mts'; import { releaseJavaScript } from '../packaging/emit-javascript.mts'; import { canonicalGzipSync } from '../packaging/portable-archive.mts'; @@ -512,6 +512,8 @@ function extensionGithubReleaseFixture( extensionSqlFileNames: [...extension['extension-sql-file-names']].sort(), extensionSqlFilePrefixes: [...extension['extension-sql-file-prefixes']].sort(), nativeModuleStem, + sqlMembers: [...extension['sql-members']], + nativeModuleStems: [...extension['native-module-stems']], iosNativeDependencies: stagesIos ? iosNativeDependencies : [], iosRegistration: nativeModuleStem === null || !stagesIos @@ -720,6 +722,8 @@ function extensionGithubReleaseFixture( extensionSqlFileNames: extensions[0].extensionSqlFileNames, extensionSqlFilePrefixes: extensions[0].extensionSqlFilePrefixes, nativeModuleStem: extensions[0].nativeModuleStem, + sqlMembers: extensions[0].sqlMembers, + nativeModuleStems: extensions[0].nativeModuleStems, iosNativeDependencies: extensions[0].iosNativeDependencies, iosRegistration: extensions[0].iosRegistration, wasixInstall: extensions[0].wasixInstall,