@x12i/catalox 1.2.0 → 2.0.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.
Files changed (125) hide show
  1. package/README.md +424 -406
  2. package/dist/src/catalox/catalox.d.ts +35 -1
  3. package/dist/src/catalox/catalox.d.ts.map +1 -1
  4. package/dist/src/catalox/catalox.js +460 -5
  5. package/dist/src/catalox/catalox.js.map +1 -1
  6. package/dist/src/catalox/field-source-resolution.d.ts +16 -0
  7. package/dist/src/catalox/field-source-resolution.d.ts.map +1 -0
  8. package/dist/src/catalox/field-source-resolution.js +43 -0
  9. package/dist/src/catalox/field-source-resolution.js.map +1 -0
  10. package/dist/src/catalox/reporting/render-inventory-report.d.ts +3 -0
  11. package/dist/src/catalox/reporting/render-inventory-report.d.ts.map +1 -0
  12. package/dist/src/catalox/reporting/render-inventory-report.js +78 -0
  13. package/dist/src/catalox/reporting/render-inventory-report.js.map +1 -0
  14. package/dist/src/cli/index.d.ts +3 -0
  15. package/dist/src/cli/index.d.ts.map +1 -0
  16. package/dist/src/cli/index.js +267 -0
  17. package/dist/src/cli/index.js.map +1 -0
  18. package/dist/src/contracts/context.d.ts +7 -1
  19. package/dist/src/contracts/context.d.ts.map +1 -1
  20. package/dist/src/contracts/descriptors.d.ts +6 -0
  21. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  22. package/dist/src/contracts/diagram-map.d.ts +24 -0
  23. package/dist/src/contracts/diagram-map.d.ts.map +1 -0
  24. package/dist/src/contracts/diagram-map.js +2 -0
  25. package/dist/src/contracts/diagram-map.js.map +1 -0
  26. package/dist/src/contracts/field-source.d.ts +43 -0
  27. package/dist/src/contracts/field-source.d.ts.map +1 -0
  28. package/dist/src/contracts/field-source.js +2 -0
  29. package/dist/src/contracts/field-source.js.map +1 -0
  30. package/dist/src/contracts/filters.d.ts +59 -0
  31. package/dist/src/contracts/filters.d.ts.map +1 -0
  32. package/dist/src/contracts/filters.js +2 -0
  33. package/dist/src/contracts/filters.js.map +1 -0
  34. package/dist/src/contracts/ids.d.ts +1 -0
  35. package/dist/src/contracts/ids.d.ts.map +1 -1
  36. package/dist/src/contracts/index.d.ts +11 -1
  37. package/dist/src/contracts/index.d.ts.map +1 -1
  38. package/dist/src/contracts/index.js.map +1 -1
  39. package/dist/src/contracts/inventory-export.d.ts +40 -0
  40. package/dist/src/contracts/inventory-export.d.ts.map +1 -0
  41. package/dist/src/contracts/inventory-export.js +2 -0
  42. package/dist/src/contracts/inventory-export.js.map +1 -0
  43. package/dist/src/contracts/inventory-report.d.ts +61 -0
  44. package/dist/src/contracts/inventory-report.d.ts.map +1 -0
  45. package/dist/src/contracts/inventory-report.js +2 -0
  46. package/dist/src/contracts/inventory-report.js.map +1 -0
  47. package/dist/src/contracts/items.d.ts +3 -0
  48. package/dist/src/contracts/items.d.ts.map +1 -1
  49. package/dist/src/contracts/markdown-map.d.ts +42 -0
  50. package/dist/src/contracts/markdown-map.d.ts.map +1 -0
  51. package/dist/src/contracts/markdown-map.js +2 -0
  52. package/dist/src/contracts/markdown-map.js.map +1 -0
  53. package/dist/src/contracts/presentation.d.ts +149 -0
  54. package/dist/src/contracts/presentation.d.ts.map +1 -0
  55. package/dist/src/contracts/presentation.js +2 -0
  56. package/dist/src/contracts/presentation.js.map +1 -0
  57. package/dist/src/contracts/render-map.d.ts +135 -0
  58. package/dist/src/contracts/render-map.d.ts.map +1 -0
  59. package/dist/src/contracts/render-map.js +2 -0
  60. package/dist/src/contracts/render-map.js.map +1 -0
  61. package/dist/src/contracts/snapshots.d.ts +12 -0
  62. package/dist/src/contracts/snapshots.d.ts.map +1 -0
  63. package/dist/src/contracts/snapshots.js +2 -0
  64. package/dist/src/contracts/snapshots.js.map +1 -0
  65. package/dist/src/contracts/stores.d.ts +21 -0
  66. package/dist/src/contracts/stores.d.ts.map +1 -0
  67. package/dist/src/contracts/stores.js +2 -0
  68. package/dist/src/contracts/stores.js.map +1 -0
  69. package/dist/src/diagrams/render-catalog-diagram.d.ts +3 -0
  70. package/dist/src/diagrams/render-catalog-diagram.d.ts.map +1 -0
  71. package/dist/src/diagrams/render-catalog-diagram.js +27 -0
  72. package/dist/src/diagrams/render-catalog-diagram.js.map +1 -0
  73. package/dist/src/diagrams/render-item-diagram.d.ts +3 -0
  74. package/dist/src/diagrams/render-item-diagram.d.ts.map +1 -0
  75. package/dist/src/diagrams/render-item-diagram.js +26 -0
  76. package/dist/src/diagrams/render-item-diagram.js.map +1 -0
  77. package/dist/src/firebase/index.d.ts +1 -0
  78. package/dist/src/firebase/index.d.ts.map +1 -1
  79. package/dist/src/firebase/index.js +1 -0
  80. package/dist/src/firebase/index.js.map +1 -1
  81. package/dist/src/firebase/snapshot-store.d.ts +7 -0
  82. package/dist/src/firebase/snapshot-store.d.ts.map +1 -1
  83. package/dist/src/firebase/snapshot-store.js +30 -0
  84. package/dist/src/firebase/snapshot-store.js.map +1 -1
  85. package/dist/src/firebase/store-app-binding-store.d.ts +14 -0
  86. package/dist/src/firebase/store-app-binding-store.d.ts.map +1 -0
  87. package/dist/src/firebase/store-app-binding-store.js +36 -0
  88. package/dist/src/firebase/store-app-binding-store.js.map +1 -0
  89. package/dist/src/index.d.ts +4 -0
  90. package/dist/src/index.d.ts.map +1 -1
  91. package/dist/src/index.js +4 -0
  92. package/dist/src/index.js.map +1 -1
  93. package/dist/src/markdown/render-item-markdown.d.ts +5 -0
  94. package/dist/src/markdown/render-item-markdown.d.ts.map +1 -0
  95. package/dist/src/markdown/render-item-markdown.js +83 -0
  96. package/dist/src/markdown/render-item-markdown.js.map +1 -0
  97. package/dist/src/markdown/render-list-markdown.d.ts +5 -0
  98. package/dist/src/markdown/render-list-markdown.d.ts.map +1 -0
  99. package/dist/src/markdown/render-list-markdown.js +90 -0
  100. package/dist/src/markdown/render-list-markdown.js.map +1 -0
  101. package/dist/src/validation/index.d.ts +3 -1
  102. package/dist/src/validation/index.d.ts.map +1 -1
  103. package/dist/src/validation/index.js +2 -2
  104. package/dist/src/validation/index.js.map +1 -1
  105. package/dist/src/validation/ui-spec-schema.d.ts +19 -0
  106. package/dist/src/validation/ui-spec-schema.d.ts.map +1 -0
  107. package/dist/src/validation/ui-spec-schema.js +547 -0
  108. package/dist/src/validation/ui-spec-schema.js.map +1 -0
  109. package/dist/src/validation/ui-spec-validate.d.ts +19 -0
  110. package/dist/src/validation/ui-spec-validate.d.ts.map +1 -0
  111. package/dist/src/validation/ui-spec-validate.js +69 -0
  112. package/dist/src/validation/ui-spec-validate.js.map +1 -0
  113. package/dist/test/unit/field-source-resolution.test.d.ts +2 -0
  114. package/dist/test/unit/field-source-resolution.test.d.ts.map +1 -0
  115. package/dist/test/unit/field-source-resolution.test.js +45 -0
  116. package/dist/test/unit/field-source-resolution.test.js.map +1 -0
  117. package/dist/test/unit/markdown-and-diagrams.test.d.ts +2 -0
  118. package/dist/test/unit/markdown-and-diagrams.test.d.ts.map +1 -0
  119. package/dist/test/unit/markdown-and-diagrams.test.js +62 -0
  120. package/dist/test/unit/markdown-and-diagrams.test.js.map +1 -0
  121. package/dist/test/unit/ui-spec-validation.test.d.ts +2 -0
  122. package/dist/test/unit/ui-spec-validation.test.d.ts.map +1 -0
  123. package/dist/test/unit/ui-spec-validation.test.js +78 -0
  124. package/dist/test/unit/ui-spec-validation.test.js.map +1 -0
  125. package/package.json +10 -2
