@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 +161 -0
- package/dist/.gitkeep +0 -0
- package/dist/index.js +84 -0
- package/docs/PLAN.md +96 -0
- package/docs/QUICKSTART.md +123 -0
- package/package.json +42 -0
- package/src/__tests__/dynamic-schema.test.ts +130 -0
- package/src/__tests__/repository.test.ts +195 -0
- package/src/__tests__/settings-loader.test.ts +90 -0
- package/src/__tests__/smoke.test.ts +27 -0
- package/src/index.ts +149 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +9 -0
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
|
+
}
|