@kardoe/quickback 0.5.10 → 0.5.12

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.
@@ -100,42 +100,40 @@ Features live in `quickback/features/{name}/` with one file per table using `def
100
100
 
101
101
  **Important**: Legacy mode (separate schema.ts + resource.ts) is no longer supported.
102
102
 
103
- ## Example: todos.ts
103
+ ## Example: candidates.ts
104
104
 
105
105
  ```typescript
106
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
106
+ import { sqliteTable, text } from "drizzle-orm/sqlite-core";
107
107
  import { defineTable } from "@quickback/compiler";
108
108
 
109
- export const todos = sqliteTable("todos", {
110
- id: integer("id").primaryKey(),
111
- title: text("title").notNull(),
112
- description: text("description"),
113
- completed: integer("completed", { mode: "boolean" }).default(false),
114
- userId: text("user_id").notNull(),
109
+ export const candidates = sqliteTable("candidates", {
110
+ id: text("id").primaryKey(),
115
111
  organizationId: text("organization_id").notNull(),
112
+ name: text("name").notNull(),
113
+ email: text("email").notNull(),
114
+ phone: text("phone"),
115
+ resumeUrl: text("resume_url"),
116
+ source: text("source"),
116
117
  });
117
118
 
118
- export default defineTable(todos, {
119
+ export default defineTable(candidates, {
119
120
  firewall: {
120
- organization: {}, // Auto-detects 'organizationId' column
121
- owner: {}, // Auto-detects 'userId' column
121
+ organization: {},
122
122
  },
123
-
124
123
  crud: {
125
- list: { access: { roles: ["member", "admin"] } },
126
- get: { access: { roles: ["member", "admin"] } },
127
- create: { access: { roles: ["member", "admin"] } },
128
- update: { access: { roles: ["admin"] } },
129
- delete: { access: { roles: ["admin"] }, mode: "soft" },
124
+ list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
125
+ get: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
126
+ create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
127
+ update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
128
+ delete: { access: { roles: ["owner", "hiring-manager"] }, mode: "soft" },
130
129
  },
131
-
132
130
  guards: {
133
- createable: ["title", "description", "completed"],
134
- updatable: ["title", "description", "completed"],
131
+ createable: ["name", "email", "phone", "resumeUrl", "source"],
132
+ updatable: ["name", "phone"],
135
133
  },
136
-
137
134
  masking: {
138
- userId: { type: "redact", show: { roles: ["admin"] } },
135
+ email: { type: "email", show: { roles: ["hiring-manager", "recruiter"] } },
136
+ phone: { type: "phone", show: { roles: ["hiring-manager", "recruiter"] } },
139
137
  },
140
138
  });
141
139
  ```
@@ -146,9 +144,12 @@ Tables without a `defineTable()` default export get no API routes:
146
144
 
147
145
  ```typescript
148
146
  // No default export = no API routes generated
149
- export const roomAmenities = sqliteTable('room_amenities', {
150
- roomId: text('room_id').notNull(),
151
- amenityId: text('amenity_id').notNull(),
147
+ export const interviewScores = sqliteTable('interview_scores', {
148
+ applicationId: text('application_id').notNull(),
149
+ interviewerId: text('interviewer_id').notNull(),
150
+ score: integer('score').notNull(),
151
+ feedback: text('feedback'),
152
+ organizationId: text('organization_id').notNull(), // Always scope junction tables
152
153
  });
153
154
  ```
154
155
 
@@ -201,18 +202,18 @@ Role-based and record-based access control. Deny by default.
201
202
 
202
203
  ```typescript
203
204
  crud: {
204
- list: { access: { roles: ['member'] } },
205
- get: { access: { roles: ['member'] } },
206
- create: { access: { roles: ['admin', 'manager'] } },
205
+ list: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
206
+ get: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
207
+ create: { access: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
207
208
  update: {
208
209
  access: {
209
210
  or: [
210
- { roles: ['admin'] },
211
+ { roles: ['owner', 'hiring-manager'] },
211
212
  { record: { createdBy: { equals: '$ctx.userId' } } },
212
213
  ],
213
214
  },
214
215
  },
215
- delete: { access: { roles: ['admin'] } },
216
+ delete: { access: { roles: ['owner', 'hiring-manager'] } },
216
217
  }
217
218
  ```
218
219
 
@@ -243,10 +244,10 @@ Controls which fields can be modified.
243
244
 
244
245
  ```typescript
245
246
  guards: {
246
- createable: ['name', 'description'], // Allowed on POST
247
- updatable: ['description'], // Allowed on PATCH
248
- immutable: ['invoiceNumber'], // Set once, never change
249
- protected: { status: ['approve'] }, // Only via named actions
247
+ createable: ['candidateId', 'jobId', 'notes'],
248
+ updatable: ['notes'],
249
+ immutable: ['appliedAt'],
250
+ protected: { stage: ['advance-stage'] },
250
251
  }
251
252
  ```
252
253
 
@@ -281,13 +282,13 @@ export default defineTable(external_orders, {
281
282
 
282
283
  ```typescript
283
284
  masking: {
284
- ssn: {
285
- type: 'ssn', // *****6789
286
- show: { roles: ['admin', 'hr'] },
287
- },
288
285
  email: {
289
- type: 'email', // p***@e******.com
290
- show: { roles: ['admin'], or: 'owner' },
286
+ type: 'email',
287
+ show: { roles: ['hiring-manager', 'recruiter'] },
288
+ },
289
+ phone: {
290
+ type: 'phone',
291
+ show: { roles: ['hiring-manager', 'recruiter'] },
291
292
  },
292
293
  }
293
294
  ```
@@ -323,45 +324,89 @@ The compiler warns if sensitive columns aren't explicitly configured:
323
324
  Views are named projections that control which fields are visible based on role.
324
325
 
325
326
  ```typescript
326
- export default defineTable(customers, {
327
+ export default defineTable(candidates, {
327
328
  // ... firewall, guards, etc.
328
329
 
329
330
  views: {
330
- summary: {
331
- fields: ['id', 'name', 'email'],
332
- access: { roles: ['member', 'admin'] },
331
+ pipeline: {
332
+ fields: ['id', 'name', 'email', 'source'],
333
+ access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
333
334
  },
334
335
  full: {
335
- fields: ['id', 'name', 'email', 'phone', 'ssn', 'address'],
336
- access: { roles: ['admin'] },
336
+ fields: ['id', 'name', 'email', 'phone', 'resumeUrl', 'source'],
337
+ access: { roles: ['owner', 'hiring-manager', 'recruiter'] },
337
338
  },
338
339
  },
339
340
  });
340
341
  ```
341
342
 
342
343
  **Generated endpoints**:
343
- - `GET /api/v1/customers/views/summary` — returns only id, name, email
344
- - `GET /api/v1/customers/views/full` — returns all fields (admin only)
344
+ - `GET /api/v1/candidates/views/pipeline` — returns only id, name, email, source
345
+ - `GET /api/v1/candidates/views/full` — returns all fields (recruiters and above)
345
346
 
346
347
  Views support the same query parameters as list (filtering, sorting, pagination). Masking still applies to returned fields.
347
348
 
348
349
  ---
349
350
 
351
+ # References — FK Target Mapping
352
+
353
+ When FK columns don't match the target table name by convention (e.g., `vendorId` points to the `contact` table), declare explicit references:
354
+
355
+ ```typescript
356
+ export default defineTable(items, {
357
+ references: {
358
+ clientId: "contact",
359
+ vendorId: "contact",
360
+ salesCodeId: "salesCode",
361
+ taxLocationId: "taxLocation",
362
+ },
363
+ // ...
364
+ });
365
+ ```
366
+
367
+ Each key is a column name ending in `Id`, value is the camelCase target table name. These flow into schema-registry.json as `fkTarget` on each column, enabling the CMS to render typeahead/lookup inputs.
368
+
369
+ Convention-based matching (strip `Id` suffix) still works for simple cases like `projectId` → `project`. Use `references` only when the convention doesn't match.
370
+
371
+ ---
372
+
373
+ # Input Hints — CMS Form Controls
374
+
375
+ Control how the CMS renders form inputs for specific columns:
376
+
377
+ ```typescript
378
+ export default defineTable(invoice, {
379
+ inputHints: {
380
+ status: "select",
381
+ sortOrder: "radio",
382
+ isPartialPaymentDisabled: "checkbox",
383
+ headerMessage: "textarea",
384
+ footerMessage: "textarea",
385
+ },
386
+ // ...
387
+ });
388
+ ```
389
+
390
+ Available values: `select`, `multi-select`, `radio`, `checkbox`, `textarea`, `lookup`, `hidden`, `color`, `date`, `datetime`, `time`, `currency`.
391
+
392
+ Input hints are emitted in schema-registry.json as `inputHints` on the table metadata.
393
+
394
+ ---
395
+
350
396
  # Validation
351
397
 
352
398
  Field-level validation rules compiled into the API:
353
399
 
354
400
  ```typescript
355
- export default defineTable(rooms, {
401
+ export default defineTable(jobs, {
356
402
  // ... other config
357
403
 
358
404
  validation: {
359
- name: { minLength: 1, maxLength: 100 },
360
- capacity: { min: 1, max: 1000 },
361
- roomType: { enum: ['meeting', 'conference', 'breakout'] },
362
- email: { email: true },
363
- website: { url: true },
364
- code: { pattern: '^[A-Z]{3}$' },
405
+ title: { minLength: 1, maxLength: 200 },
406
+ department: { minLength: 1, maxLength: 100 },
407
+ status: { enum: ['draft', 'open', 'closed'] },
408
+ salaryMin: { min: 0 },
409
+ salaryMax: { min: 0 },
365
410
  },
366
411
  });
367
412
  ```
@@ -373,51 +418,54 @@ export default defineTable(rooms, {
373
418
  Actions are custom API endpoints for business logic beyond CRUD. Defined in a separate `actions.ts` file using `defineActions()`.
374
419
 
375
420
  ```typescript
376
- // quickback/features/todos/actions.ts
377
- import { todos } from './todos';
421
+ // quickback/features/applications/actions.ts
422
+ import { applications } from './applications';
378
423
  import { defineActions } from '@quickback/compiler';
379
424
  import { z } from 'zod';
380
425
 
381
- export default defineActions(todos, {
382
- complete: {
383
- description: "Mark todo as complete",
426
+ export default defineActions(applications, {
427
+ 'advance-stage': {
428
+ description: "Move application to the next pipeline stage",
384
429
  input: z.object({
385
- completedAt: z.string().datetime().optional(),
430
+ stage: z.enum(["screening", "interview", "offer", "hired"]),
431
+ notes: z.string().optional(),
386
432
  }),
387
- guard: {
388
- roles: ["member", "admin"],
389
- record: { completed: { equals: false } },
433
+ access: {
434
+ roles: ["owner", "hiring-manager"],
435
+ record: { stage: { notEquals: "rejected" } },
390
436
  },
391
437
  execute: async ({ db, record, ctx, input }) => {
392
- // Inline handler
393
- await db.update(todos).set({ completed: true }).where(eq(todos.id, record.id));
394
- return { success: true };
438
+ const [updated] = await db.update(applications)
439
+ .set({ stage: input.stage, notes: input.notes ?? record.notes })
440
+ .where(eq(applications.id, record.id))
441
+ .returning();
442
+ return updated;
395
443
  },
396
444
  sideEffects: "sync",
397
445
  },
398
446
 
399
- archive: {
400
- description: "Archive a todo",
401
- input: z.object({}),
402
- guard: { roles: ["admin"] },
403
- handler: "./handlers/archive", // OR: external file handler
447
+ reject: {
448
+ description: "Reject an application",
449
+ input: z.object({ reason: z.string() }),
450
+ access: { roles: ["owner", "hiring-manager"] },
451
+ handler: "./handlers/reject", // OR: external file handler
404
452
  },
405
453
  });
406
454
  ```
407
455
 
408
- **Generated Route**: `POST /api/v1/todos/:id/complete`
456
+ **Generated Route**: `POST /api/v1/applications/:id/advance-stage`
409
457
 
410
458
  ### Record-based vs Standalone Actions
411
459
 
412
460
  **Record-based** (default) — operates on a specific record via `:id`:
413
461
  ```typescript
414
- approve: {
415
- description: "Approve an invoice",
416
- input: z.object({ notes: z.string().optional() }),
417
- guard: { roles: ["admin"] },
418
- handler: "./handlers/approve",
462
+ 'advance-stage': {
463
+ description: "Move application to the next pipeline stage",
464
+ input: z.object({ stage: z.enum(["screening", "interview", "offer", "hired"]) }),
465
+ access: { roles: ["owner", "hiring-manager"] },
466
+ handler: "./handlers/advance-stage",
419
467
  }
420
- // → POST /api/v1/invoices/:id/approve
468
+ // → POST /api/v1/applications/:id/advance-stage
421
469
  ```
422
470
 
423
471
  **Standalone** — custom endpoint not tied to a record:
@@ -428,10 +476,10 @@ chat: {
428
476
  method: "POST",
429
477
  responseType: "stream",
430
478
  input: z.object({ message: z.string() }),
431
- guard: { roles: ["member"] },
479
+ access: { roles: ["recruiter", "hiring-manager"] },
432
480
  handler: "./handlers/chat",
433
481
  }
434
- // → POST /api/v1/todos/chat
482
+ // → POST /api/v1/applications/chat
435
483
  ```
436
484
 
437
485
  ### Action Options
@@ -535,7 +583,7 @@ OR'd LIKE across all `text()` schema columns (system columns excluded). Combine
535
583
 
536
584
  ```json
537
585
  {
538
- "data": [{ "id": "...", "title": "Todo 1" }],
586
+ "data": [{ "id": "...", "name": "Jane Smith", "source": "linkedin" }],
539
587
  "pagination": {
540
588
  "limit": 10,
541
589
  "offset": 0,
@@ -718,16 +766,15 @@ Detect from `quickback.config.ts` and use the correct imports:
718
766
 
719
767
  Full documentation at https://docs.quickback.dev
720
768
 
721
- - [Quick Start](https://docs.quickback.dev/definitions/quick-start)
722
- - [Concepts](https://docs.quickback.dev/definitions/concepts)
723
- - [Database Schema](https://docs.quickback.dev/definitions/database-schema)
724
- - [CRUD Endpoints](https://docs.quickback.dev/definitions/crud-endpoints)
725
- - [Views](https://docs.quickback.dev/definitions/views)
726
- - [Actions](https://docs.quickback.dev/definitions/actions)
727
- - [Firewall](https://docs.quickback.dev/definitions/firewall)
728
- - [Access](https://docs.quickback.dev/definitions/access)
729
- - [Guards](https://docs.quickback.dev/definitions/guards)
730
- - [Masking](https://docs.quickback.dev/definitions/masking)
731
- - [CLI Reference](https://docs.quickback.dev/compiler/cli)
732
- - [Cloudflare Stack](https://docs.quickback.dev/stack/cloudflare)
733
- - [Quick Reference](https://docs.quickback.dev/stack/reference)
769
+ - [Getting Started](https://docs.quickback.dev/compiler/getting-started)
770
+ - [Concepts](https://docs.quickback.dev/compiler/definitions/concepts)
771
+ - [Full Example](https://docs.quickback.dev/compiler/getting-started/full-example)
772
+ - [Database Schema](https://docs.quickback.dev/compiler/definitions/schema)
773
+ - [CRUD & API](https://docs.quickback.dev/compiler/using-the-api)
774
+ - [Views](https://docs.quickback.dev/compiler/definitions/views)
775
+ - [Actions](https://docs.quickback.dev/compiler/definitions/actions)
776
+ - [Firewall](https://docs.quickback.dev/compiler/definitions/firewall)
777
+ - [Access](https://docs.quickback.dev/compiler/definitions/access)
778
+ - [Guards](https://docs.quickback.dev/compiler/definitions/guards)
779
+ - [Masking](https://docs.quickback.dev/compiler/definitions/masking)
780
+ - [CLI Reference](https://docs.quickback.dev/compiler/cloud-compiler/cli)
@@ -51,35 +51,37 @@ Detect the database dialect from `quickback.config.ts`:
51
51
  ### Table Definition Pattern
52
52
 
53
53
  ```typescript
54
- import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
54
+ import { sqliteTable, text } from "drizzle-orm/sqlite-core";
55
55
  import { defineTable } from "@quickback/compiler";
56
56
 
57
- export const todos = sqliteTable("todos", {
58
- id: integer("id").primaryKey(),
59
- title: text("title").notNull(),
60
- completed: integer("completed", { mode: "boolean" }).default(false),
61
- userId: text("user_id").notNull(),
57
+ export const candidates = sqliteTable("candidates", {
58
+ id: text("id").primaryKey(),
62
59
  organizationId: text("organization_id").notNull(),
60
+ name: text("name").notNull(),
61
+ email: text("email").notNull(),
62
+ phone: text("phone"),
63
+ resumeUrl: text("resume_url"),
64
+ source: text("source"),
63
65
  });
64
66
 
65
- export default defineTable(todos, {
67
+ export default defineTable(candidates, {
66
68
  firewall: {
67
- organization: {}, // Auto-detects 'organizationId' column
68
- owner: {}, // Auto-detects 'userId' column
69
+ organization: {},
69
70
  },
70
71
  crud: {
71
- list: { access: { roles: ["member", "admin"] } },
72
- get: { access: { roles: ["member", "admin"] } },
73
- create: { access: { roles: ["member", "admin"] } },
74
- update: { access: { roles: ["admin"] } },
75
- delete: { access: { roles: ["admin"] }, mode: "soft" },
72
+ list: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
73
+ get: { access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] } },
74
+ create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
75
+ update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
76
+ delete: { access: { roles: ["owner", "hiring-manager"] }, mode: "soft" },
76
77
  },
77
78
  guards: {
78
- createable: ["title", "completed"],
79
- updatable: ["title", "completed"],
79
+ createable: ["name", "email", "phone", "resumeUrl", "source"],
80
+ updatable: ["name", "phone"],
80
81
  },
81
82
  masking: {
82
- userId: { type: "redact", show: { roles: ["admin"] } },
83
+ email: { type: "email", show: { roles: ["hiring-manager", "recruiter"] } },
84
+ phone: { type: "phone", show: { roles: ["hiring-manager", "recruiter"] } },
83
85
  },
84
86
  });
85
87
  ```
@@ -87,24 +89,28 @@ export default defineTable(todos, {
87
89
  ### Actions Pattern
88
90
 
89
91
  ```typescript
90
- // quickback/features/todos/actions.ts
91
- import { todos } from './todos';
92
+ // quickback/features/applications/actions.ts
93
+ import { applications } from './applications';
92
94
  import { defineActions } from '@quickback/compiler';
93
95
  import { z } from 'zod';
94
96
 
95
- export default defineActions(todos, {
96
- complete: {
97
- description: "Mark todo as complete",
97
+ export default defineActions(applications, {
98
+ 'advance-stage': {
99
+ description: "Move application to the next pipeline stage",
98
100
  input: z.object({
99
- completedAt: z.string().datetime().optional(),
101
+ stage: z.enum(["screening", "interview", "offer", "hired"]),
102
+ notes: z.string().optional(),
100
103
  }),
101
- guard: {
102
- roles: ["member", "admin"],
103
- record: { completed: { equals: false } },
104
+ access: {
105
+ roles: ["owner", "hiring-manager"],
106
+ record: { stage: { notEquals: "rejected" } },
104
107
  },
105
108
  execute: async ({ db, record, ctx, input }) => {
106
- await db.update(todos).set({ completed: true }).where(eq(todos.id, record.id));
107
- return { success: true };
109
+ const [updated] = await db.update(applications)
110
+ .set({ stage: input.stage, notes: input.notes ?? record.notes })
111
+ .where(eq(applications.id, record.id))
112
+ .returning();
113
+ return updated;
108
114
  },
109
115
  },
110
116
  });
@@ -137,29 +143,29 @@ firewall: {
137
143
  ### Workflow with protected status
138
144
  ```typescript
139
145
  guards: {
140
- protected: { status: ['approve', 'reject'] }
146
+ protected: { stage: ['advance-stage', 'reject'] }
141
147
  }
142
- // Plus defineActions() for approve/reject
148
+ // Plus defineActions() for advance-stage/reject
143
149
  ```
144
150
 
145
151
  ### PII masking
146
152
  ```typescript
147
153
  masking: {
148
- email: { type: 'email', show: { roles: ['admin'] } },
149
- ssn: { type: 'ssn', show: { roles: ['hr'] } }
154
+ email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },
155
+ phone: { type: 'phone', show: { roles: ['hiring-manager', 'recruiter'] } },
150
156
  }
151
157
  ```
152
158
 
153
159
  ### Views (column-level security)
154
160
  ```typescript
155
161
  views: {
156
- summary: {
157
- fields: ['id', 'name', 'email'],
158
- access: { roles: ['member', 'admin'] },
162
+ pipeline: {
163
+ fields: ['id', 'name', 'source', 'stage'],
164
+ access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
159
165
  },
160
166
  full: {
161
- fields: ['id', 'name', 'email', 'phone', 'ssn'],
162
- access: { roles: ['admin'] },
167
+ fields: ['id', 'name', 'email', 'phone', 'resumeUrl', 'source', 'stage'],
168
+ access: { roles: ['owner', 'hiring-manager', 'recruiter'] },
163
169
  },
164
170
  }
165
171
  ```
@@ -173,7 +179,7 @@ chat: {
173
179
  method: "POST",
174
180
  responseType: "stream",
175
181
  input: z.object({ message: z.string() }),
176
- guard: { roles: ["member"] },
182
+ access: { roles: ["recruiter", "hiring-manager"] },
177
183
  handler: "./handlers/chat",
178
184
  }
179
185
  ```