graphile-storage-registry 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Constructive <developers@constructive.io>
5
+ Copyright (c) 2020-present, Interweb, Inc.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # graphile-storage-registry
2
+
3
+ Registry-derived storage plane discovery for PostGraphile v5.
4
+
5
+ A storage plane is a `@storageFiles` table and the `@storageBuckets` table it
6
+ writes into. This package pairs them through their **actual foreign-key
7
+ relations** in the Graphile registry (`pgRegistry.pgRelations`) — never through
8
+ table names — and derives the canonical upload surface names from the schema's
9
+ own inflection, so no physical name is ever load-bearing.
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { discoverStoragePlanes, uploadSurfaceNames } from 'graphile-storage-registry';
15
+
16
+ const planes = discoverStoragePlanes(build.input.pgRegistry);
17
+ for (const plane of planes) {
18
+ const names = uploadSurfaceNames(build.inflection, plane.filesCodec);
19
+ // names.uploadMutation === 'uploadAppFile', names.bulkUploadMutation === 'uploadAppFiles', …
20
+ }
21
+ ```
22
+
23
+ `discoverStoragePlanes` throws at schema build when a tagged storage table
24
+ cannot be paired:
25
+
26
+ - `STORAGE_PLANE_UNPAIRED` — a `@storageFiles` table with no FK to a
27
+ `@storageBuckets` table, or a `@storageBuckets` table referenced by no files
28
+ table.
29
+ - `STORAGE_PLANE_AMBIGUOUS` — a files table with FKs to more than one buckets
30
+ table.
31
+
32
+ A plane is entity-keyed when its buckets table carries an `owner_id` attribute
33
+ (`plane.hasOwnerId`) — a registry fact, never inferred from the plane's scope
34
+ name.
35
+
36
+ ---
37
+
38
+ ## Education and Tutorials
39
+
40
+ 1. 🚀 [Quickstart: Getting Up and Running](https://constructive.io/learn/quickstart)
41
+ Get started with modular databases in minutes. Install prerequisites and deploy your first module.
42
+
43
+ 2. 📦 [Modular PostgreSQL Development with Database Packages](https://constructive.io/learn/modular-postgres)
44
+ Learn to organize PostgreSQL projects with pgpm workspaces and reusable database modules.
45
+
46
+ 3. ✏️ [Authoring Database Changes](https://constructive.io/learn/authoring-database-changes)
47
+ Master the workflow for adding, organizing, and managing database changes with pgpm.
48
+
49
+ 4. 🧪 [End-to-End PostgreSQL Testing with TypeScript](https://constructive.io/learn/e2e-postgres-testing)
50
+ Master end-to-end PostgreSQL testing with ephemeral databases, RLS testing, and CI/CD automation.
51
+
52
+ 5. ⚡ [Supabase Testing](https://constructive.io/learn/supabase)
53
+ Use TypeScript-first tools to test Supabase projects with realistic RLS, policies, and auth contexts.
54
+
55
+ 6. 💧 [Drizzle ORM Testing](https://constructive.io/learn/drizzle-testing)
56
+ Run full-stack tests with Drizzle ORM, including database setup, teardown, and RLS enforcement.
57
+
58
+ 7. 🔧 [Troubleshooting](https://constructive.io/learn/troubleshooting)
59
+ Common issues and solutions for pgpm, PostgreSQL, and testing.
60
+
61
+ ## Related Constructive Tooling
62
+
63
+ ### 📦 Package Management
64
+
65
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
66
+
67
+ ### 🧪 Testing
68
+
69
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
70
+ * [pglite-test](https://github.com/constructive-io/constructive/tree/main/postgres/pglite-test): **🪶 Drop-in pgsql-test replacement backed by PGlite** — in-process Postgres, no server required, instance-per-suite isolation.
71
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
72
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
73
+ * [graphile-test](https://github.com/constructive-io/constructive/tree/main/graphile/graphile-test): **🔐 Authentication mocking** for Graphile-focused test helpers and emulating row-level security contexts.
74
+ * [pg-query-context](https://github.com/constructive-io/constructive/tree/main/postgres/pg-query-context): **🔒 Session context injection** to add session-local context (e.g., `SET LOCAL`) into queries—ideal for setting `role`, `jwt.claims`, and other session settings.
75
+
76
+ ### 🧠 Parsing & AST
77
+
78
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
79
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
80
+ * [pg-proto-parser](https://www.npmjs.com/package/pg-proto-parser): **📦 Protobuf parser** for parsing PostgreSQL Protocol Buffers definitions to generate TypeScript interfaces, utility functions, and JSON mappings for enums.
81
+ * [@pgsql/enums](https://www.npmjs.com/package/@pgsql/enums): **🏷️ TypeScript enums** for PostgreSQL AST for safe and ergonomic parsing logic.
82
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
83
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
84
+
85
+ ### 📚 Documentation & Skills
86
+
87
+ * [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
88
+
89
+ Install skills for AI coding agents:
90
+
91
+ ```bash
92
+ # All platform skills (security, blueprints, codegen, billing, etc.)
93
+ npx skills add constructive-io/constructive-skills
94
+
95
+ # Individual repo skills (pgpm, testing, CLI, search, etc.)
96
+ npx skills add https://github.com/constructive-io/constructive --skill pgpm
97
+ npx skills add https://github.com/constructive-io/constructive --skill constructive-testing
98
+ ```
99
+
100
+ ## Credits
101
+
102
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
103
+
104
+ ## Disclaimer
105
+
106
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
107
+
108
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/esm/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { StorageInflection, UploadSurfaceNames } from './naming';
2
+ export { DOWNLOAD_URL_FIELD, uploadSurfaceNames } from './naming';
3
+ export type { StorageCodec, StorageCodecAttributes, StorageCodecRelation, StoragePgRegistry, StoragePlanePair, } from './pairing';
4
+ export { discoverStoragePlanes, pairStoragePlane } from './pairing';
package/esm/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { DOWNLOAD_URL_FIELD, uploadSurfaceNames } from './naming';
2
+ export { discoverStoragePlanes, pairStoragePlane } from './pairing';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Canonical names of a storage plane's upload surface.
3
+ *
4
+ * The presigned-url plugin emits these fields and types; anything that reports
5
+ * or consumes the surface (the `_meta` storage section, dynamic clients) must
6
+ * derive the same names from the same inflection facts. This module is the one
7
+ * definition of that derivation, so the emitter and the reporters cannot
8
+ * disagree.
9
+ */
10
+ import type { StorageCodec } from './pairing';
11
+ /**
12
+ * The computed field the download plugin adds to every files type. One
13
+ * definition so the emitter (download-url-field.ts) and the reporters
14
+ * (`_meta`, dynamic clients) cannot disagree.
15
+ */
16
+ export declare const DOWNLOAD_URL_FIELD = "downloadUrl";
17
+ /** The slice of `build.inflection` the naming derivation reads. */
18
+ export interface StorageInflection {
19
+ tableType(codec: StorageCodec): string;
20
+ }
21
+ /** The GraphQL names of one plane's upload surface. */
22
+ export interface UploadSurfaceNames {
23
+ /** GraphQL type name of the files table (e.g. `AppFile`). */
24
+ filesTypeName: string;
25
+ /** Single upload mutation field (e.g. `uploadAppFile`). */
26
+ uploadMutation: string;
27
+ /** Input type of the single upload mutation (e.g. `UploadAppFileInput`). */
28
+ uploadInputType: string;
29
+ /** Payload type of the single upload mutation (e.g. `UploadAppFilePayload`). */
30
+ uploadPayloadType: string;
31
+ /** Bulk upload mutation field (e.g. `uploadAppFiles`). */
32
+ bulkUploadMutation: string;
33
+ /** Input type of the bulk upload mutation (e.g. `UploadAppFileBulkInput`). */
34
+ bulkUploadInputType: string;
35
+ /** Payload type of the bulk upload mutation (e.g. `UploadAppFileBulkPayload`). */
36
+ bulkUploadPayloadType: string;
37
+ /** Per-file input type inside the bulk input (e.g. `UploadAppFileBulkFileInput`). */
38
+ bulkUploadFileInputType: string;
39
+ /** Per-file payload type inside the bulk payload (e.g. `UploadAppFileBulkFilePayload`). */
40
+ bulkUploadFilePayloadType: string;
41
+ }
42
+ /**
43
+ * Derive the upload surface names for a plane's files codec.
44
+ */
45
+ export declare function uploadSurfaceNames(inflection: StorageInflection, filesCodec: StorageCodec): UploadSurfaceNames;
package/esm/naming.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Canonical names of a storage plane's upload surface.
3
+ *
4
+ * The presigned-url plugin emits these fields and types; anything that reports
5
+ * or consumes the surface (the `_meta` storage section, dynamic clients) must
6
+ * derive the same names from the same inflection facts. This module is the one
7
+ * definition of that derivation, so the emitter and the reporters cannot
8
+ * disagree.
9
+ */
10
+ /**
11
+ * The computed field the download plugin adds to every files type. One
12
+ * definition so the emitter (download-url-field.ts) and the reporters
13
+ * (`_meta`, dynamic clients) cannot disagree.
14
+ */
15
+ export const DOWNLOAD_URL_FIELD = 'downloadUrl';
16
+ /**
17
+ * Derive the upload surface names for a plane's files codec.
18
+ */
19
+ export function uploadSurfaceNames(inflection, filesCodec) {
20
+ const filesTypeName = inflection.tableType(filesCodec);
21
+ return {
22
+ filesTypeName,
23
+ uploadMutation: `upload${filesTypeName}`,
24
+ uploadInputType: `Upload${filesTypeName}Input`,
25
+ uploadPayloadType: `Upload${filesTypeName}Payload`,
26
+ bulkUploadMutation: `upload${filesTypeName}s`,
27
+ bulkUploadInputType: `Upload${filesTypeName}BulkInput`,
28
+ bulkUploadPayloadType: `Upload${filesTypeName}BulkPayload`,
29
+ bulkUploadFileInputType: `Upload${filesTypeName}BulkFileInput`,
30
+ bulkUploadFilePayloadType: `Upload${filesTypeName}BulkFilePayload`,
31
+ };
32
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Registry-derived storage plane discovery.
3
+ *
4
+ * A storage plane is a `@storageFiles` table and the `@storageBuckets` table it
5
+ * writes into. The pairing fact is the files table's foreign key to the buckets
6
+ * table — a relation the Graphile registry already carries — so it is read from
7
+ * `pgRegistry.pgRelations`, never derived from table names. Physical naming
8
+ * (`app_files`/`app_buckets` vs unprefixed `files`/`buckets`) is a rendering
9
+ * choice and carries no meaning here.
10
+ *
11
+ * A `@storageFiles` codec that cannot be paired through a real FK relation is a
12
+ * provisioning bug, and discovery throws at schema build rather than silently
13
+ * emitting a schema with no upload surface for that plane.
14
+ */
15
+ /** The attributes shape shared by pg codecs. */
16
+ export interface StorageCodecAttributes {
17
+ [attributeName: string]: unknown;
18
+ }
19
+ /**
20
+ * The slice of a Graphile pg codec that storage discovery reads. Structural on
21
+ * purpose: callers pass real codecs from `build.input.pgRegistry`, and this
22
+ * package stays free of graphile-build peer dependencies.
23
+ */
24
+ export interface StorageCodec {
25
+ name: string;
26
+ attributes?: StorageCodecAttributes;
27
+ extensions?: {
28
+ pg?: {
29
+ schemaName?: string;
30
+ name?: string;
31
+ };
32
+ tags?: {
33
+ storageFiles?: unknown;
34
+ storageBuckets?: unknown;
35
+ [tag: string]: unknown;
36
+ };
37
+ };
38
+ }
39
+ /** The slice of a registry relation entry that storage discovery reads. */
40
+ export interface StorageCodecRelation {
41
+ isReferencee?: boolean;
42
+ localAttributes: readonly string[];
43
+ remoteAttributes: readonly string[];
44
+ remoteResource?: {
45
+ codec?: StorageCodec;
46
+ };
47
+ }
48
+ /** The slice of `build.input.pgRegistry` that storage discovery reads. */
49
+ export interface StoragePgRegistry {
50
+ pgCodecs: Record<string, StorageCodec>;
51
+ pgRelations: Record<string, Record<string, StorageCodecRelation>>;
52
+ }
53
+ /**
54
+ * A discovered storage plane: the files codec, the buckets codec its FK names,
55
+ * and the facts a plugin needs to shape the plane's GraphQL surface.
56
+ */
57
+ export interface StoragePlanePair {
58
+ filesCodec: StorageCodec;
59
+ bucketsCodec: StorageCodec;
60
+ /** Registry name of the FK relation on the files codec (e.g. `bucketsByMyBucketId`). */
61
+ relationName: string;
62
+ /** FK attribute(s) on the files table referencing the buckets table (e.g. `['bucket_id']`). */
63
+ fkAttributes: string[];
64
+ /**
65
+ * Whether the plane is entity-keyed: the buckets table carries an `owner_id`
66
+ * attribute (a registry fact — never inferred from the plane's scope name).
67
+ */
68
+ hasOwnerId: boolean;
69
+ }
70
+ /**
71
+ * Pair one `@storageFiles` codec with its `@storageBuckets` codec through the
72
+ * registry's FK relations.
73
+ *
74
+ * Reads the codec's forward relations (`isReferencee` false — the files table
75
+ * holds the FK) and keeps those whose remote codec is tagged `@storageBuckets`.
76
+ * Exactly one such relation must exist; zero or several is a malformed plane
77
+ * and throws.
78
+ */
79
+ export declare function pairStoragePlane(filesCodec: StorageCodec, pgRegistry: StoragePgRegistry): StoragePlanePair;
80
+ /**
81
+ * Discover every storage plane in the registry.
82
+ *
83
+ * Pairs each `@storageFiles` codec through {@link pairStoragePlane}, then
84
+ * verifies no `@storageBuckets` codec was left unpaired — a buckets table with
85
+ * no files table referencing it is the same provisioning bug from the other
86
+ * side, and throws rather than being silently ignored.
87
+ */
88
+ export declare function discoverStoragePlanes(pgRegistry: StoragePgRegistry): StoragePlanePair[];
package/esm/pairing.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Registry-derived storage plane discovery.
3
+ *
4
+ * A storage plane is a `@storageFiles` table and the `@storageBuckets` table it
5
+ * writes into. The pairing fact is the files table's foreign key to the buckets
6
+ * table — a relation the Graphile registry already carries — so it is read from
7
+ * `pgRegistry.pgRelations`, never derived from table names. Physical naming
8
+ * (`app_files`/`app_buckets` vs unprefixed `files`/`buckets`) is a rendering
9
+ * choice and carries no meaning here.
10
+ *
11
+ * A `@storageFiles` codec that cannot be paired through a real FK relation is a
12
+ * provisioning bug, and discovery throws at schema build rather than silently
13
+ * emitting a schema with no upload surface for that plane.
14
+ */
15
+ function hasTag(codec, tag) {
16
+ return !!codec.attributes && !!codec.extensions?.tags?.[tag];
17
+ }
18
+ function codecSqlName(codec) {
19
+ const schema = codec.extensions?.pg?.schemaName;
20
+ const table = codec.extensions?.pg?.name ?? codec.name;
21
+ return schema ? `${schema}.${table}` : table;
22
+ }
23
+ /**
24
+ * Pair one `@storageFiles` codec with its `@storageBuckets` codec through the
25
+ * registry's FK relations.
26
+ *
27
+ * Reads the codec's forward relations (`isReferencee` false — the files table
28
+ * holds the FK) and keeps those whose remote codec is tagged `@storageBuckets`.
29
+ * Exactly one such relation must exist; zero or several is a malformed plane
30
+ * and throws.
31
+ */
32
+ export function pairStoragePlane(filesCodec, pgRegistry) {
33
+ const relations = pgRegistry.pgRelations[filesCodec.name] ?? {};
34
+ const bucketRelations = Object.entries(relations).filter(([, relation]) => !relation.isReferencee &&
35
+ relation.remoteResource?.codec &&
36
+ hasTag(relation.remoteResource.codec, 'storageBuckets'));
37
+ if (bucketRelations.length === 0) {
38
+ throw new Error(`STORAGE_PLANE_UNPAIRED: @storageFiles table ${codecSqlName(filesCodec)} has no ` +
39
+ `foreign key to a @storageBuckets table in the registry. A files table must ` +
40
+ `reference its buckets table; check the storage module's generated FK and the ` +
41
+ `@storageBuckets smart tag on the buckets table.`);
42
+ }
43
+ if (bucketRelations.length > 1) {
44
+ const targets = bucketRelations
45
+ .map(([, relation]) => codecSqlName(relation.remoteResource.codec))
46
+ .join(', ');
47
+ throw new Error(`STORAGE_PLANE_AMBIGUOUS: @storageFiles table ${codecSqlName(filesCodec)} has ` +
48
+ `foreign keys to ${bucketRelations.length} @storageBuckets tables (${targets}); ` +
49
+ `a files table must reference exactly one buckets table.`);
50
+ }
51
+ const [relationName, relation] = bucketRelations[0];
52
+ const bucketsCodec = relation.remoteResource.codec;
53
+ return {
54
+ filesCodec,
55
+ bucketsCodec,
56
+ relationName,
57
+ fkAttributes: [...relation.localAttributes],
58
+ hasOwnerId: !!bucketsCodec.attributes?.owner_id,
59
+ };
60
+ }
61
+ /**
62
+ * Discover every storage plane in the registry.
63
+ *
64
+ * Pairs each `@storageFiles` codec through {@link pairStoragePlane}, then
65
+ * verifies no `@storageBuckets` codec was left unpaired — a buckets table with
66
+ * no files table referencing it is the same provisioning bug from the other
67
+ * side, and throws rather than being silently ignored.
68
+ */
69
+ export function discoverStoragePlanes(pgRegistry) {
70
+ const codecs = Object.values(pgRegistry.pgCodecs);
71
+ const filesCodecs = codecs.filter((codec) => hasTag(codec, 'storageFiles'));
72
+ const bucketsCodecs = codecs.filter((codec) => hasTag(codec, 'storageBuckets'));
73
+ const pairs = filesCodecs.map((filesCodec) => pairStoragePlane(filesCodec, pgRegistry));
74
+ const bucketsPairCounts = new Map();
75
+ for (const pair of pairs) {
76
+ const existing = bucketsPairCounts.get(pair.bucketsCodec) ?? [];
77
+ existing.push(pair);
78
+ bucketsPairCounts.set(pair.bucketsCodec, existing);
79
+ }
80
+ for (const [bucketsCodec, bucketPairs] of bucketsPairCounts) {
81
+ if (bucketPairs.length > 1) {
82
+ const sources = bucketPairs.map((pair) => codecSqlName(pair.filesCodec)).join(', ');
83
+ throw new Error(`STORAGE_PLANE_AMBIGUOUS: @storageBuckets table ${codecSqlName(bucketsCodec)} is ` +
84
+ `referenced by ${bucketPairs.length} @storageFiles tables (${sources}); every ` +
85
+ `buckets table must be the FK target of exactly one files table.`);
86
+ }
87
+ }
88
+ const pairedBuckets = new Set(pairs.map((pair) => pair.bucketsCodec));
89
+ const orphanBuckets = bucketsCodecs.filter((codec) => !pairedBuckets.has(codec));
90
+ if (orphanBuckets.length > 0) {
91
+ const names = orphanBuckets.map(codecSqlName).join(', ');
92
+ throw new Error(`STORAGE_PLANE_UNPAIRED: @storageBuckets table(s) ${names} are referenced by no ` +
93
+ `@storageFiles table. Every buckets table must be the FK target of exactly one ` +
94
+ `files table; check the storage module's generated FK and the @storageFiles ` +
95
+ `smart tag on the files table.`);
96
+ }
97
+ return pairs;
98
+ }
package/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { StorageInflection, UploadSurfaceNames } from './naming';
2
+ export { DOWNLOAD_URL_FIELD, uploadSurfaceNames } from './naming';
3
+ export type { StorageCodec, StorageCodecAttributes, StorageCodecRelation, StoragePgRegistry, StoragePlanePair, } from './pairing';
4
+ export { discoverStoragePlanes, pairStoragePlane } from './pairing';
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pairStoragePlane = exports.discoverStoragePlanes = exports.uploadSurfaceNames = exports.DOWNLOAD_URL_FIELD = void 0;
4
+ var naming_1 = require("./naming");
5
+ Object.defineProperty(exports, "DOWNLOAD_URL_FIELD", { enumerable: true, get: function () { return naming_1.DOWNLOAD_URL_FIELD; } });
6
+ Object.defineProperty(exports, "uploadSurfaceNames", { enumerable: true, get: function () { return naming_1.uploadSurfaceNames; } });
7
+ var pairing_1 = require("./pairing");
8
+ Object.defineProperty(exports, "discoverStoragePlanes", { enumerable: true, get: function () { return pairing_1.discoverStoragePlanes; } });
9
+ Object.defineProperty(exports, "pairStoragePlane", { enumerable: true, get: function () { return pairing_1.pairStoragePlane; } });
package/naming.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Canonical names of a storage plane's upload surface.
3
+ *
4
+ * The presigned-url plugin emits these fields and types; anything that reports
5
+ * or consumes the surface (the `_meta` storage section, dynamic clients) must
6
+ * derive the same names from the same inflection facts. This module is the one
7
+ * definition of that derivation, so the emitter and the reporters cannot
8
+ * disagree.
9
+ */
10
+ import type { StorageCodec } from './pairing';
11
+ /**
12
+ * The computed field the download plugin adds to every files type. One
13
+ * definition so the emitter (download-url-field.ts) and the reporters
14
+ * (`_meta`, dynamic clients) cannot disagree.
15
+ */
16
+ export declare const DOWNLOAD_URL_FIELD = "downloadUrl";
17
+ /** The slice of `build.inflection` the naming derivation reads. */
18
+ export interface StorageInflection {
19
+ tableType(codec: StorageCodec): string;
20
+ }
21
+ /** The GraphQL names of one plane's upload surface. */
22
+ export interface UploadSurfaceNames {
23
+ /** GraphQL type name of the files table (e.g. `AppFile`). */
24
+ filesTypeName: string;
25
+ /** Single upload mutation field (e.g. `uploadAppFile`). */
26
+ uploadMutation: string;
27
+ /** Input type of the single upload mutation (e.g. `UploadAppFileInput`). */
28
+ uploadInputType: string;
29
+ /** Payload type of the single upload mutation (e.g. `UploadAppFilePayload`). */
30
+ uploadPayloadType: string;
31
+ /** Bulk upload mutation field (e.g. `uploadAppFiles`). */
32
+ bulkUploadMutation: string;
33
+ /** Input type of the bulk upload mutation (e.g. `UploadAppFileBulkInput`). */
34
+ bulkUploadInputType: string;
35
+ /** Payload type of the bulk upload mutation (e.g. `UploadAppFileBulkPayload`). */
36
+ bulkUploadPayloadType: string;
37
+ /** Per-file input type inside the bulk input (e.g. `UploadAppFileBulkFileInput`). */
38
+ bulkUploadFileInputType: string;
39
+ /** Per-file payload type inside the bulk payload (e.g. `UploadAppFileBulkFilePayload`). */
40
+ bulkUploadFilePayloadType: string;
41
+ }
42
+ /**
43
+ * Derive the upload surface names for a plane's files codec.
44
+ */
45
+ export declare function uploadSurfaceNames(inflection: StorageInflection, filesCodec: StorageCodec): UploadSurfaceNames;
package/naming.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ /**
3
+ * Canonical names of a storage plane's upload surface.
4
+ *
5
+ * The presigned-url plugin emits these fields and types; anything that reports
6
+ * or consumes the surface (the `_meta` storage section, dynamic clients) must
7
+ * derive the same names from the same inflection facts. This module is the one
8
+ * definition of that derivation, so the emitter and the reporters cannot
9
+ * disagree.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.DOWNLOAD_URL_FIELD = void 0;
13
+ exports.uploadSurfaceNames = uploadSurfaceNames;
14
+ /**
15
+ * The computed field the download plugin adds to every files type. One
16
+ * definition so the emitter (download-url-field.ts) and the reporters
17
+ * (`_meta`, dynamic clients) cannot disagree.
18
+ */
19
+ exports.DOWNLOAD_URL_FIELD = 'downloadUrl';
20
+ /**
21
+ * Derive the upload surface names for a plane's files codec.
22
+ */
23
+ function uploadSurfaceNames(inflection, filesCodec) {
24
+ const filesTypeName = inflection.tableType(filesCodec);
25
+ return {
26
+ filesTypeName,
27
+ uploadMutation: `upload${filesTypeName}`,
28
+ uploadInputType: `Upload${filesTypeName}Input`,
29
+ uploadPayloadType: `Upload${filesTypeName}Payload`,
30
+ bulkUploadMutation: `upload${filesTypeName}s`,
31
+ bulkUploadInputType: `Upload${filesTypeName}BulkInput`,
32
+ bulkUploadPayloadType: `Upload${filesTypeName}BulkPayload`,
33
+ bulkUploadFileInputType: `Upload${filesTypeName}BulkFileInput`,
34
+ bulkUploadFilePayloadType: `Upload${filesTypeName}BulkFilePayload`,
35
+ };
36
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "graphile-storage-registry",
3
+ "version": "0.1.0",
4
+ "description": "Registry-derived storage plane discovery for PostGraphile v5 — pairs @storageFiles/@storageBuckets codecs through their actual foreign-key relations and derives the canonical upload surface names, so no table name is ever load-bearing",
5
+ "author": "Constructive <developers@constructive.io>",
6
+ "homepage": "https://github.com/constructive-io/constructive",
7
+ "license": "MIT",
8
+ "main": "index.js",
9
+ "module": "esm/index.js",
10
+ "types": "index.d.ts",
11
+ "scripts": {
12
+ "clean": "makage clean",
13
+ "prepack": "npm run build",
14
+ "build": "makage build",
15
+ "build:dev": "makage build --dev",
16
+ "lint": "eslint . --fix",
17
+ "test": "jest",
18
+ "test:watch": "jest --watch"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public",
22
+ "directory": "dist"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/constructive-io/constructive"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/constructive-io/constructive/issues"
30
+ },
31
+ "devDependencies": {
32
+ "@types/node": "^22.19.11",
33
+ "makage": "^0.3.0"
34
+ },
35
+ "keywords": [
36
+ "postgraphile",
37
+ "graphile",
38
+ "constructive",
39
+ "storage",
40
+ "registry",
41
+ "plugin",
42
+ "postgres",
43
+ "graphql"
44
+ ],
45
+ "gitHead": "d10fe91ede80a4c2dfe2c1c6dcf6fe7193784eba"
46
+ }
package/pairing.d.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Registry-derived storage plane discovery.
3
+ *
4
+ * A storage plane is a `@storageFiles` table and the `@storageBuckets` table it
5
+ * writes into. The pairing fact is the files table's foreign key to the buckets
6
+ * table — a relation the Graphile registry already carries — so it is read from
7
+ * `pgRegistry.pgRelations`, never derived from table names. Physical naming
8
+ * (`app_files`/`app_buckets` vs unprefixed `files`/`buckets`) is a rendering
9
+ * choice and carries no meaning here.
10
+ *
11
+ * A `@storageFiles` codec that cannot be paired through a real FK relation is a
12
+ * provisioning bug, and discovery throws at schema build rather than silently
13
+ * emitting a schema with no upload surface for that plane.
14
+ */
15
+ /** The attributes shape shared by pg codecs. */
16
+ export interface StorageCodecAttributes {
17
+ [attributeName: string]: unknown;
18
+ }
19
+ /**
20
+ * The slice of a Graphile pg codec that storage discovery reads. Structural on
21
+ * purpose: callers pass real codecs from `build.input.pgRegistry`, and this
22
+ * package stays free of graphile-build peer dependencies.
23
+ */
24
+ export interface StorageCodec {
25
+ name: string;
26
+ attributes?: StorageCodecAttributes;
27
+ extensions?: {
28
+ pg?: {
29
+ schemaName?: string;
30
+ name?: string;
31
+ };
32
+ tags?: {
33
+ storageFiles?: unknown;
34
+ storageBuckets?: unknown;
35
+ [tag: string]: unknown;
36
+ };
37
+ };
38
+ }
39
+ /** The slice of a registry relation entry that storage discovery reads. */
40
+ export interface StorageCodecRelation {
41
+ isReferencee?: boolean;
42
+ localAttributes: readonly string[];
43
+ remoteAttributes: readonly string[];
44
+ remoteResource?: {
45
+ codec?: StorageCodec;
46
+ };
47
+ }
48
+ /** The slice of `build.input.pgRegistry` that storage discovery reads. */
49
+ export interface StoragePgRegistry {
50
+ pgCodecs: Record<string, StorageCodec>;
51
+ pgRelations: Record<string, Record<string, StorageCodecRelation>>;
52
+ }
53
+ /**
54
+ * A discovered storage plane: the files codec, the buckets codec its FK names,
55
+ * and the facts a plugin needs to shape the plane's GraphQL surface.
56
+ */
57
+ export interface StoragePlanePair {
58
+ filesCodec: StorageCodec;
59
+ bucketsCodec: StorageCodec;
60
+ /** Registry name of the FK relation on the files codec (e.g. `bucketsByMyBucketId`). */
61
+ relationName: string;
62
+ /** FK attribute(s) on the files table referencing the buckets table (e.g. `['bucket_id']`). */
63
+ fkAttributes: string[];
64
+ /**
65
+ * Whether the plane is entity-keyed: the buckets table carries an `owner_id`
66
+ * attribute (a registry fact — never inferred from the plane's scope name).
67
+ */
68
+ hasOwnerId: boolean;
69
+ }
70
+ /**
71
+ * Pair one `@storageFiles` codec with its `@storageBuckets` codec through the
72
+ * registry's FK relations.
73
+ *
74
+ * Reads the codec's forward relations (`isReferencee` false — the files table
75
+ * holds the FK) and keeps those whose remote codec is tagged `@storageBuckets`.
76
+ * Exactly one such relation must exist; zero or several is a malformed plane
77
+ * and throws.
78
+ */
79
+ export declare function pairStoragePlane(filesCodec: StorageCodec, pgRegistry: StoragePgRegistry): StoragePlanePair;
80
+ /**
81
+ * Discover every storage plane in the registry.
82
+ *
83
+ * Pairs each `@storageFiles` codec through {@link pairStoragePlane}, then
84
+ * verifies no `@storageBuckets` codec was left unpaired — a buckets table with
85
+ * no files table referencing it is the same provisioning bug from the other
86
+ * side, and throws rather than being silently ignored.
87
+ */
88
+ export declare function discoverStoragePlanes(pgRegistry: StoragePgRegistry): StoragePlanePair[];
package/pairing.js ADDED
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ /**
3
+ * Registry-derived storage plane discovery.
4
+ *
5
+ * A storage plane is a `@storageFiles` table and the `@storageBuckets` table it
6
+ * writes into. The pairing fact is the files table's foreign key to the buckets
7
+ * table — a relation the Graphile registry already carries — so it is read from
8
+ * `pgRegistry.pgRelations`, never derived from table names. Physical naming
9
+ * (`app_files`/`app_buckets` vs unprefixed `files`/`buckets`) is a rendering
10
+ * choice and carries no meaning here.
11
+ *
12
+ * A `@storageFiles` codec that cannot be paired through a real FK relation is a
13
+ * provisioning bug, and discovery throws at schema build rather than silently
14
+ * emitting a schema with no upload surface for that plane.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.pairStoragePlane = pairStoragePlane;
18
+ exports.discoverStoragePlanes = discoverStoragePlanes;
19
+ function hasTag(codec, tag) {
20
+ return !!codec.attributes && !!codec.extensions?.tags?.[tag];
21
+ }
22
+ function codecSqlName(codec) {
23
+ const schema = codec.extensions?.pg?.schemaName;
24
+ const table = codec.extensions?.pg?.name ?? codec.name;
25
+ return schema ? `${schema}.${table}` : table;
26
+ }
27
+ /**
28
+ * Pair one `@storageFiles` codec with its `@storageBuckets` codec through the
29
+ * registry's FK relations.
30
+ *
31
+ * Reads the codec's forward relations (`isReferencee` false — the files table
32
+ * holds the FK) and keeps those whose remote codec is tagged `@storageBuckets`.
33
+ * Exactly one such relation must exist; zero or several is a malformed plane
34
+ * and throws.
35
+ */
36
+ function pairStoragePlane(filesCodec, pgRegistry) {
37
+ const relations = pgRegistry.pgRelations[filesCodec.name] ?? {};
38
+ const bucketRelations = Object.entries(relations).filter(([, relation]) => !relation.isReferencee &&
39
+ relation.remoteResource?.codec &&
40
+ hasTag(relation.remoteResource.codec, 'storageBuckets'));
41
+ if (bucketRelations.length === 0) {
42
+ throw new Error(`STORAGE_PLANE_UNPAIRED: @storageFiles table ${codecSqlName(filesCodec)} has no ` +
43
+ `foreign key to a @storageBuckets table in the registry. A files table must ` +
44
+ `reference its buckets table; check the storage module's generated FK and the ` +
45
+ `@storageBuckets smart tag on the buckets table.`);
46
+ }
47
+ if (bucketRelations.length > 1) {
48
+ const targets = bucketRelations
49
+ .map(([, relation]) => codecSqlName(relation.remoteResource.codec))
50
+ .join(', ');
51
+ throw new Error(`STORAGE_PLANE_AMBIGUOUS: @storageFiles table ${codecSqlName(filesCodec)} has ` +
52
+ `foreign keys to ${bucketRelations.length} @storageBuckets tables (${targets}); ` +
53
+ `a files table must reference exactly one buckets table.`);
54
+ }
55
+ const [relationName, relation] = bucketRelations[0];
56
+ const bucketsCodec = relation.remoteResource.codec;
57
+ return {
58
+ filesCodec,
59
+ bucketsCodec,
60
+ relationName,
61
+ fkAttributes: [...relation.localAttributes],
62
+ hasOwnerId: !!bucketsCodec.attributes?.owner_id,
63
+ };
64
+ }
65
+ /**
66
+ * Discover every storage plane in the registry.
67
+ *
68
+ * Pairs each `@storageFiles` codec through {@link pairStoragePlane}, then
69
+ * verifies no `@storageBuckets` codec was left unpaired — a buckets table with
70
+ * no files table referencing it is the same provisioning bug from the other
71
+ * side, and throws rather than being silently ignored.
72
+ */
73
+ function discoverStoragePlanes(pgRegistry) {
74
+ const codecs = Object.values(pgRegistry.pgCodecs);
75
+ const filesCodecs = codecs.filter((codec) => hasTag(codec, 'storageFiles'));
76
+ const bucketsCodecs = codecs.filter((codec) => hasTag(codec, 'storageBuckets'));
77
+ const pairs = filesCodecs.map((filesCodec) => pairStoragePlane(filesCodec, pgRegistry));
78
+ const bucketsPairCounts = new Map();
79
+ for (const pair of pairs) {
80
+ const existing = bucketsPairCounts.get(pair.bucketsCodec) ?? [];
81
+ existing.push(pair);
82
+ bucketsPairCounts.set(pair.bucketsCodec, existing);
83
+ }
84
+ for (const [bucketsCodec, bucketPairs] of bucketsPairCounts) {
85
+ if (bucketPairs.length > 1) {
86
+ const sources = bucketPairs.map((pair) => codecSqlName(pair.filesCodec)).join(', ');
87
+ throw new Error(`STORAGE_PLANE_AMBIGUOUS: @storageBuckets table ${codecSqlName(bucketsCodec)} is ` +
88
+ `referenced by ${bucketPairs.length} @storageFiles tables (${sources}); every ` +
89
+ `buckets table must be the FK target of exactly one files table.`);
90
+ }
91
+ }
92
+ const pairedBuckets = new Set(pairs.map((pair) => pair.bucketsCodec));
93
+ const orphanBuckets = bucketsCodecs.filter((codec) => !pairedBuckets.has(codec));
94
+ if (orphanBuckets.length > 0) {
95
+ const names = orphanBuckets.map(codecSqlName).join(', ');
96
+ throw new Error(`STORAGE_PLANE_UNPAIRED: @storageBuckets table(s) ${names} are referenced by no ` +
97
+ `@storageFiles table. Every buckets table must be the FK target of exactly one ` +
98
+ `files table; check the storage module's generated FK and the @storageFiles ` +
99
+ `smart tag on the files table.`);
100
+ }
101
+ return pairs;
102
+ }