@crazygiscool/cap 1.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.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # CAP — Central Archive Protocol
2
+
3
+ Configuration-driven **TypeScript** library for lore documentation. Wraps **MongoDB** with **Zod** validation to standardize irregular, deeply nested lore data across fictional universes.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install cap
9
+ # peer deps (must install yourself)
10
+ npm install mongodb@^6 zod@^3
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { loadSettings, generateDynamicSchema, CAPRepository, ICAPBaseDocument } from "cap";
17
+ import { MongoClient, Db } from "mongodb";
18
+
19
+ // 1. Load universe config
20
+ const config = loadSettings("./settings.json");
21
+
22
+ // 2. Build dynamic validation schema
23
+ const schema = generateDynamicSchema(config.entryFormat.customFields ?? []);
24
+
25
+ // 3. Connect to MongoDB
26
+ const client = new MongoClient("mongodb://localhost:27017");
27
+ const db = client.db(config.databaseName);
28
+
29
+ // 4. Define an entry type
30
+ interface CharacterEntry extends ICAPBaseDocument {
31
+ sparkId: string;
32
+ firepower: number;
33
+ isOnline: boolean;
34
+ }
35
+
36
+ // 5. Extend the repository
37
+ class CharacterRepo extends CAPRepository<CharacterEntry> {
38
+ constructor(db: Db) {
39
+ super(db, "characters");
40
+ }
41
+ }
42
+
43
+ // 6. Use it
44
+ const repo = new CharacterRepo(db);
45
+
46
+ const optimus = await repo.create({
47
+ name: "Optimus Prime",
48
+ description: "Leader of the Autobots",
49
+ continuityId: "g1",
50
+ factions: ["Autobots"],
51
+ sparkId: "SP-2187",
52
+ firepower: 9000,
53
+ isOnline: true,
54
+ });
55
+
56
+ console.log(optimus.id); // auto-generated UUID
57
+
58
+ const found = await repo.findById(optimus.id);
59
+ const updated = await repo.update(optimus.id, { firepower: 9500 });
60
+ await repo.delete(optimus.id);
61
+
62
+ // Validate incoming data at runtime
63
+ const parsed = schema.parse({
64
+ name: "Bumblebee",
65
+ description: "Autobot scout",
66
+ continuityId: "animated",
67
+ factions: ["Autobots"],
68
+ sparkId: "SP-0001",
69
+ firepower: 500,
70
+ isOnline: true,
71
+ });
72
+ ```
73
+
74
+ ## API
75
+
76
+ ### `CAPRepository<T>`
77
+
78
+ Abstract class you extend. `T` must implement `ICAPBaseDocument`.
79
+
80
+ | Method | Returns | Description |
81
+ |--------|---------|-------------|
82
+ | `create(data)` | `T & { id: string }` | Insert doc with auto-generated UUID + `createdAt` / `updatedAt` |
83
+ | `findById(id)` | `T \| null` | Lookup by string id |
84
+ | `findByName(name)` | `T \| null` | Case-insensitive regex match on `name` |
85
+ | `listAll()` | `T[]` | All documents in the collection |
86
+ | `update(id, data)` | `T \| null` | Partial update, auto-bumps `updatedAt`. Returns updated doc or null |
87
+ | `delete(id)` | `boolean` | Delete by id. Returns `true` if a document was removed |
88
+
89
+ ### `generateDynamicSchema(customFields)`
90
+
91
+ Creates a Zod schema from a `settings.json` `customFields` array and merges it with `CAPBaseSchema`.
92
+
93
+ ```typescript
94
+ const schema = generateDynamicSchema([
95
+ { key: "sparkId", type: "string", label: "Spark Signature" },
96
+ { key: "firepower", type: "number", label: "Firepower Rating" },
97
+ ]);
98
+ // Result: CAPBaseSchema extended with sparkId (z.string()) + firepower (z.number())
99
+ ```
100
+
101
+ Supported field types: `"string"` | `"number"` | `"boolean"` | `"date"`
102
+
103
+ ### `loadSettings(path)`
104
+
105
+ Reads a JSON file from disk, validates it against `CAPSettingsSchema`, and returns a typed `SettingsConfig`.
106
+
107
+ ```typescript
108
+ const config = loadSettings("./settings.json");
109
+ // config.databaseName → string
110
+ // config.entryFormat → { requiredFields?, customFields? }
111
+ ```
112
+
113
+ ## Configuration
114
+
115
+ Create a `settings.json` at your app root:
116
+
117
+ ```json
118
+ {
119
+ "databaseName": "Nova Cronum",
120
+ "entryFormat": {
121
+ "requiredFields": ["name", "description", "continuityId", "factions"],
122
+ "customFields": [
123
+ { "key": "sparkId", "type": "string", "label": "Spark Signature" },
124
+ { "key": "primaryFunction", "type": "string", "label": "Designated Function" },
125
+ { "key": "firepower", "type": "number", "label": "Firepower Rating" }
126
+ ]
127
+ }
128
+ }
129
+ ```
130
+
131
+ ## Build & Test
132
+
133
+ ```sh
134
+ npm run build # tsc → dist/
135
+ npm test # vitest run --coverage
136
+ npm run test:watch # vitest (watch mode)
137
+ ```
138
+
139
+ ## Exports
140
+
141
+ | Export | Kind |
142
+ |--------|------|
143
+ | `ICAPBaseDocument` | interface |
144
+ | `ILoreVariant<T>` | interface |
145
+ | `CAPBaseSchema` | Zod schema |
146
+ | `CAPRepository<T>` | abstract class |
147
+ | `generateDynamicSchema` | function |
148
+ | `loadSettings` | function |
149
+ | `CAPSettingsSchema` | Zod schema |
150
+ | `CustomFieldConfig` | interface |
151
+ | `EntryFormatConfig` | interface |
152
+ | `SettingsConfig` | interface |
153
+ | `FieldType` | type (`"string" | "number" | "boolean" | "date"`) |
154
+
155
+ ## Module System
156
+
157
+ ESM (`"type": "module"`) with NodeNext module resolution. Import sibling files with `.js` extension.
158
+
159
+ ```typescript
160
+ import { CAPRepository } from "cap";
161
+ ```
package/dist/.gitkeep ADDED
File without changes
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { z } from "zod";
4
+ // Runtime validation baseline
5
+ export const CAPBaseSchema = z.object({
6
+ name: z.string().min(1),
7
+ description: z.string(),
8
+ continuityId: z.string(),
9
+ factions: z.array(z.string()),
10
+ });
11
+ // Standardized MongoDB Data Layer
12
+ export class CAPRepository {
13
+ collection;
14
+ constructor(db, collectionName) {
15
+ this.collection = db.collection(collectionName);
16
+ }
17
+ async create(data) {
18
+ const doc = {
19
+ ...data,
20
+ id: randomUUID(),
21
+ createdAt: new Date(),
22
+ updatedAt: new Date(),
23
+ };
24
+ await this.collection.insertOne(doc);
25
+ return doc;
26
+ }
27
+ async findById(id) {
28
+ return this.collection.findOne({ id });
29
+ }
30
+ async findByName(name) {
31
+ return this.collection.findOne({
32
+ name: { $regex: name, $options: "i" },
33
+ });
34
+ }
35
+ async listAll() {
36
+ return this.collection.find({}).toArray();
37
+ }
38
+ async update(id, data) {
39
+ return this.collection.findOneAndUpdate({ id }, { $set: { ...data, updatedAt: new Date() } }, { returnDocument: "after" });
40
+ }
41
+ async delete(id) {
42
+ const result = await this.collection.deleteOne({ id });
43
+ return result.deletedCount > 0;
44
+ }
45
+ }
46
+ const CustomFieldSchema = z.object({
47
+ key: z.string().min(1),
48
+ type: z.enum(["string", "number", "boolean", "date"]),
49
+ label: z.string().min(1),
50
+ });
51
+ export const CAPSettingsSchema = z.object({
52
+ databaseName: z.string().min(1),
53
+ entryFormat: z.object({
54
+ requiredFields: z.array(z.string()).optional(),
55
+ customFields: z.array(CustomFieldSchema).optional(),
56
+ }),
57
+ });
58
+ function zodTypeForFieldType(fieldType) {
59
+ switch (fieldType) {
60
+ case "string":
61
+ return z.string();
62
+ case "number":
63
+ return z.number();
64
+ case "boolean":
65
+ return z.boolean();
66
+ case "date":
67
+ return z.string().datetime();
68
+ default:
69
+ throw new Error(`Unknown field type: ${fieldType}`);
70
+ }
71
+ }
72
+ export function generateDynamicSchema(customFields) {
73
+ const shape = {};
74
+ for (const field of customFields) {
75
+ shape[field.key] = zodTypeForFieldType(field.type);
76
+ }
77
+ return CAPBaseSchema.extend(shape);
78
+ }
79
+ export function loadSettings(path) {
80
+ const raw = readFileSync(path, "utf-8");
81
+ const parsed = JSON.parse(raw);
82
+ return CAPSettingsSchema.parse(parsed);
83
+ }
84
+ //# sourceMappingURL=index.js.map
package/docs/PLAN.md ADDED
@@ -0,0 +1,96 @@
1
+ # CAP Development Plan
2
+
3
+ ## Architecture Overview
4
+
5
+ ```
6
+ ┌─────────────────────────────────────────────┐
7
+ │ CAP (this repo) │
8
+ │ ───────────── │
9
+ │ cap package │
10
+ │ - CAPRepository<T> │
11
+ │ - CAPBaseSchema │
12
+ │ - generateDynamicSchema(customFields) │
13
+ │ - loadSettings(path) │
14
+ └──────────────────────┬──────────────────────┘
15
+ │ npm install file:../cap
16
+
17
+ ┌─────────────────────────────────────────────┐
18
+ │ Nova Cronum (separate repo) │
19
+ │ SvelteKit full-stack app │
20
+ │ - settings.json (backend config, validation)│
21
+ │ - CRUD routes with dynamic Zod validation │
22
+ │ - consumes `cap` library │
23
+ └─────────────────────────────────────────────┘
24
+
25
+ ┌─────────────────────────────────────────────┐
26
+ │ Frontend Template (separate GH template) │
27
+ │ SvelteKit skeleton │
28
+ │ - theme.config.json (separate frontend cfg) │
29
+ │ - CSS custom properties (build-time baked) │
30
+ │ - Nav from pages config │
31
+ │ - Favicon injection │
32
+ │ - Layout + slot for universe pages │
33
+ └─────────────────────────────────────────────┘
34
+ ```
35
+
36
+ **Key decisions:**
37
+ - No monorepo — `cap` stays a standalone npm package at root
38
+ - Backend and frontend use separate config files
39
+ - Frontend template is a "Use this template" GitHub repo
40
+ - Template loads config at build time (static, baked into CSS)
41
+
42
+ ---
43
+
44
+ ## Task 1: Testing Infrastructure
45
+
46
+ | # | File | Detail |
47
+ |---|------|--------|
48
+ | 1.1 | `package.json` | Add `vitest` + `@vitest/coverage-v8` to devDependencies. Add `"test": "vitest run --coverage"` and `"test:watch": "vitest"`. |
49
+ | 1.2 | `vitest.config.ts` | `defineConfig({ test: { globals: true, environment: 'node', include: ['src/**/*.test.ts'] } })` |
50
+ | 1.3 | `src/__tests__/smoke.test.ts` | Import `CAPBaseSchema` and `CAPRepository` — verify they exist and schema validates a valid object. |
51
+ | 1.4 | — | `npm install && npm run test` — verify green. |
52
+
53
+ ## Task 2: Dynamic Zod Schema Generator
54
+
55
+ | # | File | Detail |
56
+ |---|------|--------|
57
+ | 2.1 | `src/index.ts` | Add types: `CustomFieldConfig`, `EntryFormatConfig`, `SettingsConfig`, `FieldType`. |
58
+ | 2.2 | `src/index.ts` | **`generateDynamicSchema(customFields: CustomFieldConfig[])`:** maps each field type to a Zod primitive (`string → z.string()`, `number → z.number()`, `boolean → z.boolean()`, `date → z.string().datetime()`), builds a `z.object({ [key]: zodType })`, merges with `CAPBaseSchema`. Returns composite schema. |
59
+ | 2.3 | `src/index.ts` | **`loadSettings(path: string)`:** reads JSON with `fs.readFileSync`, parses, validates against a static `SettingsSchema` (Zod schema for the config file shape), returns typed `SettingsConfig`. |
60
+ | 2.4 | — | `npm run build` — verify compilation. |
61
+
62
+ ## Task 3: Tests
63
+
64
+ | # | File | Detail |
65
+ |---|------|--------|
66
+ | 3.1 | `src/__tests__/dynamic-schema.test.ts` | Test `generateDynamicSchema`: empty list returns base schema; each type maps correctly; mixed fields; unknown type throws; valid data passes; invalid fails. |
67
+ | 3.2 | `src/__tests__/settings-loader.test.ts` | Test `loadSettings` with temp files: valid config parses; missing file throws; invalid JSON throws; missing required field throws. |
68
+ | 3.3 | — | `npm run test` — full suite green. |
69
+
70
+ ## Task 4: Documentation
71
+
72
+ | # | File | Detail |
73
+ |---|------|--------|
74
+ | 4.1 | `AGENTS.md` | Add test commands, new exports, dynamic schema notes. |
75
+ | 4.2 | — | `npm run build && npm run test` — final verification. |
76
+
77
+ ---
78
+
79
+ ## Separate Repos (out of scope for this repo)
80
+
81
+ ### Nova Cronum
82
+ - Standalone SvelteKit repo
83
+ - `npm install file:../cap` to link cap package
84
+ - `settings.json` with universe-specific entry format and custom fields
85
+ - API routes using `CAPRepository` + `generateDynamicSchema`
86
+ - Serves as the integration test for cap
87
+
88
+ ### Frontend Template
89
+ - GitHub template repository ("Use this template")
90
+ - SvelteKit skeleton project
91
+ - `theme.config.json` with theme, favicon, pages config
92
+ - Imports config at build time (Vite JSON import)
93
+ - Binds CSS custom properties from theme
94
+ - Renders navigation from pages config
95
+ - Injects favicon from config path
96
+ - Universe apps fork and add their routes on top
@@ -0,0 +1,123 @@
1
+ # Quick Start — Building a Universe App with CAP
2
+
3
+ This guide walks through creating a **Nova Cronum**-style lore application from scratch.
4
+
5
+ ## 1. Create the project
6
+
7
+ ```sh
8
+ mkdir nova-cronum && cd nova-cronum
9
+ npm init -y
10
+ npm install ../cap # or: npm install file:../cap
11
+ npm install mongodb@^6 zod@^3
12
+ ```
13
+
14
+ Set `"type": "module"` in `package.json`.
15
+
16
+ ## 2. Create settings.json
17
+
18
+ ```json
19
+ {
20
+ "databaseName": "Nova Cronum",
21
+ "entryFormat": {
22
+ "customFields": [
23
+ { "key": "sparkId", "type": "string", "label": "Spark Signature" },
24
+ { "key": "primaryFunction", "type": "string", "label": "Designated Function" },
25
+ { "key": "firepower", "type": "number", "label": "Firepower Rating" },
26
+ { "key": "isOnline", "type": "boolean", "label": "Active Status" },
27
+ { "key": "activated", "type": "date", "label": "Activation Date" }
28
+ ]
29
+ }
30
+ }
31
+ ```
32
+
33
+ ## 3. Connect and seed
34
+
35
+ ```typescript
36
+ // src/db.ts
37
+ import { loadSettings, CAPRepository, ICAPBaseDocument } from "cap";
38
+ import { MongoClient, Db } from "mongodb";
39
+
40
+ export interface CharacterEntry extends ICAPBaseDocument {
41
+ sparkId: string;
42
+ primaryFunction: string;
43
+ firepower: number;
44
+ isOnline: boolean;
45
+ activated: string; // ISO datetime string
46
+ }
47
+
48
+ export class CharacterRepo extends CAPRepository<CharacterEntry> {
49
+ constructor(db: Db) {
50
+ super(db, "characters");
51
+ }
52
+ }
53
+
54
+ const config = loadSettings("./settings.json");
55
+ const client = new MongoClient("mongodb://localhost:27017");
56
+ const db = client.db(config.databaseName);
57
+ export const repo = new CharacterRepo(db);
58
+ ```
59
+
60
+ ```typescript
61
+ // src/seed.ts
62
+ import { repo } from "./db.js";
63
+
64
+ const optimus = await repo.create({
65
+ name: "Optimus Prime",
66
+ description: "Leader of the Autobots",
67
+ continuityId: "g1",
68
+ factions: ["Autobots"],
69
+ sparkId: "SP-2187",
70
+ primaryFunction: "Matrix of Leadership Bearer",
71
+ firepower: 9000,
72
+ isOnline: true,
73
+ activated: "1984-09-17T00:00:00.000Z",
74
+ });
75
+
76
+ console.log("Created:", optimus.id);
77
+ ```
78
+
79
+ ## 4. Validate incoming data dynamically
80
+
81
+ ```typescript
82
+ // src/validate.ts
83
+ import { loadSettings, generateDynamicSchema } from "cap";
84
+
85
+ const config = loadSettings("./settings.json");
86
+ const schema = generateDynamicSchema(config.entryFormat.customFields ?? []);
87
+
88
+ // Use schema.parse() in API routes to reject bad data
89
+ const result = schema.safeParse(req.body);
90
+ if (!result.success) {
91
+ return res.status(400).json(result.error);
92
+ }
93
+ ```
94
+
95
+ ## 5. Full CRUD pattern
96
+
97
+ ```typescript
98
+ // src/routes.ts
99
+ import { repo } from "./db.js";
100
+
101
+ // READ
102
+ const all = await repo.listAll();
103
+ const one = await repo.findById("some-uuid");
104
+ const search = await repo.findByName("optimus");
105
+
106
+ // CREATE (returns entry with generated id + timestamps)
107
+ const created = await repo.create({ ... });
108
+
109
+ // UPDATE (partial update, bumps updatedAt)
110
+ const updated = await repo.update(created.id, { firepower: 9500 });
111
+
112
+ // DELETE
113
+ const deleted = await repo.delete(created.id); // boolean
114
+ ```
115
+
116
+ ## 6. With SvelteKit (for frontend + API later)
117
+
118
+ ```sh
119
+ npx sv create . --template minimal --types ts
120
+ npm install ../cap
121
+ ```
122
+
123
+ Then add API routes under `src/routes/api/entries/+server.ts` using the same `repo` pattern above.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@crazygiscool/cap",
3
+ "version": "1.0.0",
4
+ "description": "The Central Archive Protocol core library for lore documentation",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "prepare": "npm run build",
11
+ "test": "vitest run --coverage",
12
+ "test:watch": "vitest"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Crazygiscool/CAP.git"
17
+ },
18
+ "keywords": [
19
+ "documentation",
20
+ "mongodb",
21
+ "typescript",
22
+ "lore"
23
+ ],
24
+ "author": "",
25
+ "license": "ISC",
26
+ "bugs": {
27
+ "url": "https://github.com/Crazygiscool/CAP/issues"
28
+ },
29
+ "homepage": "https://github.com/Crazygiscool/CAP#readme",
30
+ "peerDependencies": {
31
+ "mongodb": "^6.0.0",
32
+ "zod": "^3.0.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.12.7",
36
+ "@vitest/coverage-v8": "^2.1.9",
37
+ "mongodb": "^6.5.0",
38
+ "typescript": "^5.4.5",
39
+ "vitest": "^2.1.9",
40
+ "zod": "^3.22.4"
41
+ }
42
+ }
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ CAPBaseSchema,
4
+ generateDynamicSchema,
5
+ type CustomFieldConfig,
6
+ } from "../index.js";
7
+
8
+ const baseFields = {
9
+ name: "Optimus Prime",
10
+ description: "Leader of the Autobots",
11
+ continuityId: "g1",
12
+ factions: ["Autobots"],
13
+ };
14
+
15
+ describe("generateDynamicSchema", () => {
16
+ it("returns base schema when customFields is empty", () => {
17
+ const schema = generateDynamicSchema([]);
18
+ const result = schema.safeParse(baseFields);
19
+ expect(result.success).toBe(true);
20
+ });
21
+
22
+ it("adds a string field", () => {
23
+ const fields: CustomFieldConfig[] = [
24
+ { key: "sparkId", type: "string", label: "Spark Signature" },
25
+ ];
26
+ const schema = generateDynamicSchema(fields);
27
+ const result = schema.safeParse({
28
+ ...baseFields,
29
+ sparkId: "SP-2187",
30
+ });
31
+ expect(result.success).toBe(true);
32
+ });
33
+
34
+ it("rejects a number for a string field", () => {
35
+ const fields: CustomFieldConfig[] = [
36
+ { key: "sparkId", type: "string", label: "Spark Signature" },
37
+ ];
38
+ const schema = generateDynamicSchema(fields);
39
+ const result = schema.safeParse({
40
+ ...baseFields,
41
+ sparkId: 42,
42
+ });
43
+ expect(result.success).toBe(false);
44
+ });
45
+
46
+ it("adds a number field", () => {
47
+ const fields: CustomFieldConfig[] = [
48
+ { key: "firepower", type: "number", label: "Firepower Rating" },
49
+ ];
50
+ const schema = generateDynamicSchema(fields);
51
+ const ok = schema.safeParse({
52
+ ...baseFields,
53
+ firepower: 9000,
54
+ });
55
+ expect(ok.success).toBe(true);
56
+
57
+ const fail = schema.safeParse({
58
+ ...baseFields,
59
+ firepower: "over 9000",
60
+ });
61
+ expect(fail.success).toBe(false);
62
+ });
63
+
64
+ it("adds a boolean field", () => {
65
+ const fields: CustomFieldConfig[] = [
66
+ { key: "isOnline", type: "boolean", label: "Online Status" },
67
+ ];
68
+ const schema = generateDynamicSchema(fields);
69
+ const ok = schema.safeParse({
70
+ ...baseFields,
71
+ isOnline: true,
72
+ });
73
+ expect(ok.success).toBe(true);
74
+
75
+ const fail = schema.safeParse({
76
+ ...baseFields,
77
+ isOnline: "yes",
78
+ });
79
+ expect(fail.success).toBe(false);
80
+ });
81
+
82
+ it("adds a date field (ISO datetime string)", () => {
83
+ const fields: CustomFieldConfig[] = [
84
+ { key: "activated", type: "date", label: "Activation Date" },
85
+ ];
86
+ const schema = generateDynamicSchema(fields);
87
+ const ok = schema.safeParse({
88
+ ...baseFields,
89
+ activated: "2024-01-15T08:00:00.000Z",
90
+ });
91
+ expect(ok.success).toBe(true);
92
+
93
+ const fail = schema.safeParse({
94
+ ...baseFields,
95
+ activated: "not-a-date",
96
+ });
97
+ expect(fail.success).toBe(false);
98
+ });
99
+
100
+ it("handles multiple custom fields of different types", () => {
101
+ const fields: CustomFieldConfig[] = [
102
+ { key: "sparkId", type: "string", label: "Spark Signature" },
103
+ { key: "firepower", type: "number", label: "Firepower Rating" },
104
+ { key: "isOnline", type: "boolean", label: "Online Status" },
105
+ ];
106
+ const schema = generateDynamicSchema(fields);
107
+ const result = schema.safeParse({
108
+ ...baseFields,
109
+ sparkId: "SP-2187",
110
+ firepower: 9000,
111
+ isOnline: true,
112
+ });
113
+ expect(result.success).toBe(true);
114
+ });
115
+
116
+ it("rejects missing custom fields", () => {
117
+ const fields: CustomFieldConfig[] = [
118
+ { key: "sparkId", type: "string", label: "Spark Signature" },
119
+ ];
120
+ const schema = generateDynamicSchema(fields);
121
+ const result = schema.safeParse(baseFields);
122
+ expect(result.success).toBe(false);
123
+ });
124
+
125
+ it("still enforces base schema requirements", () => {
126
+ const schema = generateDynamicSchema([]);
127
+ const result = schema.safeParse({ name: "Megatron" });
128
+ expect(result.success).toBe(false);
129
+ });
130
+ });
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { CAPRepository, type ICAPBaseDocument } from "../index.js";
3
+
4
+ interface TestEntry extends ICAPBaseDocument {
5
+ sparkId: string;
6
+ }
7
+
8
+ class TestRepository extends CAPRepository<TestEntry> {
9
+ constructor(db: any) {
10
+ super(db, "test_entries");
11
+ }
12
+ }
13
+
14
+ function mockDb() {
15
+ const mockCollection = {
16
+ insertOne: vi.fn(),
17
+ findOne: vi.fn(),
18
+ find: vi.fn(() => ({
19
+ toArray: vi.fn(),
20
+ })),
21
+ findOneAndUpdate: vi.fn(),
22
+ deleteOne: vi.fn(),
23
+ };
24
+
25
+ const db = {
26
+ collection: vi.fn(() => mockCollection),
27
+ } as any;
28
+
29
+ return { db, collection: mockCollection };
30
+ }
31
+
32
+ describe("CAPRepository", () => {
33
+ let repo: TestRepository;
34
+ let collection: ReturnType<typeof mockDb>["collection"];
35
+
36
+ beforeEach(() => {
37
+ const { db, collection: c } = mockDb();
38
+ repo = new TestRepository(db);
39
+ collection = c;
40
+ });
41
+
42
+ describe("create", () => {
43
+ it("inserts a document with generated id and timestamps", async () => {
44
+ collection.insertOne.mockResolvedValue({ acknowledged: true });
45
+
46
+ const result = await repo.create({
47
+ name: "Optimus Prime",
48
+ description: "Leader of the Autobots",
49
+ continuityId: "g1",
50
+ factions: ["Autobots"],
51
+ sparkId: "SP-2187",
52
+ });
53
+
54
+ expect(result.id).toBeDefined();
55
+ expect(typeof result.id).toBe("string");
56
+ expect(result.createdAt).toBeInstanceOf(Date);
57
+ expect(result.updatedAt).toBeInstanceOf(Date);
58
+ expect(result.name).toBe("Optimus Prime");
59
+ expect(result.sparkId).toBe("SP-2187");
60
+
61
+ expect(collection.insertOne).toHaveBeenCalledOnce();
62
+ const inserted = collection.insertOne.mock.calls[0][0];
63
+ expect(inserted.id).toBe(result.id);
64
+ expect(inserted.createdAt).toBeInstanceOf(Date);
65
+ expect(inserted.updatedAt).toBeInstanceOf(Date);
66
+ });
67
+ });
68
+
69
+ describe("findById", () => {
70
+ it("returns a document by id", async () => {
71
+ const doc = {
72
+ _id: "abc",
73
+ id: "uuid-123",
74
+ name: "Bumblebee",
75
+ description: "Scout",
76
+ continuityId: "g1",
77
+ factions: ["Autobots"],
78
+ sparkId: "SP-0001",
79
+ createdAt: new Date(),
80
+ updatedAt: new Date(),
81
+ };
82
+ collection.findOne.mockResolvedValue(doc);
83
+
84
+ const result = await repo.findById("uuid-123");
85
+ expect(result).toEqual(doc);
86
+ expect(collection.findOne).toHaveBeenCalledWith({ id: "uuid-123" });
87
+ });
88
+
89
+ it("returns null when not found", async () => {
90
+ collection.findOne.mockResolvedValue(null);
91
+
92
+ const result = await repo.findById("nonexistent");
93
+ expect(result).toBeNull();
94
+ });
95
+ });
96
+
97
+ describe("findByName", () => {
98
+ it("returns a document by name (case-insensitive)", async () => {
99
+ const doc = {
100
+ _id: "abc",
101
+ id: "uuid-1",
102
+ name: "Megatron",
103
+ description: "Decepticon leader",
104
+ continuityId: "g1",
105
+ factions: ["Decepticons"],
106
+ createdAt: new Date(),
107
+ updatedAt: new Date(),
108
+ };
109
+ collection.findOne.mockResolvedValue(doc);
110
+
111
+ const result = await repo.findByName("megatron");
112
+ expect(result).toEqual(doc);
113
+ });
114
+
115
+ it("returns null when not found", async () => {
116
+ collection.findOne.mockResolvedValue(null);
117
+
118
+ const result = await repo.findByName("Starscream");
119
+ expect(result).toBeNull();
120
+ });
121
+ });
122
+
123
+ describe("listAll", () => {
124
+ it("returns all documents", async () => {
125
+ const docs = [
126
+ { id: "1", name: "A", description: "", continuityId: "g1", factions: [], createdAt: new Date(), updatedAt: new Date() },
127
+ { id: "2", name: "B", description: "", continuityId: "g1", factions: [], createdAt: new Date(), updatedAt: new Date() },
128
+ ];
129
+ collection.find.mockReturnValue({ toArray: vi.fn().mockResolvedValue(docs) });
130
+
131
+ const result = await repo.listAll();
132
+ expect(result).toEqual(docs);
133
+ expect(result).toHaveLength(2);
134
+ });
135
+ });
136
+
137
+ describe("update", () => {
138
+ it("updates fields and bumps updatedAt", async () => {
139
+ const original = {
140
+ id: "uuid-1",
141
+ name: "Optimus Prime",
142
+ description: "Old description",
143
+ continuityId: "g1",
144
+ factions: ["Autobots"],
145
+ createdAt: new Date("2020-01-01"),
146
+ updatedAt: new Date("2020-01-01"),
147
+ };
148
+
149
+ const updated = {
150
+ ...original,
151
+ description: "New description",
152
+ updatedAt: new Date(),
153
+ };
154
+
155
+ collection.findOneAndUpdate.mockResolvedValue(updated);
156
+
157
+ const result = await repo.update("uuid-1", {
158
+ description: "New description",
159
+ });
160
+
161
+ expect(result).toEqual(updated);
162
+ expect(collection.findOneAndUpdate).toHaveBeenCalled();
163
+
164
+ const [, updateDoc] = collection.findOneAndUpdate.mock.calls[0];
165
+ expect(updateDoc.$set.description).toBe("New description");
166
+ expect(updateDoc.$set.updatedAt).toBeInstanceOf(Date);
167
+ });
168
+
169
+ it("returns null when document does not exist", async () => {
170
+ collection.findOneAndUpdate.mockResolvedValue(null);
171
+
172
+ const result = await repo.update("nonexistent", {
173
+ description: "Nope",
174
+ });
175
+ expect(result).toBeNull();
176
+ });
177
+ });
178
+
179
+ describe("delete", () => {
180
+ it("returns true when a document is deleted", async () => {
181
+ collection.deleteOne.mockResolvedValue({ deletedCount: 1 });
182
+
183
+ const result = await repo.delete("uuid-1");
184
+ expect(result).toBe(true);
185
+ expect(collection.deleteOne).toHaveBeenCalledWith({ id: "uuid-1" });
186
+ });
187
+
188
+ it("returns false when no document matched", async () => {
189
+ collection.deleteOne.mockResolvedValue({ deletedCount: 0 });
190
+
191
+ const result = await repo.delete("nonexistent");
192
+ expect(result).toBe(false);
193
+ });
194
+ });
195
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadSettings } from "../index.js";
6
+
7
+ function withTempFile(
8
+ name: string,
9
+ content: string,
10
+ fn: (path: string) => void,
11
+ ) {
12
+ const dir = mkdtempSync(join(tmpdir(), "cap-test-"));
13
+ const filePath = join(dir, name);
14
+ writeFileSync(filePath, content, "utf-8");
15
+ try {
16
+ fn(filePath);
17
+ } finally {
18
+ try {
19
+ unlinkSync(filePath);
20
+ } catch {}
21
+ try {
22
+ rmdirSync(dir);
23
+ } catch {}
24
+ }
25
+ }
26
+
27
+ const validSettings = JSON.stringify({
28
+ databaseName: "Nova Cronum",
29
+ entryFormat: {
30
+ requiredFields: ["name", "description", "continuityId", "factions"],
31
+ customFields: [
32
+ { key: "sparkId", type: "string", label: "Spark Signature" },
33
+ { key: "firepower", type: "number", label: "Firepower Rating" },
34
+ ],
35
+ },
36
+ });
37
+
38
+ describe("loadSettings", () => {
39
+ it("parses a valid settings file", () => {
40
+ withTempFile("settings.json", validSettings, (path) => {
41
+ const config = loadSettings(path);
42
+ expect(config.databaseName).toBe("Nova Cronum");
43
+ expect(config.entryFormat.customFields).toHaveLength(2);
44
+ expect(config.entryFormat.customFields![0].key).toBe("sparkId");
45
+ });
46
+ });
47
+
48
+ it("accepts settings without customFields", () => {
49
+ const minimal = JSON.stringify({
50
+ databaseName: "Test Universe",
51
+ entryFormat: {},
52
+ });
53
+ withTempFile("settings.json", minimal, (path) => {
54
+ const config = loadSettings(path);
55
+ expect(config.databaseName).toBe("Test Universe");
56
+ expect(config.entryFormat.customFields).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ it("throws on missing file", () => {
61
+ expect(() => loadSettings("/nonexistent/path.json")).toThrow();
62
+ });
63
+
64
+ it("throws on invalid JSON", () => {
65
+ withTempFile("settings.json", "{bad json}", (path) => {
66
+ expect(() => loadSettings(path)).toThrow();
67
+ });
68
+ });
69
+
70
+ it("throws on invalid field type", () => {
71
+ const bad = JSON.stringify({
72
+ databaseName: "Test",
73
+ entryFormat: {
74
+ customFields: [{ key: "x", type: "binary", label: "X" }],
75
+ },
76
+ });
77
+ withTempFile("settings.json", bad, (path) => {
78
+ expect(() => loadSettings(path)).toThrow();
79
+ });
80
+ });
81
+
82
+ it("throws when databaseName is missing", () => {
83
+ const bad = JSON.stringify({
84
+ entryFormat: {},
85
+ });
86
+ withTempFile("settings.json", bad, (path) => {
87
+ expect(() => loadSettings(path)).toThrow();
88
+ });
89
+ });
90
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { CAPBaseSchema, CAPRepository } from "../index.js";
3
+
4
+ describe("CAPBaseSchema", () => {
5
+ it("validates a correct lore entry", () => {
6
+ const result = CAPBaseSchema.safeParse({
7
+ name: "Optimus Prime",
8
+ description: "Leader of the Autobots",
9
+ continuityId: "g1",
10
+ factions: ["Autobots"],
11
+ });
12
+ expect(result.success).toBe(true);
13
+ });
14
+
15
+ it("rejects an entry missing required fields", () => {
16
+ const result = CAPBaseSchema.safeParse({
17
+ name: "Megatron",
18
+ });
19
+ expect(result.success).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe("CAPRepository", () => {
24
+ it("is an abstract class that cannot be instantiated directly", () => {
25
+ expect(CAPRepository).toBeDefined();
26
+ });
27
+ });
package/src/index.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { z } from "zod";
4
+ import { Collection, Db } from "mongodb";
5
+
6
+ // Universal Document Rules
7
+ export interface ICAPBaseDocument {
8
+ id: string;
9
+ name: string;
10
+ description: string;
11
+ continuityId: string;
12
+ factions: string[];
13
+ createdAt: Date;
14
+ updatedAt: Date;
15
+ }
16
+
17
+ // Flexible variant template (Alt-modes, Multiverse variants, Eras)
18
+ export interface ILoreVariant<T> {
19
+ variantName: string;
20
+ appearanceMediaId: string;
21
+ specifications: T;
22
+ }
23
+
24
+ // Runtime validation baseline
25
+ export const CAPBaseSchema = z.object({
26
+ name: z.string().min(1),
27
+ description: z.string(),
28
+ continuityId: z.string(),
29
+ factions: z.array(z.string()),
30
+ });
31
+
32
+ // Standardized MongoDB Data Layer
33
+ export abstract class CAPRepository<T extends ICAPBaseDocument> {
34
+ protected collection: Collection<T>;
35
+
36
+ constructor(db: Db, collectionName: string) {
37
+ this.collection = db.collection<T>(collectionName);
38
+ }
39
+
40
+ async create(
41
+ data: Omit<T, "id" | "createdAt" | "updatedAt">,
42
+ ): Promise<T & { id: string }> {
43
+ const doc = {
44
+ ...data,
45
+ id: randomUUID(),
46
+ createdAt: new Date(),
47
+ updatedAt: new Date(),
48
+ } as any;
49
+ await this.collection.insertOne(doc);
50
+ return doc as T & { id: string };
51
+ }
52
+
53
+ async findById(id: string): Promise<T | null> {
54
+ return this.collection.findOne({ id } as any) as Promise<T | null>;
55
+ }
56
+
57
+ async findByName(name: string): Promise<T | null> {
58
+ return this.collection.findOne({
59
+ name: { $regex: name, $options: "i" },
60
+ } as any) as Promise<T | null>;
61
+ }
62
+
63
+ async listAll(): Promise<T[]> {
64
+ return this.collection.find({}).toArray() as Promise<T[]>;
65
+ }
66
+
67
+ async update(
68
+ id: string,
69
+ data: Partial<Omit<T, "id" | "createdAt" | "updatedAt">>,
70
+ ): Promise<T | null> {
71
+ return this.collection.findOneAndUpdate(
72
+ { id } as any,
73
+ { $set: { ...data, updatedAt: new Date() } } as any,
74
+ { returnDocument: "after" },
75
+ ) as Promise<T | null>;
76
+ }
77
+
78
+ async delete(id: string): Promise<boolean> {
79
+ const result = await this.collection.deleteOne({ id } as any);
80
+ return result.deletedCount > 0;
81
+ }
82
+ }
83
+
84
+ // --- Dynamic Schema Generator ---
85
+
86
+ export type FieldType = "string" | "number" | "boolean" | "date";
87
+
88
+ export interface CustomFieldConfig {
89
+ key: string;
90
+ type: FieldType;
91
+ label: string;
92
+ }
93
+
94
+ export interface EntryFormatConfig {
95
+ requiredFields?: string[];
96
+ customFields?: CustomFieldConfig[];
97
+ }
98
+
99
+ export interface SettingsConfig {
100
+ databaseName: string;
101
+ entryFormat: EntryFormatConfig;
102
+ }
103
+
104
+ const CustomFieldSchema = z.object({
105
+ key: z.string().min(1),
106
+ type: z.enum(["string", "number", "boolean", "date"]),
107
+ label: z.string().min(1),
108
+ });
109
+
110
+ export const CAPSettingsSchema = z.object({
111
+ databaseName: z.string().min(1),
112
+ entryFormat: z.object({
113
+ requiredFields: z.array(z.string()).optional(),
114
+ customFields: z.array(CustomFieldSchema).optional(),
115
+ }),
116
+ });
117
+
118
+ function zodTypeForFieldType(fieldType: FieldType): z.ZodType {
119
+ switch (fieldType) {
120
+ case "string":
121
+ return z.string();
122
+ case "number":
123
+ return z.number();
124
+ case "boolean":
125
+ return z.boolean();
126
+ case "date":
127
+ return z.string().datetime();
128
+ default:
129
+ throw new Error(`Unknown field type: ${fieldType}`);
130
+ }
131
+ }
132
+
133
+ export function generateDynamicSchema(
134
+ customFields: CustomFieldConfig[],
135
+ ): z.ZodObject<z.ZodRawShape> {
136
+ const shape: Record<string, z.ZodType> = {};
137
+
138
+ for (const field of customFields) {
139
+ shape[field.key] = zodTypeForFieldType(field.type);
140
+ }
141
+
142
+ return CAPBaseSchema.extend(shape);
143
+ }
144
+
145
+ export function loadSettings(path: string): SettingsConfig {
146
+ const raw = readFileSync(path, "utf-8");
147
+ const parsed = JSON.parse(raw);
148
+ return CAPSettingsSchema.parse(parsed) as SettingsConfig;
149
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "declaration": true,
7
+ "sourceMap": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src",
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ },
15
+ "include": ["src/**/*"],
16
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["src/**/*.test.ts"],
8
+ },
9
+ });