opacacms 0.3.12 → 0.3.14

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Francy Santos (fhorray)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -123,31 +123,32 @@ A **Collection** is a database table + a REST API. Pure magic. ✨
123
123
 
124
124
  ```typescript
125
125
  // collections/posts.ts
126
- import { Collection, Field } from 'opacacms';
127
-
128
- export const posts = Collection.create('posts')
129
- .label('Blog Posts')
130
- .icon('FileText')
131
- .fields([
132
- Field.text('title').required().label('Post Title'),
133
- Field.slug('slug').from('title').unique(),
134
- Field.richText('content').localized(),
135
- Field.relationship('author').to('_users').single(),
136
- Field.select('status')
137
- .options([
138
- { label: 'Draft', value: 'draft' },
139
- { label: 'Published', value: 'published' },
140
- ])
141
- .defaultValue('draft'),
142
- Field.checkbox('featured').label('Featured Post'),
143
- ])
144
- .access({
126
+ import { defineCollection, z } from 'opacacms';
127
+
128
+ export const posts = defineCollection({
129
+ slug: 'posts',
130
+ label: 'Blog Posts',
131
+ admin: {
132
+ icon: 'FileText',
133
+ useAsTitle: 'title',
134
+ },
135
+ schema: z.object({
136
+ title: z.text({ label: 'Post Title' }).required(),
137
+ slug: z.slug({ from: 'title' }).unique(),
138
+ content: z.richText({ label: 'Content' }).localized(),
139
+ author: z.relationship('_users', { label: 'Author' }),
140
+ status: z
141
+ .select(['draft', 'published'], { label: 'Status' })
142
+ .default('draft'),
143
+ featured: z.boolean({ label: 'Featured Post' }),
144
+ }),
145
+ access: {
145
146
  read: () => true,
146
147
  create: ({ user }) => !!user,
147
148
  update: ({ user }) => user?.role === 'admin',
148
149
  delete: ({ user }) => user?.role === 'admin',
149
- })
150
- .hooks({
150
+ },
151
+ hooks: {
151
152
  beforeCreate: async (data) => {
152
153
  // Mutate data before insertion
153
154
  return { ...data, publishedAt: new Date().toISOString() };
@@ -155,104 +156,90 @@ export const posts = Collection.create('posts')
155
156
  afterCreate: async (doc) => {
156
157
  console.log('New post created:', doc.id);
157
158
  },
158
- });
159
+ },
160
+ });
159
161
  ```
160
162
 
161
- ### Collection Builder Methods
163
+ ### Collection Configuration
162
164
 
