opacacms 0.1.3 → 0.1.4
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,1063 @@
|
|
|
1
|
+
# OpacaCMS
|
|
2
|
+
|
|
3
|
+
> An experimental, runtime-agnostic headless CMS built with TypeScript. Define your schema, access control, and business logic directly in code — no visual builders, no lock-in.
|
|
4
|
+
|
|
5
|
+
OpacaCMS runs natively on **Node.js, Bun, Cloudflare Workers, and Next.js** using [Hono](https://hono.dev) for routing. The schema you define becomes both your database tables and your REST API, automatically.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
- [Getting Started](#getting-started)
|
|
12
|
+
- [Project Structure](#project-structure)
|
|
13
|
+
- [Configuration](#configuration)
|
|
14
|
+
- [Collections](#collections)
|
|
15
|
+
- [Field Types](#field-types)
|
|
16
|
+
- [Globals](#globals)
|
|
17
|
+
- [Access Control](#access-control)
|
|
18
|
+
- [Hooks](#hooks)
|
|
19
|
+
- [Authentication](#authentication)
|
|
20
|
+
- [Database Adapters](#database-adapters)
|
|
21
|
+
- [Migrations](#migrations)
|
|
22
|
+
- [Storage (File Uploads)](#storage-file-uploads)
|
|
23
|
+
- [Internationalization (i18n)](#internationalization-i18n)
|
|
24
|
+
- [Custom Admin Components](#custom-admin-components)
|
|
25
|
+
- [The Client SDK](#the-client-sdk)
|
|
26
|
+
- [Runtime Integrations](#runtime-integrations)
|
|
27
|
+
- [CLI Reference](#cli-reference)
|
|
28
|
+
- [Production Build](#production-build)
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Getting Started
|
|
33
|
+
|
|
34
|
+
**Requirements:** [Bun](https://bun.sh) (recommended) or Node.js 18+.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Scaffold a new project
|
|
38
|
+
bunx opacacms init my-cms
|
|
39
|
+
|
|
40
|
+
cd my-cms
|
|
41
|
+
bun install
|
|
42
|
+
bun dev
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
To add OpacaCMS to an **existing** project:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bun add opacacms
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Project Structure
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
my-cms/
|
|
57
|
+
├── opacacms.config.ts ← schema + database + auth configuration
|
|
58
|
+
├── migrations/ ← generated migration files
|
|
59
|
+
├── opaca-types.d.ts ← generated TypeScript types (optional)
|
|
60
|
+
├── collections/ ← your collection definitions
|
|
61
|
+
│ ├── posts.ts
|
|
62
|
+
│ └── products.ts
|
|
63
|
+
├── globals/ ← your global definitions
|
|
64
|
+
│ └── site-settings.ts
|
|
65
|
+
└── src/
|
|
66
|
+
└── index.ts ← runtime entry point
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Everything is defined in `opacacms.config.ts` and exported as the **default export**.
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// opacacms.config.ts
|
|
77
|
+
import { defineConfig } from 'opacacms';
|
|
78
|
+
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
|
|
79
|
+
import { posts } from './collections/posts';
|
|
80
|
+
import { siteSettings } from './globals/site-settings';
|
|
81
|
+
|
|
82
|
+
export default defineConfig({
|
|
83
|
+
appName: 'My Blog',
|
|
84
|
+
serverURL: 'http://localhost:3000',
|
|
85
|
+
secret: process.env.OPACA_SECRET,
|
|
86
|
+
db: createSQLiteAdapter('local.db'),
|
|
87
|
+
collections: [posts.build()],
|
|
88
|
+
globals: [siteSettings.build()],
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> `defineConfig` validates your configuration at startup and throws a descriptive error if anything is misconfigured.
|
|
93
|
+
|
|
94
|
+
### Full config options
|
|
95
|
+
|
|
96
|
+
| Option | Type | Description |
|
|
97
|
+
| ---------------- | -------------------------------- | --------------------------------------------- |
|
|
98
|
+
| `appName` | `string` | Displayed in the admin UI |
|
|
99
|
+
| `serverURL` | `string` | The public URL of your application |
|
|
100
|
+
| `secret` | `string` | Signs auth tokens — use an env variable |
|
|
101
|
+
| `db` | `DatabaseAdapter` | Required. Your database adapter |
|
|
102
|
+
| `collections` | `Collection[]` | Your data schemas (call `.build()` on each) |
|
|
103
|
+
| `globals` | `Global[]` | Singleton documents (call `.build()` on each) |
|
|
104
|
+
| `storages` | `Record<string, StorageAdapter>` | Named file storage buckets |
|
|
105
|
+
| `auth` | `OpacaAuthConfig` | Auth strategies & features |
|
|
106
|
+
| `api` | `ApiConfig` | Rate limiting, max pagination limit |
|
|
107
|
+
| `i18n` | `{ locales, defaultLocale }` | Localization |
|
|
108
|
+
| `trustedOrigins` | `string[]` | For CORS and cookie security |
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Collections
|
|
113
|
+
|
|
114
|
+
A **Collection** maps directly to a database table and generates a full REST API automatically. Collections are defined using the **Collection Builder** from `opacacms/schema`.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// collections/posts.ts
|
|
118
|
+
import { Collection, Field } from 'opacacms/schema';
|
|
119
|
+
|
|
120
|
+
export const posts = Collection.create('posts') // slug → /api/posts
|
|
121
|
+
.label('Blog Posts')
|
|
122
|
+
.icon('FileText')
|
|
123
|
+
.useAsTitle('title')
|
|
124
|
+
.versions({ drafts: true, maxRevisions: 10 })
|
|
125
|
+
.admin({
|
|
126
|
+
defaultColumns: ['title', 'slug', '_status', 'createdAt'],
|
|
127
|
+
views: [
|
|
128
|
+
{ name: 'Published', filter: { _status: { equals: 'published' } } },
|
|
129
|
+
{ name: 'Drafts', filter: { _status: { equals: 'draft' } } },
|
|
130
|
+
],
|
|
131
|
+
})
|
|
132
|
+
.fields([
|
|
133
|
+
Field.text('title').localized().required(),
|
|
134
|
+
Field.slug('slug').from('title').unique(),
|
|
135
|
+
Field.richText('content').localized(),
|
|
136
|
+
Field.relationship('author').to('_users').single().displayField('name'),
|
|
137
|
+
Field.select('category')
|
|
138
|
+
.choices(['News', 'Tutorial', 'Review'])
|
|
139
|
+
.defaultValue('News'),
|
|
140
|
+
Field.date('publishedAt').label('Published At'),
|
|
141
|
+
]);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Register in your config:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// opacacms.config.ts
|
|
148
|
+
import { posts } from './collections/posts';
|
|
149
|
+
|
|
150
|
+
export default defineConfig({
|
|
151
|
+
collections: [posts.build()],
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Collection Builder — all methods
|
|
156
|
+
|
|
157
|
+
| Method | Description |
|
|
158
|
+
| ------------------------- | ----------------------------------------------- |
|
|
159
|
+
| `Collection.create(slug)` | Start a new collection |
|
|
160
|
+
| `.label(string)` | Display name in the admin UI |
|
|
161
|
+
| `.icon(IconName)` | Lucide icon for the sidebar |
|
|
162
|
+
| `.useAsTitle(fieldName)` | Which field acts as the document title |
|
|
163
|
+
| `.timestamps(bool)` | Add `createdAt` / `updatedAt` (default: `true`) |
|
|
164
|
+
| `.fields([...])` | Define fields — see [Field Types](#field-types) |
|
|
165
|
+
| `.access(rules)` | Collection-level access control |
|
|
166
|
+
| `.versions(opts)` | Enable drafts & revisions |
|
|
167
|
+
| `.webhooks([...])` | Outbound HTTP callbacks on lifecycle events |
|
|
168
|
+
| `.admin(opts)` | Admin UI settings (columns, views, etc.) |
|
|
169
|
+
| `.virtual(name, opts)` | Add a computed field (not stored in DB) |
|
|
170
|
+
| `.computed(name, opts)` | Alias for `.virtual()` |
|
|
171
|
+
| `.build()` | Compile to the raw config object |
|
|
172
|
+
|
|
173
|
+
### Auto-generated API
|
|
174
|
+
|
|
175
|
+
For a collection with slug `posts`:
|
|
176
|
+
|
|
177
|
+
| Method | Endpoint | Description |
|
|
178
|
+
| -------- | ---------------- | -------------------------- |
|
|
179
|
+
| `GET` | `/api/posts` | List documents (paginated) |
|
|
180
|
+
| `GET` | `/api/posts/:id` | Get single document |
|
|
181
|
+
| `POST` | `/api/posts` | Create document |
|
|
182
|
+
| `PATCH` | `/api/posts/:id` | Update document |
|
|
183
|
+
| `DELETE` | `/api/posts/:id` | Delete document |
|
|
184
|
+
|
|
185
|
+
**Query parameters:** `?page=1&limit=10&sort=createdAt:desc&populate=author&locale=pt-BR`
|
|
186
|
+
|
|
187
|
+
**Filtering:** `GET /api/posts?title[like]=%TypeScript%&_status[equals]=published`
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Field Types
|
|
192
|
+
|
|
193
|
+
All fields are constructed with `Field` from `opacacms/schema`. Every field starts with `Field.<type>(name)` and supports a fluent modifier chain.
|
|
194
|
+
|
|
195
|
+
### Common modifiers — available on all fields
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
Field.text('title')
|
|
199
|
+
.label('Article Title')
|
|
200
|
+
.required() // NOT NULL
|
|
201
|
+
.unique() // UNIQUE constraint
|
|
202
|
+
.localized() // stored per-locale
|
|
203
|
+
.defaultValue('Untitled')
|
|
204
|
+
.readOnly() // non-editable in admin
|
|
205
|
+
.hidden() // hidden from admin UI
|
|
206
|
+
.placeholder('Enter title…')
|
|
207
|
+
.admin({ description: 'The article title', width: '50%' })
|
|
208
|
+
.validate((val) => val.length > 3 || 'Too short');
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Primitives
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
Field.text('title').required();
|
|
215
|
+
Field.textarea('bio').placeholder('Tell us about yourself…');
|
|
216
|
+
Field.number('price').min(0).max(99999).defaultValue(0);
|
|
217
|
+
Field.boolean('active').defaultValue(true).label('Is Active?');
|
|
218
|
+
Field.date('publishedAt');
|
|
219
|
+
Field.json('metadata');
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Slug
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
Field.slug('slug').from('title').unique();
|
|
226
|
+
// 'My Article' → 'my-article'
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Select & Radio
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
Field.select('status').choices(['draft', 'published', 'archived']);
|
|
233
|
+
|
|
234
|
+
Field.select('role').choices([
|
|
235
|
+
{ label: 'Admin', value: 'admin' },
|
|
236
|
+
{ label: 'Editor', value: 'editor' },
|
|
237
|
+
{ label: 'Viewer', value: 'viewer' },
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
Field.radio('tier').choices([
|
|
241
|
+
{ label: 'Free', value: 'free' },
|
|
242
|
+
{ label: 'Pro', value: 'pro' },
|
|
243
|
+
{ label: 'Enterprise', value: 'enterprise' },
|
|
244
|
+
]);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Rich Text
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
Field.richText('body').localized();
|
|
251
|
+
// defaultMode: 'notion' (block-based) | 'simple' (toolbar)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### File Upload
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
Field.file('avatar')
|
|
258
|
+
.bucket('default') // key in config.storages
|
|
259
|
+
.allowedmime_types(['image/jpeg', 'image/png', 'image/webp'])
|
|
260
|
+
.maxSize(5 * 1024 * 1024); // 5 MB
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
The uploaded file URL is stored inline in the document:
|
|
264
|
+
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"avatar": {
|
|
268
|
+
"url": "https://cdn.example.com/photo.jpg",
|
|
269
|
+
"filename": "photo.jpg",
|
|
270
|
+
"mime_type": "image/jpeg",
|
|
271
|
+
"filesize": 204800
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Relationship
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
// Single reference
|
|
280
|
+
Field.relationship('author').to('_users').single().displayField('name');
|
|
281
|
+
|
|
282
|
+
// hasMany — creates a join table automatically
|
|
283
|
+
Field.relationship('tags').to('tags').many();
|
|
284
|
+
|
|
285
|
+
// Type-safe reference to another Collection builder
|
|
286
|
+
import { products } from './products';
|
|
287
|
+
Field.relationship('related').to(products).many();
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Join (virtual inverse relationship)
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// Lists documents from another collection that reference this one
|
|
294
|
+
Field.join('comments').collection('comments').on('post_id');
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Group (nested object)
|
|
298
|
+
|
|
299
|
+
Flattened as `preferences__theme` in the database, returned as `{ preferences: { theme } }` in the API.
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
Field.group('preferences').fields(
|
|
303
|
+
Field.select('theme').choices(['light', 'dark', 'system']),
|
|
304
|
+
Field.boolean('notifications').defaultValue(true),
|
|
305
|
+
);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Blocks (dynamic content sections)
|
|
309
|
+
|
|
310
|
+
Each block type gets its own table. The API returns them as a single ordered array.
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
Field.blocks('content').blocks(
|
|
314
|
+
Field.block('hero')
|
|
315
|
+
.label('Hero Section')
|
|
316
|
+
.fields(
|
|
317
|
+
Field.text('headline').required(),
|
|
318
|
+
Field.text('subheadline'),
|
|
319
|
+
Field.text('ctaLabel'),
|
|
320
|
+
Field.text('ctaLink'),
|
|
321
|
+
),
|
|
322
|
+
Field.block('featureGrid')
|
|
323
|
+
.label('Feature Grid')
|
|
324
|
+
.fields(
|
|
325
|
+
Field.text('sectionTitle'),
|
|
326
|
+
Field.number('columns').defaultValue(3),
|
|
327
|
+
),
|
|
328
|
+
);
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Layout fields (admin only — no database column)
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
// Side-by-side columns
|
|
335
|
+
Field.row().fields(
|
|
336
|
+
Field.text('firstName').required(),
|
|
337
|
+
Field.text('lastName').required(),
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
// Tabbed sections
|
|
341
|
+
Field.tabs()
|
|
342
|
+
.tab('Content', Field.text('title').required(), Field.textarea('description'))
|
|
343
|
+
.tab('SEO', Field.text('metaTitle'), Field.textarea('metaDescription'));
|
|
344
|
+
|
|
345
|
+
// Collapsible section
|
|
346
|
+
Field.collapsible()
|
|
347
|
+
.label('SEO Metadata')
|
|
348
|
+
.initiallyCollapsed()
|
|
349
|
+
.fields(
|
|
350
|
+
Field.text('metaTitle'),
|
|
351
|
+
Field.textarea('metaDescription'),
|
|
352
|
+
Field.text('canonicalUrl'),
|
|
353
|
+
);
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
### Virtual / Computed
|
|
357
|
+
|
|
358
|
+
Computed at request time — never written to the database:
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
export const users = Collection.create('users')
|
|
362
|
+
.fields([
|
|
363
|
+
Field.text('firstName').required(),
|
|
364
|
+
Field.text('lastName').required(),
|
|
365
|
+
])
|
|
366
|
+
.virtual('fullName', {
|
|
367
|
+
label: 'Full Name',
|
|
368
|
+
returnType: 'string',
|
|
369
|
+
resolve: ({ data }) => `${data.firstName} ${data.lastName}`,
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Globals
|
|
376
|
+
|
|
377
|
+
A **Global** is a singleton document (one row in the database). Perfect for site settings, navigation, or any app-wide configuration.
|
|
378
|
+
|
|
379
|
+
```typescript
|
|
380
|
+
// globals/site-settings.ts
|
|
381
|
+
import { Global, Field } from 'opacacms/schema';
|
|
382
|
+
|
|
383
|
+
export const siteSettings = Global.create('site-settings')
|
|
384
|
+
.label('Site Settings')
|
|
385
|
+
.icon('Settings')
|
|
386
|
+
.fields([
|
|
387
|
+
Field.text('siteName').required().defaultValue('My Site'),
|
|
388
|
+
Field.text('email').required(),
|
|
389
|
+
Field.text('phone'),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
// globals/header.ts
|
|
393
|
+
export const headerSettings = Global.create('header')
|
|
394
|
+
.label('Header')
|
|
395
|
+
.icon('Layout')
|
|
396
|
+
.fields([
|
|
397
|
+
Field.text('siteTitle').required(),
|
|
398
|
+
Field.json('navItems').label('Navigation Items'),
|
|
399
|
+
]);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Register in your config:
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
export default defineConfig({
|
|
406
|
+
globals: [siteSettings.build(), headerSettings.build()],
|
|
407
|
+
});
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Global Builder — all methods
|
|
411
|
+
|
|
412
|
+
| Method | Description |
|
|
413
|
+
| ------------------------ | ----------------------------------------------------- |
|
|
414
|
+
| `Global.create(slug)` | Start a new global |
|
|
415
|
+
| `.label(string)` | Admin display name |
|
|
416
|
+
| `.icon(IconName)` | Lucide icon |
|
|
417
|
+
| `.timestamps(bool/opts)` | Enable/disable or rename timestamps (default: `true`) |
|
|
418
|
+
| `.fields([...])` | Field definitions |
|
|
419
|
+
| `.access(rules)` | Who can read / update this global |
|
|
420
|
+
| `.virtual(name, opts)` | Add a computed field |
|
|
421
|
+
| `.build()` | Compile to the raw config object |
|
|
422
|
+
|
|
423
|
+
**API endpoints:**
|
|
424
|
+
|
|
425
|
+
| Method | Endpoint | Description |
|
|
426
|
+
| ------- | ---------------------------- | ------------------- |
|
|
427
|
+
| `GET` | `/api/globals/site-settings` | Read the document |
|
|
428
|
+
| `PATCH` | `/api/globals/site-settings` | Update the document |
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Access Control
|
|
433
|
+
|
|
434
|
+
Access can be defined at the **collection level** and at the **field level**.
|
|
435
|
+
|
|
436
|
+
### Collection-level access
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
export const orders = Collection.create('orders')
|
|
440
|
+
.fields([...])
|
|
441
|
+
.access({
|
|
442
|
+
read: ({ user }) => !!user,
|
|
443
|
+
create: ({ user }) => user?.role === 'admin',
|
|
444
|
+
update: ({ user, data }) => user?.id === data?.userId,
|
|
445
|
+
delete: false,
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
The `access` function receives: `{ req, user, session, apiKey, data, operation }`.
|
|
450
|
+
|
|
451
|
+
### Field-level access
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
Field.number('creditScore').access({
|
|
455
|
+
readOnly: ({ user }) => user?.role !== 'admin',
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
Field.textarea('internalNotes').access({
|
|
459
|
+
hidden: ({ user }) => user?.role !== 'admin', // stripped from API responses
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Requiring API keys
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
export const webhooks = Collection.create('webhooks')
|
|
467
|
+
.access({ requireApiKey: true })
|
|
468
|
+
.fields([...]);
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Hooks
|
|
474
|
+
|
|
475
|
+
Run async logic during a document's lifecycle. Hooks can transform data before it is written or trigger side effects after.
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
export const posts = Collection.create('posts')
|
|
479
|
+
.fields([
|
|
480
|
+
Field.text('title').required(),
|
|
481
|
+
Field.slug('slug').from('title').unique(),
|
|
482
|
+
Field.number('views'),
|
|
483
|
+
])
|
|
484
|
+
.hooks({
|
|
485
|
+
beforeCreate: async (data) => {
|
|
486
|
+
data.views = 0;
|
|
487
|
+
return data;
|
|
488
|
+
},
|
|
489
|
+
afterCreate: async (doc) => {
|
|
490
|
+
await notifySlack(`New post: ${doc.title}`);
|
|
491
|
+
},
|
|
492
|
+
beforeUpdate: async (data) => data,
|
|
493
|
+
afterUpdate: async (doc) => {
|
|
494
|
+
/* ... */
|
|
495
|
+
},
|
|
496
|
+
beforeDelete: async (id) => {
|
|
497
|
+
/* ... */
|
|
498
|
+
},
|
|
499
|
+
afterDelete: async (id) => {
|
|
500
|
+
/* ... */
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
### Webhooks
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
export const products = Collection.create('products')
|
|
509
|
+
.webhooks([
|
|
510
|
+
{
|
|
511
|
+
events: ['afterCreate', 'afterUpdate', 'afterDelete'],
|
|
512
|
+
url: 'https://api.example.com/webhooks/cms',
|
|
513
|
+
headers: { 'X-Secret': process.env.WEBHOOK_SECRET! },
|
|
514
|
+
},
|
|
515
|
+
])
|
|
516
|
+
.fields([...]);
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Authentication
|
|
522
|
+
|
|
523
|
+
OpacaCMS uses [better-auth](https://better-auth.com) under the hood. Auth tables (`_users`, `_sessions`, etc.) are created automatically by migrations.
|
|
524
|
+
|
|
525
|
+
```typescript
|
|
526
|
+
export default defineConfig({
|
|
527
|
+
auth: {
|
|
528
|
+
strategies: {
|
|
529
|
+
emailPassword: true,
|
|
530
|
+
magicLink: {
|
|
531
|
+
enabled: true,
|
|
532
|
+
sendEmail: async ({ email, url }) => {
|
|
533
|
+
await sendTransactionalEmail({ to: email, loginUrl: url });
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
socialProviders: {
|
|
538
|
+
github: {
|
|
539
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
540
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
features: {
|
|
544
|
+
apiKeys: { enabled: true },
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
Auth endpoints are at `/api/auth/*`. The Admin UI provides a built-in login page.
|
|
551
|
+
|
|
552
|
+
### API Keys
|
|
553
|
+
|
|
554
|
+
When `apiKeys` is enabled, clients can authenticate using `Authorization: Bearer <key>` instead of cookies — useful for server-to-server calls and webhooks.
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Database Adapters
|
|
559
|
+
|
|
560
|
+
Install only the adapter you need:
|
|
561
|
+
|
|
562
|
+
```typescript
|
|
563
|
+
// SQLite (zero config, great for development)
|
|
564
|
+
import { createSQLiteAdapter } from 'opacacms/db/sqlite';
|
|
565
|
+
db: createSQLiteAdapter('local.db');
|
|
566
|
+
|
|
567
|
+
// SQLite via better-sqlite3 (synchronous, faster reads)
|
|
568
|
+
import { createBetterSQLiteAdapter } from 'opacacms/db/better-sqlite';
|
|
569
|
+
db: createBetterSQLiteAdapter('local.db');
|
|
570
|
+
|
|
571
|
+
// Bun's built-in SQLite
|
|
572
|
+
import { createBunSQLiteAdapter } from 'opacacms/db/bun-sqlite';
|
|
573
|
+
db: createBunSQLiteAdapter('local.db');
|
|
574
|
+
|
|
575
|
+
// PostgreSQL
|
|
576
|
+
import { createPostgresAdapter } from 'opacacms/db/postgres';
|
|
577
|
+
db: createPostgresAdapter(process.env.DATABASE_URL);
|
|
578
|
+
|
|
579
|
+
// Cloudflare D1
|
|
580
|
+
import { createD1Adapter } from 'opacacms/db/d1';
|
|
581
|
+
db: createD1Adapter(env.DB);
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Push mode (development only)
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
db: createPostgresAdapter(process.env.DATABASE_URL, {
|
|
588
|
+
push: process.env.NODE_ENV !== 'production',
|
|
589
|
+
pushDestructive: false, // if true, drops stale columns/tables
|
|
590
|
+
});
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## Migrations
|
|
596
|
+
|
|
597
|
+
For production, use file-based migrations instead of push mode.
|
|
598
|
+
|
|
599
|
+
### 1. Generate migration files
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
bunx opacacms migrate:create initial-schema
|
|
603
|
+
# → migrations/20260319120000_initial-schema.ts (Postgres/SQLite)
|
|
604
|
+
# → migrations/20260319120000_initial-schema.sql (Cloudflare D1)
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The generated file has zero external dependencies — no need to install `kysely`:
|
|
608
|
+
|
|
609
|
+
```typescript
|
|
610
|
+
// migrations/20260319120000_initial-schema.ts
|
|
611
|
+
import type { OpacaMigrationDb } from 'opacacms/db';
|
|
612
|
+
|
|
613
|
+
export async function up(db: OpacaMigrationDb): Promise<void> {
|
|
614
|
+
await db.schema
|
|
615
|
+
.createTable('posts')
|
|
616
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
617
|
+
.addColumn('title', 'text', (col) => col.notNull())
|
|
618
|
+
.addColumn('slug', 'text', (col) => col.unique())
|
|
619
|
+
.addColumn('created_at', 'text', (col) =>
|
|
620
|
+
col.defaultTo('CURRENT_TIMESTAMP'),
|
|
621
|
+
)
|
|
622
|
+
.addColumn('updated_at', 'text', (col) =>
|
|
623
|
+
col.defaultTo('CURRENT_TIMESTAMP'),
|
|
624
|
+
)
|
|
625
|
+
.execute();
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export async function down(db: OpacaMigrationDb): Promise<void> {
|
|
629
|
+
await db.schema.dropTable('posts').execute();
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### 2. Apply migrations
|
|
634
|
+
|
|
635
|
+
```bash
|
|
636
|
+
bunx opacacms migrate
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### 3. Check status
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
bunx opacacms migrate:status
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Cloudflare D1
|
|
646
|
+
|
|
647
|
+
```bash
|
|
648
|
+
# Apply locally (dev)
|
|
649
|
+
bunx opacacms migrate:d1 --local --binding DB
|
|
650
|
+
|
|
651
|
+
# Apply to production
|
|
652
|
+
bunx opacacms migrate:d1 --remote --binding DB
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## Storage (File Uploads)
|
|
658
|
+
|
|
659
|
+
Define storage buckets in your config. Reference them by name in `Field.file()`.
|
|
660
|
+
|
|
661
|
+
### S3 / AWS
|
|
662
|
+
|
|
663
|
+
```typescript
|
|
664
|
+
import { createS3Adapter } from 'opacacms/storage/s3';
|
|
665
|
+
|
|
666
|
+
storages: {
|
|
667
|
+
default: createS3Adapter({
|
|
668
|
+
region: process.env.AWS_REGION!,
|
|
669
|
+
bucket: process.env.S3_BUCKET!,
|
|
670
|
+
credentials: {
|
|
671
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
672
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
673
|
+
},
|
|
674
|
+
publicUrl: 'https://cdn.example.com',
|
|
675
|
+
}),
|
|
676
|
+
}
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### Cloudflare R2
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
import { createR2Adapter } from 'opacacms/storage/r2';
|
|
683
|
+
|
|
684
|
+
storages: {
|
|
685
|
+
default: createR2Adapter({
|
|
686
|
+
bucketBinding: env.BUCKET,
|
|
687
|
+
publicUrl: 'https://assets.example.com',
|
|
688
|
+
}),
|
|
689
|
+
secure: createR2Adapter({
|
|
690
|
+
bucketBinding: env.SECURE_BUCKET,
|
|
691
|
+
publicUrl: 'https://assets.example.com',
|
|
692
|
+
}),
|
|
693
|
+
}
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
### Local filesystem (development)
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
import { createLocalAdapter } from 'opacacms/storage/local';
|
|
700
|
+
|
|
701
|
+
storages: {
|
|
702
|
+
default: createLocalAdapter({
|
|
703
|
+
directory: './public/uploads',
|
|
704
|
+
publicUrl: 'http://localhost:3000/uploads',
|
|
705
|
+
}),
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
---
|
|
710
|
+
|
|
711
|
+
## Internationalization (i18n)
|
|
712
|
+
|
|
713
|
+
```typescript
|
|
714
|
+
export default defineConfig({
|
|
715
|
+
i18n: {
|
|
716
|
+
locales: ['en', 'pt-BR', 'es'],
|
|
717
|
+
defaultLocale: 'pt-BR',
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Mark individual fields as localized with `.localized()`:
|
|
723
|
+
|
|
724
|
+
```typescript
|
|
725
|
+
Field.text('title').localized().required();
|
|
726
|
+
Field.richText('body').localized();
|
|
727
|
+
Field.text('slug'); // not localized — same value across all locales
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
The API resolves the locale from the `x-opaca-locale` header or `?locale=` query param, falling back to `defaultLocale`. Pass `?locale=all` to get all locales at once.
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## Custom Admin Components
|
|
735
|
+
|
|
736
|
+
You can replace any field's input or table cell with your own component. This works with **any framework** — React, Vue, Vanilla JS — by implementing the `defineCustomField` adapter.
|
|
737
|
+
|
|
738
|
+
### 1. Register a custom field tag
|
|
739
|
+
|
|
740
|
+
```typescript
|
|
741
|
+
// src/admin/my-components.ts
|
|
742
|
+
import { defineCustomField } from 'opacacms/admin';
|
|
743
|
+
import { createRoot } from 'react-dom/client';
|
|
744
|
+
import MyColorPicker from './MyColorPicker';
|
|
745
|
+
|
|
746
|
+
defineCustomField('my-color-picker', {
|
|
747
|
+
mount(container, props) {
|
|
748
|
+
(container as any)._root = createRoot(container);
|
|
749
|
+
(container as any)._root.render(<MyColorPicker {...props} />);
|
|
750
|
+
},
|
|
751
|
+
update(container, props) {
|
|
752
|
+
(container as any)._root.render(<MyColorPicker {...props} />);
|
|
753
|
+
},
|
|
754
|
+
unmount(container) {
|
|
755
|
+
(container as any)._root.unmount();
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
The `props` object your component receives:
|
|
761
|
+
|
|
762
|
+
| Prop | Type | Description |
|
|
763
|
+
| ------------- | --------------- | ------------------------------------------- |
|
|
764
|
+
| `value` | `any` | Current field value |
|
|
765
|
+
| `onChange` | `(val) => void` | Call to update the value |
|
|
766
|
+
| `fieldConfig` | `object` | The field's schema definition |
|
|
767
|
+
| `disabled` | `boolean` | True if access control disabled the field |
|
|
768
|
+
| `readOnly` | `boolean` | True if access control made it read-only |
|
|
769
|
+
| `error` | `string?` | Validation error message |
|
|
770
|
+
| `parentData` | `object?` | All other field values in the same document |
|
|
771
|
+
|
|
772
|
+
### 2. Reference in the schema
|
|
773
|
+
|
|
774
|
+
```typescript
|
|
775
|
+
Field.text('primaryColor').admin({
|
|
776
|
+
components: {
|
|
777
|
+
Field: 'my-color-picker', // used in the edit form
|
|
778
|
+
Cell: 'my-color-cell', // used in the collection list table
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
### 3. Load your script
|
|
784
|
+
|
|
785
|
+
Your custom component script must be loaded in the browser before the admin UI mounts. For Cloudflare Workers, serve the file as a static asset and include it as a `<script>` tag in the admin HTML page. For Next.js, import it in your admin layout.
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## The Client SDK
|
|
790
|
+
|
|
791
|
+
Use the typed client to query your CMS from any frontend:
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
import { createClient } from 'opacacms/client';
|
|
795
|
+
|
|
796
|
+
const cms = createClient({ baseURL: 'https://my-cms.example.com' });
|
|
797
|
+
|
|
798
|
+
// List with filtering and pagination
|
|
799
|
+
const result = await cms.collections.posts.find({
|
|
800
|
+
'_status[equals]': 'published',
|
|
801
|
+
limit: 10,
|
|
802
|
+
page: 1,
|
|
803
|
+
});
|
|
804
|
+
console.log(result.docs); // array of documents
|
|
805
|
+
console.log(result.totalPages);
|
|
806
|
+
|
|
807
|
+
// Single document
|
|
808
|
+
const post = await cms.collections.posts.findOne('abc-123');
|
|
809
|
+
|
|
810
|
+
// Create / Update / Delete
|
|
811
|
+
const newPost = await cms.collections.posts.create({ title: 'Hello' });
|
|
812
|
+
await cms.collections.posts.update('abc-123', { title: 'Updated' });
|
|
813
|
+
await cms.collections.posts.delete('abc-123');
|
|
814
|
+
|
|
815
|
+
// Globals
|
|
816
|
+
const settings = await cms.globals['site-settings'].get();
|
|
817
|
+
await cms.globals['site-settings'].update({ siteName: 'New Name' });
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
### Typed client with generated types
|
|
821
|
+
|
|
822
|
+
```bash
|
|
823
|
+
bunx opacacms generate:types --url http://localhost:3000 --out opaca-types.d.ts
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
```typescript
|
|
827
|
+
import { createClient } from 'opacacms/client';
|
|
828
|
+
import type { GeneratedTypes } from './opaca-types';
|
|
829
|
+
|
|
830
|
+
const cms = createClient<GeneratedTypes>({ baseURL: '...' });
|
|
831
|
+
|
|
832
|
+
// IDE autocomplete now knows the exact shape of every collection
|
|
833
|
+
const post = await cms.collections.posts.findOne('123');
|
|
834
|
+
// post.title → string ✓
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
---
|
|
838
|
+
|
|
839
|
+
## Runtime Integrations
|
|
840
|
+
|
|
841
|
+
### Next.js
|
|
842
|
+
|
|
843
|
+
Create a catch-all API route:
|
|
844
|
+
|
|
845
|
+
```
|
|
846
|
+
src/app/api/[[...route]]/route.ts
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
```typescript
|
|
850
|
+
// src/app/api/[[...route]]/route.ts
|
|
851
|
+
import { createNextHandler } from 'opacacms/runtimes/next';
|
|
852
|
+
import config from '../../../opacacms.config';
|
|
853
|
+
|
|
854
|
+
export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } =
|
|
855
|
+
createNextHandler(config);
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
```typescript
|
|
859
|
+
// opacacms.config.ts
|
|
860
|
+
import { defineConfig } from 'opacacms';
|
|
861
|
+
import { createPostgresAdapter } from 'opacacms/db/postgres';
|
|
862
|
+
|
|
863
|
+
export default defineConfig({
|
|
864
|
+
appName: 'My Next.js CMS',
|
|
865
|
+
serverURL: process.env.NEXT_PUBLIC_API_URL!,
|
|
866
|
+
secret: process.env.OPACA_SECRET!,
|
|
867
|
+
db: createPostgresAdapter(process.env.DATABASE_URL!),
|
|
868
|
+
collections: [
|
|
869
|
+
/* posts.build(), ... */
|
|
870
|
+
],
|
|
871
|
+
});
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
Serve the Admin UI from a Next.js page:
|
|
875
|
+
|
|
876
|
+
```tsx
|
|
877
|
+
// src/app/admin/[[...all]]/page.tsx
|
|
878
|
+
export default function AdminPage() {
|
|
879
|
+
return (
|
|
880
|
+
<html>
|
|
881
|
+
<body>
|
|
882
|
+
<opaca-admin server-url={process.env.NEXT_PUBLIC_API_URL} />
|
|
883
|
+
<script type="module" src="/opacacms/webcomponent.js" />
|
|
884
|
+
</body>
|
|
885
|
+
</html>
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
### Cloudflare Workers
|
|
893
|
+
|
|
894
|
+
```typescript
|
|
895
|
+
// src/index.ts
|
|
896
|
+
import { createCloudflareWorkersHandler } from 'opacacms/runtimes/cloudflare-workers';
|
|
897
|
+
import getConfig from './opacacms.config';
|
|
898
|
+
|
|
899
|
+
const app = createCloudflareWorkersHandler(getConfig);
|
|
900
|
+
|
|
901
|
+
// Serve the Admin UI for /admin/* routes
|
|
902
|
+
app.get('/admin*', (c) => {
|
|
903
|
+
return c.html(`
|
|
904
|
+
<!DOCTYPE html>
|
|
905
|
+
<html>
|
|
906
|
+
<head>
|
|
907
|
+
<title>Admin</title>
|
|
908
|
+
<link rel="stylesheet" href="/admin.css">
|
|
909
|
+
</head>
|
|
910
|
+
<body>
|
|
911
|
+
<opaca-admin server-url="${new URL(c.req.url).origin}"></opaca-admin>
|
|
912
|
+
<script type="module" src="/webcomponent.js"></script>
|
|
913
|
+
</body>
|
|
914
|
+
</html>
|
|
915
|
+
`);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
export default app;
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
```typescript
|
|
922
|
+
// opacacms.config.ts
|
|
923
|
+
import { defineConfig } from 'opacacms';
|
|
924
|
+
import { createD1Adapter } from 'opacacms/db/d1';
|
|
925
|
+
import { createR2Adapter } from 'opacacms/storage/r2';
|
|
926
|
+
|
|
927
|
+
// Export as a function to access per-request Cloudflare bindings
|
|
928
|
+
const getConfig = (env: Env, request: Request) =>
|
|
929
|
+
defineConfig({
|
|
930
|
+
appName: 'My Workers CMS',
|
|
931
|
+
serverURL: new URL(request.url).origin,
|
|
932
|
+
secret: env.OPACA_SECRET,
|
|
933
|
+
trustedOrigins: ['https://my-frontend.com'],
|
|
934
|
+
db: createD1Adapter(env.DB),
|
|
935
|
+
storages: {
|
|
936
|
+
default: createR2Adapter({
|
|
937
|
+
bucketBinding: env.BUCKET,
|
|
938
|
+
publicUrl: new URL(request.url).origin,
|
|
939
|
+
}),
|
|
940
|
+
},
|
|
941
|
+
auth: {
|
|
942
|
+
features: { apiKeys: { enabled: true } },
|
|
943
|
+
},
|
|
944
|
+
i18n: {
|
|
945
|
+
locales: ['en', 'pt-BR'],
|
|
946
|
+
defaultLocale: 'pt-BR',
|
|
947
|
+
},
|
|
948
|
+
collections: [
|
|
949
|
+
/* posts.build(), ... */
|
|
950
|
+
],
|
|
951
|
+
globals: [
|
|
952
|
+
/* siteSettings.build(), ... */
|
|
953
|
+
],
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
export default getConfig;
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
```jsonc
|
|
960
|
+
// wrangler.jsonc
|
|
961
|
+
{
|
|
962
|
+
"name": "my-cms",
|
|
963
|
+
"compatibility_date": "2024-09-23",
|
|
964
|
+
"d1_databases": [
|
|
965
|
+
{ "binding": "DB", "database_name": "my-cms-db", "database_id": "..." },
|
|
966
|
+
],
|
|
967
|
+
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-cms-assets" }],
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
### Bun (standalone)
|
|
974
|
+
|
|
975
|
+
```typescript
|
|
976
|
+
// src/index.ts
|
|
977
|
+
import { createBunHandler } from 'opacacms/runtimes/bun';
|
|
978
|
+
import config from './opacacms.config';
|
|
979
|
+
|
|
980
|
+
const { init } = createBunHandler(config, { port: 3000 });
|
|
981
|
+
await init();
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
---
|
|
985
|
+
|
|
986
|
+
### Node.js
|
|
987
|
+
|
|
988
|
+
```typescript
|
|
989
|
+
// src/index.ts
|
|
990
|
+
import { createNodeHandler } from 'opacacms/runtimes/node';
|
|
991
|
+
import config from './opacacms.config';
|
|
992
|
+
|
|
993
|
+
const { init } = createNodeHandler(config, { port: 3000 });
|
|
994
|
+
await init();
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
---
|
|
998
|
+
|
|
999
|
+
## CLI Reference
|
|
1000
|
+
|
|
1001
|
+
The CLI auto-detects your config at `opacacms.config.ts`, `src/opacacms.config.ts`, or `index.ts`.
|
|
1002
|
+
|
|
1003
|
+
```bash
|
|
1004
|
+
# Scaffold a new project
|
|
1005
|
+
bunx opacacms init my-project
|
|
1006
|
+
|
|
1007
|
+
# Generate migrations from your current schema
|
|
1008
|
+
bunx opacacms migrate:create <name>
|
|
1009
|
+
|
|
1010
|
+
# Apply pending migrations (Postgres / SQLite)
|
|
1011
|
+
bunx opacacms migrate
|
|
1012
|
+
|
|
1013
|
+
# Check which migrations have been applied
|
|
1014
|
+
bunx opacacms migrate:status
|
|
1015
|
+
|
|
1016
|
+
# Apply SQL migrations to Cloudflare D1
|
|
1017
|
+
bunx opacacms migrate:d1 --local # local dev
|
|
1018
|
+
bunx opacacms migrate:d1 --remote # production
|
|
1019
|
+
|
|
1020
|
+
# Seed the database with fake data
|
|
1021
|
+
bunx opacacms seed --count 50
|
|
1022
|
+
bunx opacacms seed:assets --count 20
|
|
1023
|
+
|
|
1024
|
+
# Generate TypeScript types from a running instance
|
|
1025
|
+
bunx opacacms generate:types --url http://localhost:3000 --out opaca-types.d.ts
|
|
1026
|
+
|
|
1027
|
+
# Use a custom config file path
|
|
1028
|
+
bunx opacacms migrate:create --config ./path/to/config.ts
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
---
|
|
1032
|
+
|
|
1033
|
+
## Production Build
|
|
1034
|
+
|
|
1035
|
+
### Disable push mode
|
|
1036
|
+
|
|
1037
|
+
```typescript
|
|
1038
|
+
db: createPostgresAdapter(process.env.DATABASE_URL, {
|
|
1039
|
+
push: false, // never auto-alter schema in production
|
|
1040
|
+
});
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### Required environment variables
|
|
1044
|
+
|
|
1045
|
+
```bash
|
|
1046
|
+
OPACA_SECRET=<random-32-char-string> # required
|
|
1047
|
+
DATABASE_URL=postgres://... # for Postgres
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
### Deploy flow
|
|
1051
|
+
|
|
1052
|
+
```bash
|
|
1053
|
+
# Generate + apply migrations
|
|
1054
|
+
bunx opacacms migrate:create latest
|
|
1055
|
+
bunx opacacms migrate # Postgres / SQLite
|
|
1056
|
+
# or
|
|
1057
|
+
bunx opacacms migrate:d1 --remote # Cloudflare D1
|
|
1058
|
+
|
|
1059
|
+
# Start the server
|
|
1060
|
+
node dist/index.js
|
|
1061
|
+
# or
|
|
1062
|
+
wrangler deploy
|
|
1063
|
+
```
|
|
@@ -1249,7 +1249,6 @@ function createDatabaseInitMiddleware(config, state) {
|
|
|
1249
1249
|
}
|
|
1250
1250
|
|
|
1251
1251
|
// src/server/middlewares/rate-limit.ts
|
|
1252
|
-
import { WorkersKVStore } from "@hono-rate-limiter/cloudflare";
|
|
1253
1252
|
import { rateLimiter } from "hono-rate-limiter";
|
|
1254
1253
|
function createRateLimitMiddleware(config) {
|
|
1255
1254
|
const rateLimitConfig = config.api?.rateLimit;
|
|
@@ -1277,7 +1276,10 @@ function createRateLimitMiddleware(config) {
|
|
|
1277
1276
|
if (!resolvedStore && c.env) {
|
|
1278
1277
|
const kvBindingKey = Object.keys(c.env).find((key) => key.startsWith("OPACA_") && c.env[key]?.put && c.env[key]?.get);
|
|
1279
1278
|
if (kvBindingKey) {
|
|
1280
|
-
|
|
1279
|
+
try {
|
|
1280
|
+
const { WorkersKVStore } = await import("@hono-rate-limiter/cloudflare");
|
|
1281
|
+
resolvedStore = new WorkersKVStore({ namespace: c.env[kvBindingKey] });
|
|
1282
|
+
} catch (_) {}
|
|
1281
1283
|
}
|
|
1282
1284
|
}
|
|
1283
1285
|
const limiter = rateLimiter({
|
package/dist/runtimes/bun.js
CHANGED
package/dist/runtimes/next.js
CHANGED
package/dist/runtimes/node.js
CHANGED
package/dist/server.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opacacms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "bun run ../../scripts/build.ts && tsc --emitDeclarationOnly",
|
|
@@ -145,11 +145,13 @@
|
|
|
145
145
|
}
|
|
146
146
|
},
|
|
147
147
|
"devDependencies": {
|
|
148
|
+
"@better-auth/core": "^1.5.5",
|
|
148
149
|
"@cloudflare/workers-types": "^4.20260313.1",
|
|
149
150
|
"@faker-js/faker": "^10.3.0",
|
|
150
151
|
"@happy-dom/global-registrator": "^20.8.4",
|
|
151
152
|
"@testing-library/dom": "^10.4.1",
|
|
152
153
|
"@testing-library/react": "^16.3.2",
|
|
154
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
153
155
|
"@types/bun": "latest",
|
|
154
156
|
"@types/react": "^19.2.14",
|
|
155
157
|
"@types/react-dom": "^19.2.3",
|
|
@@ -262,8 +264,6 @@
|
|
|
262
264
|
},
|
|
263
265
|
"dependencies": {
|
|
264
266
|
"@better-auth/api-key": "^1.5.5",
|
|
265
|
-
"@better-auth/core": "^1.5.5",
|
|
266
|
-
"@types/better-sqlite3": "^7.6.13",
|
|
267
267
|
"better-auth": "^1.5.5",
|
|
268
268
|
"hono": "^4.12.7",
|
|
269
269
|
"ky": "^1.14.3",
|