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 +23 -0
- package/README.md +108 -0
- package/esm/index.d.ts +4 -0
- package/esm/index.js +2 -0
- package/esm/naming.d.ts +45 -0
- package/esm/naming.js +32 -0
- package/esm/pairing.d.ts +88 -0
- package/esm/pairing.js +98 -0
- package/index.d.ts +4 -0
- package/index.js +9 -0
- package/naming.d.ts +45 -0
- package/naming.js +36 -0
- package/package.json +46 -0
- package/pairing.d.ts +88 -0
- package/pairing.js +102 -0
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
package/esm/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/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
|
+
}
|
package/esm/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/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
|
+
}
|