163
- | Method | Description |
164
- | -------------------- | ---------------------------------------------------------------- |
165
- | `.label(name)` | Sets the display name used in the Admin UI sidebar |
166
- | `.icon(name)` | [Lucide](https://lucide.dev) icon name for the sidebar |
167
- | `.fields([...])` | Defines the data structure for this collection |
168
- | `.access(rules)` | Collection-level access control |
169
- | `.hooks(fns)` | Lifecycle hooks (`beforeCreate`, `afterUpdate`, etc.) |
170
- | `.webhooks([...])` | External webhook notifications |
171
- | `.admin({...})` | Advanced Admin UI configuration (`hidden`, `disableAdmin`, etc.) |
172
- | `.versions(true)` | Enable document versioning with history |
173
- | `.timestamps({...})` | Customize timestamp field names |
165
+ | Property | Description |
166
+ | ------------ | --------------------------------------------------------- |
167
+ | `slug` | The unique identifier for the collection |
168
+ | `label` | Display name used in the Admin UI |
169
+ | `admin` | UI configuration (`icon`, `useAsTitle`, `defaultColumns`) |
170
+ | `schema` | A `z.object` defining the data structure |
171
+ | `access` | Collection-level access control |
172
+ | `hooks` | Lifecycle hooks (`beforeCreate`, `afterUpdate`, etc.) |
173
+ | `webhooks` | External webhook notifications |
174
+ | `versions` | Enable document versioning with history |
175
+ | `timestamps` | Customize or disable timestamp fields |
174
176
 
175
177
  ---
176
178
 
177
179
  ## 🧪 Field Types
178
180
 
179
- We've got everything you need to build powerful schemas:
180
-
181
- | Field | Usage | Description |
182
- | ---------------------- | ------------------------------------------- | --------------------------------------------- |
183
- | `Field.text()` | `Field.text('title')` | Simple string input |
184
- | `Field.number()` | `Field.number('price')` | Numeric input |
185
- | `Field.richText()` | `Field.richText('content')` | Block-based Lexical editor (Notion style!) 📝 |
186
- | `Field.relationship()` | `Field.relationship('author').to('_users')` | Links to another collection |
187
- | `Field.file()` | `Field.file('image')` | File/image upload ☁️ |
188
- | `Field.blocks()` | `Field.blocks('layout').blocks([...])` | Dynamic page builder 🧱 |
189
- | `Field.group()` | `Field.group('meta').fields([...])` | Nested object group |
190
- | `Field.array()` | `Field.array('tags').fields([...])` | Repeatable field group |
191
- | `Field.select()` | `Field.select('status').options([...])` | Dropdown picker |
192
- | `Field.checkbox()` | `Field.checkbox('active')` | Boolean toggle |
193
- | `Field.slug()` | `Field.slug('slug').from('title')` | Auto-generated URL slug |
194
- | `Field.date()` | `Field.date('publishedAt')` | Date/time picker |
195
- | `Field.virtual()` | `Field.virtual('fullName').resolve(...)` | Computed field (not stored) |
196
- | `Field.tabs()` | `Field.tabs('layout').tabs([...])` | UI-only grouping for the admin |
181
+ | Field | Usage | Description |
182
+ | ------------------ | ------------------------------------------------------- | --------------------------------------------- |
183
+ | `z.text()` | `z.text({ label: 'Title' })` | Simple string input |
184
+ | `z.textarea()` | `z.textarea({ label: 'Bio' })` | Multi-line text area |
185
+ | `z.number()` | `z.number({ label: 'Price' })` | Numeric input |
186
+ | `z.richText()` | `z.richText({ label: 'Content' })` | Block-based Lexical editor (Notion style!) 📝 |
187
+ | `z.relationship()` | `z.relationship('_users', { label: 'Author' })` | Links to another collection |
188
+ | `z.file()` | `z.file({ label: 'Image' })` | File/image upload ☁️ |
189
+ | `z.blocks()` | `z.blocks([hero, gallery], { label: 'Layout' })` | Dynamic page builder 🧱 |
190
+ | `z.group()` | `z.group({ meta1: z.text(), meta2: z.text() })` | Nested object group |
191
+ | `z.array()` | `z.array(z.object({ tag: z.text() }))` | Repeatable field array |
192
+ | `z.select()` | `z.select(['draft', 'published'], { label: 'Status' })` | Dropdown picker |
193
+ | `z.boolean()` | `z.boolean({ label: 'Active' })` | Boolean toggle |
194
+ | `z.slug()` | `z.slug({ from: 'title', label: 'Slug' })` | Auto-generated URL slug |
195
+ | `z.date()` | `z.date({ label: 'Published At' })` | Date/time picker |
196
+ | `z.tabs()` | `z.tabs({ SEO: z.object({...}) })` | UI-only grouping for the admin |
197
+ | `z.join()` | `z.join('posts', { foreignKey: 'author' })` | Virtual reversed relationship (read-only) |
198
+ | `z.password()` | `z.password({ label: 'Password' })` | Secure, hashed input |
197
199
 
198
200
  ### Common Field Methods
199
201
 
200
202
  Every field type inherits these chainable methods from the base builder:
201
203
 
202
204
  ```typescript
203
- Field.text('email')
204
- .label('Email Address') // Admin UI label
205
- .placeholder('you@example.com') // Input placeholder
206
- .required() // Mark as required
205
+ z.text({ label: 'Email Address', description: 'Primary email' })
206
+ .required() // Native Zod or custom required condition
207
207
  .unique() // Unique constraint in the DB
208
208
  .localized() // Enable per-locale values (i18n)
209
- .defaultValue('hello@world.com') // Default value
210
- .validate(z.string().email()) // Custom validation (function or Zod)
211
- .access({ readOnly: true }) // Field-level access control
212
- .description('Primary email') // Help text below the field
213
- .hidden() // Hide from Admin UI
214
- .readOnly() // Read-only in Admin UI
215
- .admin({ components: { Field: 'my-custom-field' } }); // Custom component
209
+ .default('hello@world.com') // Native Zod default value
210
+ .email('Invalid email address') // Native Zod validation
211
+ .cms({
212
+ admin: {
213
+ readOnly: true,
214
+ hidden: false,
215
+ components: { Field: 'my-custom-field' },
216
+ },
217
+ }); // Advanced CMS and Admin UI options
216
218
  ```
217
219
 
218
220
  ---
219
221
 
220
222
  ## ✅ Validation
221
223
 
222
- OpacaCMS supports **granular field validation** via the `.validate()` method. You can pass either a **custom function** or a **Zod schema** they're fully interchangeable.
223
-
224
- ### Custom Function Validation
225
-
226
- Return `true` to pass, or a `string` error message to fail:
227
-
228
- ```typescript
229
- Field.text('username').validate((value) => {
230
- if (value === 'admin') return "Username 'admin' is reserved";
231
- return true;
232
- });
233
- ```
224
+ OpacaCMS natively supports validation since the entire schema is built using Zod. You can chain standard Zod validation methods directly to your fields.
234
225
 
235
226
  ### Zod Schema Validation
236
227
 
237
- Pass any `z.ZodTypeAny` schema directly. Errors are automatically mapped:
228
+ Errors are automatically mapped and handled by the API and Admin UI:
238
229
 
239
230
  ```typescript
240
231
  import { z } from 'opacacms';
241
232
 
242
- Field.text('cpf').validate(
243
- z.string().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format'),
244
- );
233
+ // Regular expressions
234
+ z.text().regex(/^\d{3}\.\d{3}\.\d{3}-\d{2}$/, 'Invalid CPF format');
245
235
 
246
- Field.text('password').validate(
247
- z
248
- .string()
249
- .min(8, 'Password must be at least 8 characters')
250
- .regex(/[A-Z]/, 'Must contain at least one uppercase letter'),
251
- );
236
+ // String constraints
237
+ z.text()
238
+ .min(8, 'Password must be at least 8 characters')
239
+ .regex(/[A-Z]/, 'Must contain at least one uppercase letter');
252
240
 
253
- Field.text('email')
254
- .required()
255
- .validate(z.string().email('Invalid email address'));
241
+ // Built-in format validators
242
+ z.text().required().email('Invalid email address');
256
243
  ```
257
244
 
258
245
  > **💡 Tip:** Zod validation integrates seamlessly with `.required()`. The built-in requirement check runs first, then your Zod schema validates the value shape.
@@ -264,17 +251,24 @@ Field.text('email')
264
251
  Globals are **singleton documents** — perfect for site settings, navigation, footers, and other one-of-a-kind configs.
265
252
 
266
253
  ```typescript
267
- import { Global, Field } from 'opacacms';
254
+ import { defineGlobal, z } from 'opacacms';
268
255
 
269
- export const siteSettings = Global.create('site-settings')
270
- .label('Site Settings')
271
- .icon('Settings')
272
- .fields([
273
- Field.text('siteName').required(),
274
- Field.text('tagline').localized(),
275
- Field.file('logo'),
276
- Field.group('social').fields([Field.text('twitter'), Field.text('github')]),
277
- ]);
256
+ export const siteSettings = defineGlobal({
257
+ slug: 'site-settings',
258
+ label: 'Site Settings',
259
+ admin: {
260
+ icon: 'Settings',
261
+ },
262
+ schema: z.object({
263
+ siteName: z.text().required().default('My Shiny App'),
264
+ tagline: z.text().localized(),
265
+ logo: z.file(),
266
+ social: z.group({
267
+ twitter: z.text(),
268
+ github: z.text(),
269
+ }),
270
+ }),
271
+ });
278
272
  ```
279
273
 
280
274
  API: `GET /api/globals/site-settings` and `PUT /api/globals/site-settings`
@@ -288,25 +282,31 @@ Secure your data with simple functions at both **collection** and **field** leve
288
282
  ### Collection-Level Access
289
283
 
290
284
  ```typescript
291
- .access({
292
- read: ({ user }) => !!user, // Logged in? You're good.
293
- create: ({ user }) => user?.role === 'admin', // Only admins please!
294
- update: ({ data, user }) => data.ownerId === user.id, // Only your own stuff.
295
- delete: ({ user }) => user?.role === 'admin',
296
- requireApiKey: true, // Require API key for programmatic access
297
- })
285
+ defineCollection({
286
+ // ...
287
+ access: {
288
+ read: ({ user }) => !!user, // Logged in? You're good.
289
+ create: ({ user }) => user?.role === 'admin', // Only admins please!
290
+ update: ({ data, user }) => data.ownerId === user.id, // Only your own stuff.
291
+ delete: ({ user }) => user?.role === 'admin',
292
+ requireApiKey: true, // Require API key for programmatic access
293
+ },
294
+ });
298
295
  ```
299
296
 
300
297
  ### Field-Level Access
301
298
 
302
- Control visibility and editability per-field:
299
+ Control visibility and editability per-field using Zod conditional methods and CMS metadata:
303
300
 
304
301
  ```typescript
305
- Field.text('internalNotes').access({
306
- hidden: ({ user }) => user?.role !== 'admin', // Only admins see this
307
- readOnly: ({ operation }) => operation === 'update', // Editable only on create
308
- disabled: false,
309
- });
302
+ z.text({ label: 'Internal Notes' })
303
+ .condition((data) => data.role === 'admin') // Only show if another field matches
304
+ .cms({
305
+ admin: {
306
+ hidden: true, // Hide from Admin UI
307
+ readOnly: false,
308
+ },
309
+ });
310
310
  ```
311
311
 
312
312
  ### Role-Based Access Control (RBAC)
@@ -334,30 +334,33 @@ access: {
334
334
  Hooks let you run side-effects at key points in the document lifecycle. They receive the document data and can mutate it before persistence.
335
335
 
336
336
  ```typescript
337
- .hooks({
338
- beforeCreate: async (data) => {
339
- // Transform or enrich data before saving
340
- data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
341
- return data;
342
- },
343
- afterCreate: async (doc) => {
344
- // Side-effects after the document is saved
345
- await sendWelcomeEmail(doc.email);
346
- },
347
- beforeUpdate: async (data) => {
348
- data.updatedBy = 'system';
349
- return data;
350
- },
351
- afterUpdate: async (doc) => {
352
- await invalidateCache(`/posts/${doc.slug}`);
353
- },
354
- beforeDelete: async (id) => {
355
- await archiveDocument(id);
356
- },
357
- afterDelete: async (id) => {
358
- console.log(`Document ${id} deleted`);
337
+ defineCollection({
338
+ // ...
339
+ hooks: {
340
+ beforeCreate: async (data) => {
341
+ // Transform or enrich data before saving
342
+ data.slug = data.title.toLowerCase().replace(/\s+/g, '-');
343
+ return data;
344
+ },
345
+ afterCreate: async (doc) => {
346
+ // Side-effects after the document is saved
347
+ await sendWelcomeEmail(doc.email);
348
+ },
349
+ beforeUpdate: async (data) => {
350
+ data.updatedBy = 'system';
351
+ return data;
352
+ },
353
+ afterUpdate: async (doc) => {
354
+ await invalidateCache(`/posts/${doc.slug}`);
355
+ },
356
+ beforeDelete: async (id) => {
357
+ await archiveDocument(id);
358
+ },
359
+ afterDelete: async (id) => {
360
+ console.log(`Document ${id} deleted`);
361
+ },
359
362
  },
360
- })
363
+ });
361
364
  ```
362
365
 
363
366
  ---
@@ -367,24 +370,25 @@ Hooks let you run side-effects at key points in the document lifecycle. They rec
367
370
  Send HTTP notifications to external services when documents change. Webhooks run in the background using `waitUntil` on supported runtimes (Cloudflare Workers, Vercel Edge).
368
371
 
369
372
  ```typescript
370
- import { defineCollection } from 'opacacms';
371
-
372
373
  defineCollection({
373
374
  slug: 'orders',
374
375
  webhooks: [
375
- {
376
- url: 'https://hooks.slack.com/services/xxx',
377
- events: ['afterCreate', 'afterUpdate'],
378
- headers: { Authorization: 'Bearer my-token' },
379
- },
380
- {
381
- url: 'https://api.example.com/webhooks/orders',
382
- events: ['afterDelete'],
383
- },
384
- ]);
376
+ {
377
+ type: 'outgoing',
378
+ url: 'https://hooks.slack.com/services/xxx',
379
+ events: ['after:create', 'after:update'],
380
+ headers: { Authorization: 'Bearer my-token' },
381
+ },
382
+ {
383
+ type: 'outgoing',
384
+ url: 'https://api.example.com/webhooks/orders',
385
+ events: ['after:delete'],
386
+ },
387
+ ],
388
+ });
385
389
  ```
386
390
 
387
- Supported events: `afterCreate`, `afterUpdate`, `afterDelete`.
391
+ Supported events: `after:create`, `after:update`, `after:delete`. OpacaCMS also supports `incoming` webhooks.
388
392
 
389
393
  ---
390
394
 
@@ -393,9 +397,11 @@ Supported events: `afterCreate`, `afterUpdate`, `afterDelete`.
393
397
  Enable document versioning to keep a full history of changes. Each save creates a snapshot that can be restored later.
394
398
 
395
399
  ```typescript
396
- Collection.create('posts')
397
- .versions(true) // That's it!
398
- .fields([...])
400
+ defineCollection({
401
+ slug: 'posts',
402
+ versions: { drafts: true, maxRevisions: 10 },
403
+ schema: z.object({...})
404
+ });
399
405
  ```
400
406
 
401
407
  ### Version API
@@ -411,13 +417,27 @@ The admin UI provides a visual "Versions" panel where editors can browse and res
411
417
 
412
418
  ## 🧮 Virtual Fields
413
419
 
414
- Virtual fields are **computed at read-time** and never stored in the database. Perfect for derived values, aggregated data, or API mashups.
420
+ Virtual fields are **computed at read-time** and never stored in the database. Perfect for derived values, aggregated data, or API mashups. Use `z.helpers()` to infer the collection types for type-safe resolving.
415
421
 
416
422
  ```typescript
417
- import { Field } from 'opacacms';
423
+ const userShape = z.object({
424
+ firstName: z.text(),
425
+ lastName: z.text(),
426
+ });
418
427
 
419
- Field.virtual('fullName').resolve(async ({ data, user, req }) => {
420
- return `${data.firstName} ${data.lastName}`;
428
+ const { meta } = z.helpers(userShape);
429
+
430
+ export const users = defineCollection({
431
+ slug: 'users',
432
+ schema: z.object({
433
+ ...userShape.shape,
434
+ fullName: meta(z.text(), {
435
+ isVirtual: true,
436
+ resolve: async ({ data, user, req }) => {
437
+ return `${data.firstName} ${data.lastName}`;
438
+ },
439
+ }),
440
+ }),
421
441
  });
422
442
  ```
423
443
 
@@ -494,12 +514,13 @@ logger.info('Custom route hit');
494
514
 
495
515
  ## 🗄 Database Adapters
496
516
 
497
- OpacaCMS provides first-class adapters for multiple database engines. All adapters implement the same interface, so switching is as simple as changing one line.
517
+ OpacaCMS provides first-class adapters for multiple database engines.
498
518
 
499
519
  | Adapter | Import | Usage |
500
520
  | ------------- | ------------- | --------------------------------- |
501
521
  | SQLite (Bun) | `opacacms/db` | `createSQLiteAdapter('local.db')` |
502
522
  | Cloudflare D1 | `opacacms/db` | `createD1Adapter(env.DB)` |
523
+ | PostgreSQL | `opacacms/db` | `createPostgresAdapter(url)` |
503
524
 
504
525
  ---
505
526
 
@@ -552,8 +573,8 @@ i18n: {
552
573
  }
553
574
 
554
575
  // Field
555
- Field.text('title').localized()
556
- Field.richText('content').localized()
576
+ z.text({ label: 'Title' }).localized()
577
+ z.richText({ label: 'Content' }).localized()
557
578
  ```
558
579
 
559
580
  ### Locale Selection
@@ -583,7 +604,7 @@ This is where OpacaCMS shines. You can replace any field UI with your own **Reac
583
604
 
584
605
  ```tsx
585
606
  // MyColorPicker.tsx
586
- import { defineReactField } from 'opacacms';
607
+ import { defineReactField } from 'opacacms/admin';
587
608
 
588
609
  const ColorPicker = ({ value, onChange }) => (
589
610
  <input
@@ -600,7 +621,7 @@ defineReactField('my-color-picker', ColorPicker);
600
621
 
601
622
  ```tsx
602
623
  // MyVuePicker.vue
603
- import { defineVueField } from 'opacacms';
624
+ import { defineVueField } from 'opacacms/admin';
604
625
  import { createApp } from 'vue';
605
626
  import MyVueComponent from './MyVueComponent.vue';
606
627
 
@@ -610,9 +631,11 @@ defineVueField('my-vue-picker', MyVueComponent, { createApp });
610
631
  ### 3️⃣ Reference in Schema
611
632
 
612
633
  ```typescript
613
- Field.text('color').admin({
614
- components: {
615
- Field: 'my-color-picker',
634
+ z.text({ label: 'Color' }).cms({
635
+ admin: {
636
+ components: {
637
+ Field: 'my-color-picker',
638
+ },
616
639
  },
617
640
  });
618
641
  ```
@@ -635,11 +658,13 @@ Collections and Fields can be further customized for the Admin UI using the `.ad
635
658
  Example:
636
659
 
637
660
  ```typescript
638
- export const InternalData = Collection.create('internal_data')
639
- .admin({
661
+ export const InternalData = defineCollection({
662
+ slug: 'internal_data',
663
+ admin: {
640
664
  hidden: true, // Only accessible via direct link
641
- })
642
- .fields([...]);
665
+ },
666
+ schema: z.object({...}),
667
+ });
643
668
  ```
644
669
 
645
670
  ---
@@ -917,8 +942,8 @@ export const myPlugin = () =>
917
942
  icon: "Activity",
918
943
  path: "/admin/my-plugin",
919
944
  render: (serverUrl) => \`
920
- <iframe
921
- src="\${serverUrl}/api/plugins/my-plugin/view"
945
+ <iframe
946
+ src="\${serverUrl}/api/plugins/my-plugin/view"
922
947
  style="width:100%; height:calc(100vh - 100px); border:none;"
923
948
  ></iframe>
924
949
  \`
@@ -929,14 +954,12 @@ export const myPlugin = () =>
929
954
 
930
955
  // Serve the Isolated HTML View with Hono/HTML
931
956
  app.get('/api/plugins/my-plugin/view', (c) => {
932
- return c.html(html`
933
- <body
934
- style="background: #f6f9fc; padding: 40px; font-family: sans-serif;"
935
- >
957
+ return (c.header('Content-Type', 'text/html'), c.body(\`
958
+ <body style="background: #f6f9fc; padding: 40px; font-family: sans-serif;">
936
959
  <h1>Modern Plugin UI</h1>
937
960
  <p>Isolated from CMS styles with zero boilerplate.</p>
938
961
  </body>
939
- `);
962
+ \`));
940
963
  });
941
964
  },
942
965
 
@@ -2,7 +2,7 @@ import { type VariantProps } from "class-variance-authority";
2
2
  import type * as React from "react";
3
3
  import "../../styles/button.scss";
4
4
  declare const buttonVariants: (props?: ({
5
- variant?: "default" | "link" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
5
+ variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
6
6
  size?: "default" | "icon" | "sm" | "lg" | "icon-sm" | "icon-xs" | "icon-lg" | "xs" | null | undefined;
7
7
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
8
  declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
@@ -335,6 +335,7 @@ class D1Adapter extends BaseDatabaseAdapter {
335
335
  }
336
336
  }
337
337
  const totalPages = Math.ceil(total / limit);
338
+ const hasNextPage = page * limit < total;
338
339
  return {
339
340
  docs: docs.filter(Boolean),
340
341
  totalDocs: total,
@@ -342,11 +343,11 @@ class D1Adapter extends BaseDatabaseAdapter {
342
343
  totalPages,
343
344
  page,
344
345
  pagingCounter: offset + 1,
345
- hasNextPage: page * limit < total,
346
+ hasNextPage,
346
347
  hasPrevPage: page > 1 || !!options?.after || !!options?.before,
347
348
  prevPage: page > 1 ? page - 1 : null,
348
349
  nextPage: page < totalPages ? page + 1 : null,
349
- nextCursor: docs.length > 0 ? docs[docs.length - 1][cursorColumn] : null,
350
+ nextCursor: docs.length > 0 && hasNextPage ? docs[docs.length - 1][cursorColumn] : null,
350
351
  prevCursor: docs.length > 0 && (page > 1 || !!options?.after || !!options?.before) ? docs[0][cursorColumn] : null
351
352
  };
352
353
  }
@@ -126,6 +126,10 @@ class BetterSQLiteAdapter extends BaseDatabaseAdapter {
126
126
  filteredData[col] = flatData[col];
127
127
  }
128
128
  }
129
+ if (!filteredData.id) {
130
+ filteredData.id = crypto.randomUUID();
131
+ flatData.id = filteredData.id;
132
+ }
129
133
  await tx.insertInto(tableName).values(filteredData).execute();
130
134
  for (const [key, values] of Object.entries(hasManyData)) {
131
135
  const joinTableName = `${tableName}_${toSnakeCase(key)}_relations`.toLowerCase();
@@ -327,6 +331,7 @@ class BetterSQLiteAdapter extends BaseDatabaseAdapter {
327
331
  }
328
332
  }
329
333
  const totalPages = Math.ceil(total / limit);
334
+ const hasNextPage = page * limit < total;
330
335
  return {
331
336
  docs: docs.filter(Boolean),
332
337
  totalDocs: total,
@@ -334,11 +339,11 @@ class BetterSQLiteAdapter extends BaseDatabaseAdapter {
334
339
  totalPages,
335
340
  page,
336
341
  pagingCounter: offset + 1,
337
- hasNextPage: page * limit < total,
342
+ hasNextPage,
338
343
  hasPrevPage: page > 1 || !!options?.after || !!options?.before,
339
344
  prevPage: page > 1 ? page - 1 : null,
340
345
  nextPage: page < totalPages ? page + 1 : null,
341
- nextCursor: docs.length > 0 ? docs[docs.length - 1][cursorColumn] : null,
346
+ nextCursor: hasNextPage && docs.length > 0 ? docs[docs.length - 1][cursorColumn] : null,
342
347
  prevCursor: docs.length > 0 && (page > 1 || !!options?.after || !!options?.before) ? docs[0][cursorColumn] : null
343
348
  };
344
349
  }