package/README.md CHANGED
@@ -1,406 +1,424 @@
1
- # `@x12i/catalox`
2
-
3
- Catalox is a **data-tier** package for managing **app-scoped catalogs** in Firebase **Firestore**, including:
4
-
5
- - **Catalog discovery** for an `appId` (what catalogs are available + access)
6
- - **First-class catalog descriptors** (capabilities, query metadata, identity metadata, field metadata)
7
- - **Native catalogs** (items stored in Firestore)
8
- - **Mapped catalogs** (items normalized from MongoDB or APIs)
9
- - **References + validation contracts** (standardized cross-catalog shapes)
10
- - **Seed/import/export + batch upsert** workflows for native catalogs
11
-
12
- Catalox **does not** own UI, workflow orchestration, remote execution, artifact blobs, or secret storage.
13
-
14
- ## Install
15
-
16
- This repo is currently set up as a workspace package.
17
-
18
- - **Node**: `>=20`
19
- - **TypeScript**: builds to `dist/`
20
-
21
- `@x12i/helpers` is a **required dependency** (installed as `@x12i/helpers@^1.2.0`).
22
-
23
- ## Configuration (real connections)
24
-
25
- Catalox is a library: you provide initialized clients + runtime env.
26
-
27
- ### Firestore (Firebase Admin SDK)
28
-
29
- This implementation of Catalox expects a **Firebase Admin SDK** Firestore instance (`firebase-admin`).
30
-
31
- Important clarification:
32
-
33
- - **“Admin” here means privileged access to your Firebase/GCP project’s Firestore**, using a service account.
34
- - It is **not** “admin of the host machine / server OS”.
35
- - Admin SDK access typically **bypasses Firestore Security Rules** (rules are for client SDKs).
36
-
37
- Scoping:
38
-
39
- - You can scope the service account to **Firestore-related IAM roles** and to a specific **project**.
40
- - You generally cannot scope an Admin SDK credential to only certain collections/documents via IAM the way client rules work.
41
-
42
- To connect to a real Firebase project, initialize `firebase-admin` using `GOOGLE_APPLICATION_CREDENTIALS` (service account JSON) or your preferred Admin initialization strategy.
43
-
44
- Minimal example:
45
-
46
- ```ts
47
- import { initializeApp, applicationDefault } from "firebase-admin/app";
48
- import { getFirestore } from "firebase-admin/firestore";
49
-
50
- initializeApp({ credential: applicationDefault() });
51
- const firestore = getFirestore();
52
- ```
53
-
54
- ### Mongo (mapped catalogs)
55
-
56
- For Mongo-mapped catalogs, provide:
57
-
58
- - `MONGO_URI` (or whatever you store in adapter config as `mongoUriEnvVar`)
59
-
60
- Example `.env` (do not commit secrets):
61
-
62
- ```bash
63
- MONGO_URI=mongodb://127.0.0.1:27017
64
- GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
65
- FIRESTORE_LIVE_TESTS=1
66
- ```
67
-
68
- ## Firestore data model (logical collections)
69
-
70
- Metadata:
71
-
72
- - `apps/{appId}`
73
- - `catalogs/{catalogId}`
74
- - `catalogBindings/{bindingId}` (many-to-many app↔catalog)
75
- - `catalogDefinitions/{catalogId}` (native vs mapped specifics)
76
- - `catalogAdapters/{adapterId}` (mongo/api adapter definitions)
77
- - `catalogMappings/{mappingId}` (field mapping specs)
78
- - `catalogDescriptors/{catalogId}` (**descriptor metadata** for generic consumption)
79
- - `catalogReferences/{referenceId}` (standardized reference records)
80
-
81
- Data:
82
-
83
- - `catalogData/{catalogId}/items/{itemId}` (native items)
84
- - `catalogSnapshots/{catalogId}/items/{itemId}` (mapped snapshot mode)
85
-
86
- ## Core usage (generic from `appId`)
87
-
88
- ### Create stores and `Catalox`
89
-
90
- ```ts
91
- import { FirestoreStore } from "@x12i/catalox";
92
- import { AppStore, CatalogStore, BindingStore, DefinitionStore, MappingStore, DescriptorStore, ReferenceStore, NativeItemStore, SnapshotStore, AdapterStore } from "@x12i/catalox";
93
- import { AuthorizationService, Catalox } from "@x12i/catalox";
94
-
95
- // firebase-admin initialization is up to the consumer
96
- import { getFirestore } from "firebase-admin/firestore";
97
-
98
- const firestore = getFirestore();
99
- const store = new FirestoreStore({ firestore });
100
-
101
- const deps = {
102
- apps: new AppStore(store),
103
- catalogs: new CatalogStore(store),
104
- bindings: new BindingStore(store),
105
- definitions: new DefinitionStore(store),
106
- mappings: new MappingStore(store),
107
- descriptors: new DescriptorStore(store),
108
- references: new ReferenceStore(store),
109
- nativeItems: new NativeItemStore(store),
110
- snapshots: new SnapshotStore(store),
111
- adapters: new AdapterStore(store),
112
- authz: new AuthorizationService(new BindingStore(store)),
113
- };
114
-
115
- const catalox = new Catalox(deps);
116
- ```
117
-
118
- ## Descriptor contract (planning-critical)
119
-
120
- Catalox is designed so upstream packages can be “generic” (no hardcoded catalog registrations). The stable contract is the persisted **descriptor**:
121
-
122
- - Stored at `catalogDescriptors/{catalogId}`
123
- - Retrieved via `getCatalogDescriptor(...)` or `getAppCatalogBootstrap(...)`
124
- - Used for identity, query, and client rendering metadata
125
-
126
- Below is the **actual current descriptor shape** (TypeScript), with the most planning-relevant subtypes.
127
-
128
- ```ts
129
- export type CatalogCapabilitiesDescriptor = {
130
- canList: boolean;
131
- canGet: boolean;
132
- canCreate: boolean;
133
- canEdit: boolean;
134
- canDelete: boolean;
135
- canImport?: boolean;
136
- canExport?: boolean;
137
- canSync?: boolean;
138
- canValidate?: boolean;
139
- canViewReferences?: boolean;
140
- };
141
-
142
- export type CatalogFieldDescriptor = {
143
- key: string;
144
- label: string;
145
- type:
146
- | "string"
147
- | "number"
148
- | "boolean"
149
- | "date"
150
- | "datetime"
151
- | "enum"
152
- | "object"
153
- | "array"
154
- | "reference";
155
- // Optional path into the stored item payload. If omitted, `key` is assumed.
156
- path?: string;
157
-
158
- // Query + indexing metadata.
159
- filterable?: boolean;
160
- sortable?: boolean;
161
- indexed?: boolean;
162
- multiValue?: boolean;
163
-
164
- // Presentation + contract metadata.
165
- listVisible?: boolean;
166
- detailVisible?: boolean;
167
- required?: boolean;
168
- enumValues?: Array<string | number>;
169
- reference?: { targetCatalogId?: string; targetField?: string };
170
- metadata?: Record<string, unknown>;
171
- };
172
-
173
- export type CatalogIdentityDescriptor = {
174
- itemIdStrategy: "natural" | "composite" | "generated";
175
- itemIdField?: string;
176
- compositeFields?: string[];
177
-
178
- // Optional display decoration fields.
179
- titleField?: string;
180
- subtitleField?: string;
181
- statusField?: string;
182
- updatedAtField?: string;
183
- };
184
-
185
- export type CatalogDescriptor = {
186
- catalogId: string;
187
- label: string;
188
- description?: string;
189
- itemLabel?: string;
190
- sourceMode: "native" | "mapped";
191
- mappedSourceType?: "mongo" | "api";
192
- status: "active" | "disabled" | "draft";
193
- visibility?: "visible" | "hidden";
194
- defaultSort?: { field: string; direction: "asc" | "desc" };
195
- defaultFilters?: Record<string, unknown>;
196
- capabilities: CatalogCapabilitiesDescriptor;
197
- queryableFields: CatalogFieldDescriptor[];
198
- queryCapabilities?: Record<string, unknown>;
199
- identity: CatalogIdentityDescriptor;
200
- metadata?: Record<string, unknown>;
201
- };
202
- ```
203
-
204
- ### What you need persisted for “generic consumption”
205
-
206
- To let consumers operate *purely* from `appId` with no hardcoded catalog registrations, Catalox relies on these being present in Firestore:
207
-
208
- - **Bindings**: `catalogBindings` determine which catalogs an app can see/use.
209
- - **Descriptors**: `catalogDescriptors/{catalogId}` provide capabilities, query metadata, identity metadata, and field metadata.
210
-
211
- Minimum viable setup for generic consumption is **catalog + descriptor + binding**.
212
-
213
- For **mapped** catalogs, the minimum viable setup is also **definition + mapping + adapter** (created automatically if you use `createCatalog` with `sourceMode: "mapped"`).
214
-
215
- ### Discover catalogs for an app
216
-
217
- ```ts
218
- const context = { appId: "myAppId" };
219
- const catalogs = await catalox.listAppCatalogs(context, { appId: "myAppId" });
220
- ```
221
-
222
- ### Get a catalog descriptor
223
-
224
- ```ts
225
- const descriptor = await catalox.getCatalogDescriptor(context, "signals");
226
- ```
227
-
228
- ### Bootstrap (all descriptors accessible to an app)
229
-
230
- ```ts
231
- const bootstrap = await catalox.getAppCatalogBootstrap(context, "myAppId");
232
- ```
233
-
234
- ### Common “generic client” flow (no hardcoded catalog logic)
235
-
236
- 1) `listAppCatalogs(appId)` get catalog list + access
237
- 2) `getAppCatalogBootstrap(appId)` get descriptors (capabilities/query/identity/fields)
238
- 3) `listCatalogItems(catalogId, filter/sort)` → fetch normalized items
239
- 4) `getCatalogItem(catalogId, itemId)` → fetch one item
240
- 5) Optional: `getCatalogItemReferences(...)`, `validateCatalogItem(...)`
241
-
242
- ## Provisioning (create catalog + bind + descriptor)
243
-
244
- Catalox includes provisioning helpers to reduce “manual Firestore wiring”.
245
-
246
- ### Create a native catalog (also seeds a minimal descriptor)
247
-
248
- ```ts
249
- const ctx = { appId: "myAppId", isGodMode: true }; // or bind admin access appropriately
250
-
251
- await catalox.createCatalog(ctx, {
252
- catalogId: "signals",
253
- name: "Signals",
254
- sourceMode: "native",
255
- native: { type: "native", firestoreCollectionPath: "catalogData/signals/items" },
256
- });
257
- ```
258
-
259
- ### Bind a catalog to an app (enables discovery + access)
260
-
261
- ```ts
262
- await catalox.bindCatalogToApp(ctx, {
263
- appId: "myAppId",
264
- catalogId: "signals",
265
- access: { canRead: true, canWrite: true, canAdmin: true },
266
- });
267
- ```
268
-
269
- ### Patch/upsert a descriptor (recommended for identity + query metadata)
270
-
271
- There is currently no `Catalox.setCatalogDescriptor(...)` method; consumers can upsert the persisted record via `DescriptorStore`.
272
-
273
- ```ts
274
- await descriptors.upsert({
275
- catalogId: "signals",
276
- descriptorVersion: "2",
277
- descriptor: {
278
- catalogId: "signals",
279
- label: "Signals",
280
- sourceMode: "native",
281
- status: "active",
282
- capabilities: { canList: true, canGet: true, canCreate: true, canEdit: true, canDelete: true },
283
- queryableFields: [
284
- { key: "categoryId", label: "Category", type: "string", indexed: true, filterable: true },
285
- { key: "code", label: "Code", type: "string", indexed: true, filterable: true },
286
- { key: "title", label: "Title", type: "string" },
287
- ],
288
- identity: {
289
- itemIdStrategy: "composite",
290
- compositeFields: ["categoryId", "code"],
291
- titleField: "title",
292
- },
293
- },
294
- createdAt: new Date().toISOString(),
295
- updatedAt: new Date().toISOString(),
296
- });
297
- ```
298
-
299
- ### Ensure helpers (idempotent)
300
-
301
- - `ensureCatalog(...)`: creates a minimal catalog record if missing (requires admin binding access).
302
- - `ensureBinding(...)`: creates a binding if missing (enforces god-mode for cross-app provisioning).
303
-
304
- ## Native catalogs (seed/import/export + batch upsert)
305
-
306
- ### Import/Export JSON (helpers)
307
-
308
- ```ts
309
- const items = catalox.importCatalogItemsFromJson<Array<Record<string, unknown>>>(jsonString);
310
- const jsonOut = catalox.exportCatalogItemsToJson(items);
311
- ```
312
-
313
- ### Upsert one item (descriptor-driven identity)
314
-
315
- ```ts
316
- await catalox.upsertNativeCatalogItem({ appId: "myAppId" }, "signals", {
317
- categoryId: "core",
318
- code: "S1",
319
- title: "Example",
320
- indexed: { categoryId: "core", code: "S1" }
321
- });
322
- ```
323
-
324
- ### Native write contract (exact)
325
-
326
- - **Input shape**: the write API accepts a plain object payload. `indexed` is a reserved top-level field used only for query performance and is **not** part of the domain payload.
327
- - **Stored shape** (native): items are stored as `{ itemId, catalogId, indexed, data }`.
328
- - **Identity**: `itemId` is resolved from `descriptor.identity`.
329
- - `natural`: uses `identity.itemIdField` from the input payload.
330
- - `composite`: joins `identity.compositeFields` with `:`.
331
- - `generated`: **currently requires caller-supplied id** (Catalox will throw if descriptor uses `generated` and no id is provided).
332
-
333
- ### Batch upsert
334
-
335
- ```ts
336
- await catalox.batchUpsertNativeCatalogItems({ appId: "myAppId" }, "signals", items);
337
- ```
338
-
339
- ### List native items with equality filtering
340
-
341
- ```ts
342
- const res = await catalox.listCatalogItems({ appId: "myAppId" }, "signals", {
343
- filter: { categoryId: "core" }
344
- });
345
- ```
346
-
347
- Filtering is performed on `indexed.<field>` in stored native records (payload remains in `data`).
348
-
349
- ### Canonical `indexed` rule (native catalogs)
350
-
351
- - **Caller may provide `indexed`**: `indexed: { ... }` is accepted on writes.
352
- - **Catalox may derive `indexed`**: if omitted, Catalox derives `indexed` from the descriptor’s `queryableFields` (fields marked `indexed: true`, using `field.path ?? field.key`).
353
- - **Data cleanliness**: `indexed` is treated as reserved metadata and is **not duplicated inside `data`**.
354
- - **If `indexed` is missing/wrong**: equality filtering and indexed sorting may return incomplete results or fail due to missing Firestore indexes; the canonical fix is to correct the catalog descriptor and/or the write input and re-upsert.
355
-
356
- ## References
357
-
358
- ```ts
359
- const refs = await catalox.getCatalogItemReferences({ appId: "myAppId" }, "signals", "core:S1");
360
- ```
361
-
362
- ### Reference write/provision model (current)
363
-
364
- - References are persisted in `catalogReferences/{referenceId}`.
365
- - Catalox currently **reads** references via `getCatalogItemReferences(...)`; reference creation/maintenance is **consumer-owned** for now.
366
- - `referenceId` is the Firestore document id; consumers should adopt a deterministic scheme, e.g.:
367
- - `${fromCatalogId}:${fromItemId}:${relationType}:${toCatalogId}:${toItemId}`
368
-
369
- ## Validation
370
-
371
- Validation APIs exist with standardized contracts, but **current behavior is minimal**:
372
-
373
- - `validateCatalog(...)` and `validateCatalogItem(...)` currently return `{ isValid: true, issues: [] }`.
374
- - Upstream domain validation is expected to be layered on in later work while keeping the response contract stable.
375
-
376
- ## Tests
377
-
378
- Unit tests (default):
379
-
380
- ```bash
381
- npm test
382
- ```
383
-
384
- Integration tests (live Firestore, no mocks/emulators):
385
-
386
- ```bash
387
- npm run test:integration
388
- ```
389
-
390
- Integration tests are **live-only** (no mocks, no emulators). They require:
391
-
392
- - `FIRESTORE_LIVE_TESTS=1`
393
- - `FIREBASE_SERVICE_ACCOUNT_PATH=...`
394
- - `FIREBASE_PROJECT_ID=...`
395
-
396
- ### Live test safety (read before running)
397
-
398
- - **Never run against production credentials/projects.** Use a dedicated Firebase project for tests.
399
- - **Touched collections**: `apps`, `catalogs`, `catalogBindings`, `catalogDefinitions`, `catalogDescriptors`, and `catalogData/{catalogId}/items/...`.
400
- - **Cleanup**: tests do best-effort deletes of the docs they created, but do not guarantee full teardown.
401
-
402
- ## Boundaries (important)
403
-
404
- - **Secrets**: do not store secret material (API keys, cloud creds) in Catalox. Store only non-secret refs like `credentialsRef`.
405
- - **Artifacts**: store descriptor metadata and remote keys; artifact blobs live in external object storage.
406
-
1
+ # `@x12i/catalox`
2
+
3
+ Catalox is a **data-tier** package for managing **app-scoped catalogs** in Firebase **Firestore**, including:
4
+
5
+ - **Catalog discovery** for an `appId` (what catalogs are available + access)
6
+ - **First-class catalog descriptors** (capabilities, query metadata, identity metadata, field metadata)
7
+ - **Native catalogs** (items stored in Firestore)
8
+ - **Mapped catalogs** (items normalized from MongoDB or APIs)
9
+ - **References + validation contracts** (standardized cross-catalog shapes)
10
+ - **Seed/import/export + batch upsert** workflows for native catalogs
11
+
12
+ Catalox **does not** own UI, workflow orchestration, remote execution, artifact blobs, or secret storage.
13
+
14
+ ## Install
15
+
16
+ This repo is currently set up as a workspace package.
17
+
18
+ - **Node**: `>=20`
19
+ - **TypeScript**: builds to `dist/`
20
+
21
+ `@x12i/helpers` is a **required dependency** (installed as `@x12i/helpers@^1.2.0`).
22
+
23
+ ## Configuration (real connections)
24
+
25
+ Catalox is a library: you provide initialized clients + runtime env.
26
+
27
+ ### Firestore (Firebase Admin SDK)
28
+
29
+ This implementation of Catalox expects a **Firebase Admin SDK** Firestore instance (`firebase-admin`).
30
+
31
+ Important clarification:
32
+
33
+ - **“Admin” here means privileged access to your Firebase/GCP project’s Firestore**, using a service account.
34
+ - It is **not** “admin of the host machine / server OS”.
35
+ - Admin SDK access typically **bypasses Firestore Security Rules** (rules are for client SDKs).
36
+
37
+ Scoping:
38
+
39
+ - You can scope the service account to **Firestore-related IAM roles** and to a specific **project**.
40
+ - You generally cannot scope an Admin SDK credential to only certain collections/documents via IAM the way client rules work.
41
+
42
+ To connect to a real Firebase project, initialize `firebase-admin` using `GOOGLE_APPLICATION_CREDENTIALS` (service account JSON) or your preferred Admin initialization strategy.
43
+
44
+ Minimal example:
45
+
46
+ ```ts
47
+ import { initializeApp, applicationDefault } from "firebase-admin/app";
48
+ import { getFirestore } from "firebase-admin/firestore";
49
+
50
+ initializeApp({ credential: applicationDefault() });
51
+ const firestore = getFirestore();
52
+ ```
53
+
54
+ ### Mongo (mapped catalogs)
55
+
56
+ For Mongo-mapped catalogs, provide:
57
+
58
+ - `MONGO_URI` (or whatever you store in adapter config as `mongoUriEnvVar`)
59
+
60
+ Example `.env` (do not commit secrets):
61
+
62
+ ```bash
63
+ MONGO_URI=mongodb://127.0.0.1:27017
64
+ GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
65
+ FIRESTORE_LIVE_TESTS=1
66
+ ```
67
+
68
+ ## Firestore data model (logical collections)
69
+
70
+ Metadata:
71
+
72
+ - `apps/{appId}`
73
+ - `catalogs/{catalogId}`
74
+ - `catalogBindings/{bindingId}` (many-to-many app↔catalog)
75
+ - `catalogDefinitions/{catalogId}` (native vs mapped specifics)
76
+ - `catalogAdapters/{adapterId}` (mongo/api adapter definitions)
77
+ - `catalogMappings/{mappingId}` (field mapping specs)
78
+ - `catalogDescriptors/{catalogId}` (**descriptor metadata** for generic consumption)
79
+ - `catalogReferences/{referenceId}` (standardized reference records)
80
+
81
+ Data:
82
+
83
+ - `catalogData/{catalogId}/items/{itemId}` (native items)
84
+ - `catalogSnapshots/{catalogId}/items/{itemId}` (mapped snapshot mode)
85
+
86
+ ## Core usage (generic from `appId`)
87
+
88
+ ### Create stores and `Catalox`
89
+
90
+ ```ts
91
+ import { FirestoreStore } from "@x12i/catalox";
92
+ import { AppStore, CatalogStore, BindingStore, DefinitionStore, MappingStore, DescriptorStore, ReferenceStore, NativeItemStore, SnapshotStore, AdapterStore } from "@x12i/catalox";
93
+ import { AuthorizationService, Catalox } from "@x12i/catalox";
94
+
95
+ // firebase-admin initialization is up to the consumer
96
+ import { getFirestore } from "firebase-admin/firestore";
97
+
98
+ const firestore = getFirestore();
99
+ const store = new FirestoreStore({ firestore });
100
+
101
+ const deps = {
102
+ apps: new AppStore(store),
103
+ catalogs: new CatalogStore(store),
104
+ bindings: new BindingStore(store),
105
+ definitions: new DefinitionStore(store),
106
+ mappings: new MappingStore(store),
107
+ descriptors: new DescriptorStore(store),
108
+ references: new ReferenceStore(store),
109
+ nativeItems: new NativeItemStore(store),
110
+ snapshots: new SnapshotStore(store),
111
+ adapters: new AdapterStore(store),
112
+ authz: new AuthorizationService(new BindingStore(store)),
113
+ };
114
+
115
+ const catalox = new Catalox(deps);
116
+ ```
117
+
118
+ ## Descriptor contract (planning-critical)
119
+
120
+ Catalox is designed so upstream packages can be “generic” (no hardcoded catalog registrations). The stable contract is the persisted **descriptor**:
121
+
122
+ - Stored at `catalogDescriptors/{catalogId}`
123
+ - Retrieved via `getCatalogDescriptor(...)` or `getAppCatalogBootstrap(...)`
124
+ - Used for identity, query, and client rendering metadata
125
+
126
+ Below is the **actual current descriptor shape** (TypeScript), with the most planning-relevant subtypes.
127
+
128
+ ```ts
129
+ export type CatalogCapabilitiesDescriptor = {
130
+ canList: boolean;
131
+ canGet: boolean;
132
+ canCreate: boolean;
133
+ canEdit: boolean;
134
+ canDelete: boolean;
135
+ canImport?: boolean;
136
+ canExport?: boolean;
137
+ canSync?: boolean;
138
+ canValidate?: boolean;
139
+ canViewReferences?: boolean;
140
+ };
141
+
142
+ export type CatalogFieldDescriptor = {
143
+ key: string;
144
+ label: string;
145
+ type:
146
+ | "string"
147
+ | "number"
148
+ | "boolean"
149
+ | "date"
150
+ | "datetime"
151
+ | "enum"
152
+ | "object"
153
+ | "array"
154
+ | "reference";
155
+ // Optional path into the stored item payload. If omitted, `key` is assumed.
156
+ path?: string;
157
+
158
+ // Query + indexing metadata.
159
+ filterable?: boolean;
160
+ sortable?: boolean;
161
+ indexed?: boolean;
162
+ multiValue?: boolean;
163
+
164
+ // Presentation + contract metadata.
165
+ listVisible?: boolean;
166
+ detailVisible?: boolean;
167
+ required?: boolean;
168
+ enumValues?: Array<string | number>;
169
+ reference?: { targetCatalogId?: string; targetField?: string };
170
+ metadata?: Record<string, unknown>;
171
+ };
172
+
173
+ export type CatalogIdentityDescriptor = {
174
+ itemIdStrategy: "natural" | "composite" | "generated";
175
+ itemIdField?: string;
176
+ compositeFields?: string[];
177
+
178
+ // Optional display decoration fields.
179
+ titleField?: string;
180
+ subtitleField?: string;
181
+ statusField?: string;
182
+ updatedAtField?: string;
183
+ };
184
+
185
+ export type CatalogDescriptor = {
186
+ catalogId: string;
187
+ label: string;
188
+ description?: string;
189
+ itemLabel?: string;
190
+ sourceMode: "native" | "mapped";
191
+ mappedSourceType?: "mongo" | "api";
192
+ status: "active" | "disabled" | "draft";
193
+ visibility?: "visible" | "hidden";
194
+ defaultSort?: { field: string; direction: "asc" | "desc" };
195
+ defaultFilters?: Record<string, unknown>;
196
+ capabilities: CatalogCapabilitiesDescriptor;
197
+ queryableFields: CatalogFieldDescriptor[];
198
+ queryCapabilities?: Record<string, unknown>;
199
+ filterSpec?: Record<string, unknown>;
200
+ presentationSpec?: Record<string, unknown>;
201
+ customRenderer?: Record<string, unknown>;
202
+ identity: CatalogIdentityDescriptor;
203
+ metadata?: Record<string, unknown>;
204
+ };
205
+ ```
206
+
207
+ ### UI metadata & custom renderers (optional)
208
+
209
+ Catalog descriptors can include optional UI metadata for generic presentation layers:
210
+
211
+ - `filterSpec`: declarative filter configuration (built on `FieldSource`)
212
+ - `presentationSpec`: declarative layout + view/edit semantics (grid/list/cards/form)
213
+ - `customRenderer`: escape hatch that assigns a host-resolved JSX component (by registry key) and passes a stable render-map contract
214
+
215
+ Custom renderer contracts:
216
+
217
+ - [`docs/catalog-list-render-map.md`](docs/catalog-list-render-map.md)
218
+ - [`docs/catalog-item-render-map.md`](docs/catalog-item-render-map.md)
219
+
220
+ Important: host presentation layers are responsible for resolving `FieldSource` lookups (cross-catalog enums/refs) and populating `resolvedSources` for renderers.
221
+
222
+ ### What you need persisted for “generic consumption”
223
+
224
+ To let consumers operate *purely* from `appId` with no hardcoded catalog registrations, Catalox relies on these being present in Firestore:
225
+
226
+ - **Bindings**: `catalogBindings` determine which catalogs an app can see/use.
227
+ - **Descriptors**: `catalogDescriptors/{catalogId}` provide capabilities, query metadata, identity metadata, and field metadata.
228
+
229
+ Minimum viable setup for generic consumption is **catalog + descriptor + binding**.
230
+
231
+ For **mapped** catalogs, the minimum viable setup is also **definition + mapping + adapter** (created automatically if you use `createCatalog` with `sourceMode: "mapped"`).
232
+
233
+ ### Discover catalogs for an app
234
+
235
+ ```ts
236
+ const context = { appId: "myAppId" };
237
+ const catalogs = await catalox.listAppCatalogs(context, { appId: "myAppId" });
238
+ ```
239
+
240
+ ### Get a catalog descriptor
241
+
242
+ ```ts
243
+ const descriptor = await catalox.getCatalogDescriptor(context, "signals");
244
+ ```
245
+
246
+ ### Bootstrap (all descriptors accessible to an app)
247
+
248
+ ```ts
249
+ const bootstrap = await catalox.getAppCatalogBootstrap(context, "myAppId");
250
+ ```
251
+
252
+ ### Common “generic client” flow (no hardcoded catalog logic)
253
+
254
+ 1) `listAppCatalogs(appId)` → get catalog list + access
255
+ 2) `getAppCatalogBootstrap(appId)` get descriptors (capabilities/query/identity/fields)
256
+ 3) `listCatalogItems(catalogId, filter/sort)` → fetch normalized items
257
+ 4) `getCatalogItem(catalogId, itemId)` → fetch one item
258
+ 5) Optional: `getCatalogItemReferences(...)`, `validateCatalogItem(...)`
259
+
260
+ ## Provisioning (create catalog + bind + descriptor)
261
+
262
+ Catalox includes provisioning helpers to reduce “manual Firestore wiring”.
263
+
264
+ ### Create a native catalog (also seeds a minimal descriptor)
265
+
266
+ ```ts
267
+ const ctx = { appId: "myAppId", isGodMode: true }; // or bind admin access appropriately
268
+
269
+ await catalox.createCatalog(ctx, {
270
+ catalogId: "signals",
271
+ name: "Signals",
272
+ sourceMode: "native",
273
+ native: { type: "native", firestoreCollectionPath: "catalogData/signals/items" },
274
+ });
275
+ ```
276
+
277
+ ### Bind a catalog to an app (enables discovery + access)
278
+
279
+ ```ts
280
+ await catalox.bindCatalogToApp(ctx, {
281
+ appId: "myAppId",
282
+ catalogId: "signals",
283
+ access: { canRead: true, canWrite: true, canAdmin: true },
284
+ });
285
+ ```
286
+
287
+ ### Patch/upsert a descriptor (recommended for identity + query metadata)
288
+
289
+ There is currently no `Catalox.setCatalogDescriptor(...)` method; consumers can upsert the persisted record via `DescriptorStore`.
290
+
291
+ ```ts
292
+ await descriptors.upsert({
293
+ catalogId: "signals",
294
+ descriptorVersion: "2",
295
+ descriptor: {
296
+ catalogId: "signals",
297
+ label: "Signals",
298
+ sourceMode: "native",
299
+ status: "active",
300
+ capabilities: { canList: true, canGet: true, canCreate: true, canEdit: true, canDelete: true },
301
+ queryableFields: [
302
+ { key: "categoryId", label: "Category", type: "string", indexed: true, filterable: true },
303
+ { key: "code", label: "Code", type: "string", indexed: true, filterable: true },
304
+ { key: "title", label: "Title", type: "string" },
305
+ ],
306
+ identity: {
307
+ itemIdStrategy: "composite",
308
+ compositeFields: ["categoryId", "code"],
309
+ titleField: "title",
310
+ },
311
+ },
312
+ createdAt: new Date().toISOString(),
313
+ updatedAt: new Date().toISOString(),
314
+ });
315
+ ```
316
+
317
+ ### Ensure helpers (idempotent)
318
+
319
+ - `ensureCatalog(...)`: creates a minimal catalog record if missing (requires admin binding access).
320
+ - `ensureBinding(...)`: creates a binding if missing (enforces god-mode for cross-app provisioning).
321
+
322
+ ## Native catalogs (seed/import/export + batch upsert)
323
+
324
+ ### Import/Export JSON (helpers)
325
+
326
+ ```ts
327
+ const items = catalox.importCatalogItemsFromJson<Array<Record<string, unknown>>>(jsonString);
328
+ const jsonOut = catalox.exportCatalogItemsToJson(items);
329
+ ```
330
+
331
+ ### Upsert one item (descriptor-driven identity)
332
+
333
+ ```ts
334
+ await catalox.upsertNativeCatalogItem({ appId: "myAppId" }, "signals", {
335
+ categoryId: "core",
336
+ code: "S1",
337
+ title: "Example",
338
+ indexed: { categoryId: "core", code: "S1" }
339
+ });
340
+ ```
341
+
342
+ ### Native write contract (exact)
343
+
344
+ - **Input shape**: the write API accepts a plain object payload. `indexed` is a reserved top-level field used only for query performance and is **not** part of the domain payload.
345
+ - **Stored shape** (native): items are stored as `{ itemId, catalogId, indexed, data }`.
346
+ - **Identity**: `itemId` is resolved from `descriptor.identity`.
347
+ - `natural`: uses `identity.itemIdField` from the input payload.
348
+ - `composite`: joins `identity.compositeFields` with `:`.
349
+ - `generated`: **currently requires caller-supplied id** (Catalox will throw if descriptor uses `generated` and no id is provided).
350
+
351
+ ### Batch upsert
352
+
353
+ ```ts
354
+ await catalox.batchUpsertNativeCatalogItems({ appId: "myAppId" }, "signals", items);
355
+ ```
356
+
357
+ ### List native items with equality filtering
358
+
359
+ ```ts
360
+ const res = await catalox.listCatalogItems({ appId: "myAppId" }, "signals", {
361
+ filter: { categoryId: "core" }
362
+ });
363
+ ```
364
+
365
+ Filtering is performed on `indexed.<field>` in stored native records (payload remains in `data`).
366
+
367
+ ### Canonical `indexed` rule (native catalogs)
368
+
369
+ - **Caller may provide `indexed`**: `indexed: { ... }` is accepted on writes.
370
+ - **Catalox may derive `indexed`**: if omitted, Catalox derives `indexed` from the descriptor’s `queryableFields` (fields marked `indexed: true`, using `field.path ?? field.key`).
371
+ - **Data cleanliness**: `indexed` is treated as reserved metadata and is **not duplicated inside `data`**.
372
+ - **If `indexed` is missing/wrong**: equality filtering and indexed sorting may return incomplete results or fail due to missing Firestore indexes; the canonical fix is to correct the catalog descriptor and/or the write input and re-upsert.
373
+
374
+ ## References
375
+
376
+ ```ts
377
+ const refs = await catalox.getCatalogItemReferences({ appId: "myAppId" }, "signals", "core:S1");
378
+ ```
379
+
380
+ ### Reference write/provision model (current)
381
+
382
+ - References are persisted in `catalogReferences/{referenceId}`.
383
+ - Catalox currently **reads** references via `getCatalogItemReferences(...)`; reference creation/maintenance is **consumer-owned** for now.
384
+ - `referenceId` is the Firestore document id; consumers should adopt a deterministic scheme, e.g.:
385
+ - `${fromCatalogId}:${fromItemId}:${relationType}:${toCatalogId}:${toItemId}`
386
+
387
+ ## Validation
388
+
389
+ Validation APIs exist with standardized contracts, but **current behavior is minimal**:
390
+
391
+ - `validateCatalog(...)` and `validateCatalogItem(...)` currently return `{ isValid: true, issues: [] }`.
392
+ - Upstream domain validation is expected to be layered on in later work while keeping the response contract stable.
393
+
394
+ ## Tests
395
+
396
+ Unit tests (default):
397
+
398
+ ```bash
399
+ npm test
400
+ ```
401
+
402
+ Integration tests (live Firestore, no mocks/emulators):
403
+
404
+ ```bash
405
+ npm run test:integration
406
+ ```
407
+
408
+ Integration tests are **live-only** (no mocks, no emulators). They require:
409
+
410
+ - `FIRESTORE_LIVE_TESTS=1`
411
+ - `FIREBASE_SERVICE_ACCOUNT_PATH=...`
412
+ - `FIREBASE_PROJECT_ID=...`
413
+
414
+ ### Live test safety (read before running)
415
+
416
+ - **Never run against production credentials/projects.** Use a dedicated Firebase project for tests.
417
+ - **Touched collections**: `apps`, `catalogs`, `catalogBindings`, `catalogDefinitions`, `catalogDescriptors`, and `catalogData/{catalogId}/items/...`.
418
+ - **Cleanup**: tests do best-effort deletes of the docs they created, but do not guarantee full teardown.
419
+
420
+ ## Boundaries (important)
421
+
422
+ - **Secrets**: do not store secret material (API keys, cloud creds) in Catalox. Store only non-secret refs like `credentialsRef`.
423
+ - **Artifacts**: store descriptor metadata and remote keys; artifact blobs live in external object storage.
424
+