@ogygie/core 0.6.0 → 0.8.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/package.json +1 -1
- package/src/manifest.schema.test.ts +42 -0
- package/src/manifest.schema.ts +68 -1
- package/src/shared-tables.test.ts +23 -9
- package/src/shared-tables.ts +12 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ogygie/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Ogygie framework core: manifest/spec/error schemas, generic tenancy and gate tooling. Ships TypeScript sources — Ogygie products run tsx/vitest by construction (D1).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -456,3 +456,45 @@ describe("manifest — packages du produit (spec manifest-packages)", () => {
|
|
|
456
456
|
expect(parsed.success).toBe(false);
|
|
457
457
|
});
|
|
458
458
|
});
|
|
459
|
+
|
|
460
|
+
describe("manifest — bloc tenancy (spec ogygie-standalone-repo v3)", () => {
|
|
461
|
+
const socle = featureNamed("espaces-et-comptes");
|
|
462
|
+
|
|
463
|
+
it("un bloc tenancy valide est accepté, rawDbAllowed a un défaut vide", () => {
|
|
464
|
+
const parsed = OgygieManifestSchema.safeParse(
|
|
465
|
+
manifestWithPackages(undefined, [socle]),
|
|
466
|
+
);
|
|
467
|
+
expect(parsed.success).toBe(true);
|
|
468
|
+
const withTenancy = OgygieManifestSchema.safeParse({
|
|
469
|
+
...manifestWithPackages(undefined, [socle]),
|
|
470
|
+
tenancy: { column: "workspace_id" },
|
|
471
|
+
});
|
|
472
|
+
expect(withTenancy.success && withTenancy.data.tenancy?.rawDbAllowed).toEqual([]);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("le socle peut nommer les features autorisées à la base brute", () => {
|
|
476
|
+
const parsed = OgygieManifestSchema.safeParse({
|
|
477
|
+
...manifestWithPackages(undefined, [socle]),
|
|
478
|
+
tenancy: { column: "workspace_id", rawDbAllowed: ["espaces-et-comptes"] },
|
|
479
|
+
});
|
|
480
|
+
expect(parsed.success).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("MUTATION : un socle nommant une feature INEXISTANTE est refusé", () => {
|
|
484
|
+
const parsed = OgygieManifestSchema.safeParse({
|
|
485
|
+
...manifestWithPackages(undefined, [socle]),
|
|
486
|
+
tenancy: { column: "workspace_id", rawDbAllowed: ["fantome"] },
|
|
487
|
+
});
|
|
488
|
+
expect(parsed.success).toBe(false);
|
|
489
|
+
const issue = parsed.success ? undefined : parsed.error.issues[0];
|
|
490
|
+
expect(issue?.message).toContain("matches no declared feature");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("MUTATION : une colonne tenant vide est refusée — elle ne garderait rien", () => {
|
|
494
|
+
const parsed = OgygieManifestSchema.safeParse({
|
|
495
|
+
...manifestWithPackages(undefined, [socle]),
|
|
496
|
+
tenancy: { column: "" },
|
|
497
|
+
});
|
|
498
|
+
expect(parsed.success).toBe(false);
|
|
499
|
+
});
|
|
500
|
+
});
|
package/src/manifest.schema.ts
CHANGED
|
@@ -64,9 +64,33 @@ export const HttpRouteSchema = z.object({
|
|
|
64
64
|
* `shared` is a literal `true`: a table is either shared (and says so) or
|
|
65
65
|
* tenant-scoped (and stays a plain string) — no `shared: false` third state.
|
|
66
66
|
*/
|
|
67
|
+
/**
|
|
68
|
+
* WHICH hole in the tenancy rail, because the two do not carry the same risk
|
|
69
|
+
* (D8):
|
|
70
|
+
* - `reference` — universal knowledge loaded by an extraction script, READ
|
|
71
|
+
* ONLY for the API. What an ABSENT key means, which is also what
|
|
72
|
+
* `shared: true` historically meant — read it through `sharedKindOf()`
|
|
73
|
+
* rather than defaulting in the schema, so the manifest a project wrote is
|
|
74
|
+
* the manifest an agent reads.
|
|
75
|
+
* - `ownerless` — data the API itself produces, owned by nobody because it is
|
|
76
|
+
* born before any account. Its feature WRITES it, and since no tenant guards
|
|
77
|
+
* it, an unguessable secret must: its rows are read by token, never by an
|
|
78
|
+
* enumerable id. No gate can check that, so the feature's spec must say
|
|
79
|
+
* where the secret lives.
|
|
80
|
+
*
|
|
81
|
+
* A security fact belongs on the map, not in a comment — hence a declared key
|
|
82
|
+
* rather than a convention.
|
|
83
|
+
*/
|
|
84
|
+
export const SharedTableKindSchema = z.enum(["reference", "ownerless"]);
|
|
85
|
+
export type SharedTableKind = z.infer<typeof SharedTableKindSchema>;
|
|
86
|
+
|
|
67
87
|
export const DbTableDeclarationSchema = z.union([
|
|
68
88
|
z.string(),
|
|
69
|
-
z.object({
|
|
89
|
+
z.object({
|
|
90
|
+
name: z.string(),
|
|
91
|
+
shared: z.literal(true),
|
|
92
|
+
sharedKind: SharedTableKindSchema.optional(),
|
|
93
|
+
}),
|
|
70
94
|
]);
|
|
71
95
|
export type DbTableDeclaration = z.infer<typeof DbTableDeclarationSchema>;
|
|
72
96
|
|
|
@@ -183,6 +207,34 @@ export const PackageManifestSchema = z.object({
|
|
|
183
207
|
});
|
|
184
208
|
export type PackageManifest = z.infer<typeof PackageManifestSchema>;
|
|
185
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The two product values the framework's own gates need to do their job.
|
|
212
|
+
*
|
|
213
|
+
* They used to live as constants INSIDE the gate scripts, which is what kept
|
|
214
|
+
* those scripts out of governance: a governed file is identical in every
|
|
215
|
+
* Ogygie project, and these are not. Declared here, the scripts become
|
|
216
|
+
* identical everywhere and can finally be governed — so a corrected gate
|
|
217
|
+
* reaches every project through `ogygie upgrade` instead of waiting for each
|
|
218
|
+
* one to recopy it (spec ogygie-standalone-repo v3).
|
|
219
|
+
*
|
|
220
|
+
* Absent, the checks that depend on them simply do not run: a project without
|
|
221
|
+
* tenancy has nothing to guard, and saying so explicitly beats guarding
|
|
222
|
+
* nothing while pretending otherwise.
|
|
223
|
+
*/
|
|
224
|
+
export const TenancyManifestSchema = z.object({
|
|
225
|
+
column: z
|
|
226
|
+
.string()
|
|
227
|
+
.min(1)
|
|
228
|
+
.describe("SQL name of the tenant column, e.g. workspace_id — the one AGENTS.product.md announces"),
|
|
229
|
+
rawDbAllowed: z
|
|
230
|
+
.array(FeatureNameSchema)
|
|
231
|
+
.default([])
|
|
232
|
+
.describe(
|
|
233
|
+
"Features allowed to reach the raw database: the tenancy socle, which BUILDS the contexts and therefore predates them. Every other feature goes through tenantDb",
|
|
234
|
+
),
|
|
235
|
+
});
|
|
236
|
+
export type TenancyManifest = z.infer<typeof TenancyManifestSchema>;
|
|
237
|
+
|
|
186
238
|
export const OgygieManifestSchema = z.object({
|
|
187
239
|
version: z.literal(1),
|
|
188
240
|
product: z.object({
|
|
@@ -213,6 +265,9 @@ export const OgygieManifestSchema = z.object({
|
|
|
213
265
|
}),
|
|
214
266
|
),
|
|
215
267
|
features: z.array(FeatureManifestSchema),
|
|
268
|
+
tenancy: TenancyManifestSchema.optional().describe(
|
|
269
|
+
"Product values the framework gates need — absent, the tenancy checks do not run",
|
|
270
|
+
),
|
|
216
271
|
packages: z
|
|
217
272
|
.array(PackageManifestSchema)
|
|
218
273
|
.optional()
|
|
@@ -280,6 +335,18 @@ export const OgygieManifestSchema = z.object({
|
|
|
280
335
|
checkPackageDependencies(packageEntry.dependsOn, ["packages", index, "dependsOn"]);
|
|
281
336
|
});
|
|
282
337
|
|
|
338
|
+
// A socle that names no existing feature guards nothing, and nobody would
|
|
339
|
+
// notice: the lint would simply allow one fewer folder.
|
|
340
|
+
(manifest.tenancy?.rawDbAllowed ?? []).forEach((featureName, index) => {
|
|
341
|
+
if (!featureNames.has(featureName)) {
|
|
342
|
+
context.addIssue({
|
|
343
|
+
code: "custom",
|
|
344
|
+
path: ["tenancy", "rawDbAllowed", index],
|
|
345
|
+
message: `"${featureName}" matches no declared feature`,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
283
350
|
// Vocabulary coherence (B2): one concept = one term. A canonical declared
|
|
284
351
|
// twice, an alias claimed by two entries, or an alias that IS someone's
|
|
285
352
|
// canonical would make the gate's suggested fix ambiguous — refused here,
|
|
@@ -7,15 +7,7 @@
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
import type { FeatureManifest } from "./manifest.schema.js";
|
|
9
9
|
import { FeatureManifestSchema } from "./manifest.schema.js";
|
|
10
|
-
import {
|
|
11
|
-
dbTableNamesOf,
|
|
12
|
-
findTenantColumnsInSharedTables,
|
|
13
|
-
mayUseRawDb,
|
|
14
|
-
ownsOnlySharedTables,
|
|
15
|
-
sharedTableNamesOf,
|
|
16
|
-
splitDbSchemaByTable,
|
|
17
|
-
tenantTableNamesOf,
|
|
18
|
-
} from "./shared-tables.js";
|
|
10
|
+
import { dbTableNamesOf, findTenantColumnsInSharedTables, mayUseRawDb, ownsOnlySharedTables, sharedKindOf, sharedTableNamesOf, splitDbSchemaByTable, tenantTableNamesOf } from "./shared-tables.js";
|
|
19
11
|
|
|
20
12
|
const SOCLE = new Set(["accounts"]);
|
|
21
13
|
|
|
@@ -134,3 +126,25 @@ export const labels = pgTable("labels", {
|
|
|
134
126
|
).toEqual([{ table: "labels", tenantColumn: "org_id" }]);
|
|
135
127
|
});
|
|
136
128
|
});
|
|
129
|
+
|
|
130
|
+
describe("sharedKindOf — quelle brèche dans le rail tenant (D8)", () => {
|
|
131
|
+
it("une table tenant n'a AUCUNE nature : ce n'est pas une brèche", () => {
|
|
132
|
+
expect(sharedKindOf("orders")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("une table partagée sans `sharedKind` vaut `reference` — le sens historique", () => {
|
|
136
|
+
expect(sharedKindOf({ name: "currency_rates", shared: true })).toBe("reference");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("une table `ownerless` est rendue telle quelle : sa feature l'écrit", () => {
|
|
140
|
+
expect(sharedKindOf({ name: "public_drafts", shared: true, sharedKind: "ownerless" })).toBe(
|
|
141
|
+
"ownerless",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("une table `reference` explicite reste `reference`", () => {
|
|
146
|
+
expect(sharedKindOf({ name: "postal_codes", shared: true, sharedKind: "reference" })).toBe(
|
|
147
|
+
"reference",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/shared-tables.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* they return. The module is GENERIC infrastructure (D10 amended): it speaks
|
|
20
20
|
* "tenant", never a word of the product's domain.
|
|
21
21
|
*/
|
|
22
|
-
import type { DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
|
|
22
|
+
import type { SharedTableKind, DbTableDeclaration, FeatureManifest } from "./manifest.schema.js";
|
|
23
23
|
|
|
24
24
|
/** Name of a table however it was declared (bare string or object form). */
|
|
25
25
|
export function dbTableNameOf(declaration: DbTableDeclaration): string {
|
|
@@ -111,3 +111,14 @@ export function findTenantColumnsInSharedTables(
|
|
|
111
111
|
}
|
|
112
112
|
return findings;
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The nature of a shared table: what the declaration says, or `reference` when
|
|
117
|
+
* it says nothing — the historical meaning of `shared: true`, kept so older
|
|
118
|
+
* manifests keep meaning what they meant (AGENTS.md §Multi-tenant rule 4).
|
|
119
|
+
* A tenant-scoped table has no nature: it is not a hole in the rail.
|
|
120
|
+
*/
|
|
121
|
+
export function sharedKindOf(declaration: DbTableDeclaration): SharedTableKind | null {
|
|
122
|
+
if (typeof declaration === "string") return null;
|
|
123
|
+
return declaration.sharedKind ?? "reference";
|
|
124
|
+
}
|