@spfn/core 0.2.0-beta.6 → 0.2.0-beta.8

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/docs/entity.md ADDED
@@ -0,0 +1,539 @@
1
+ # Entity
2
+
3
+ Database schema definition with Drizzle ORM and reusable column helpers.
4
+
5
+ ## Basic Entity
6
+
7
+ ```typescript
8
+ // src/server/entities/users.ts
9
+ import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
10
+ import { id, timestamps } from '@spfn/core/db';
11
+
12
+ export const users = pgTable('users', {
13
+ id: id(),
14
+ email: text('email').notNull().unique(),
15
+ name: text('name').notNull(),
16
+ role: text('role', { enum: ['admin', 'user', 'guest'] }).notNull().default('user'),
17
+ isActive: boolean('is_active').notNull().default(true),
18
+ ...timestamps()
19
+ });
20
+
21
+ // Type inference
22
+ export type User = typeof users.$inferSelect;
23
+ export type NewUser = typeof users.$inferInsert;
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Column Helpers
29
+
30
+ ### id()
31
+
32
+ Auto-incrementing bigserial primary key.
33
+
34
+ ```typescript
35
+ import { id } from '@spfn/core/db';
36
+
37
+ export const users = pgTable('users', {
38
+ id: id(), // bigserial primary key
39
+ ...
40
+ });
41
+ ```
42
+
43
+ ### uuid()
44
+
45
+ UUID primary key with auto-generation.
46
+
47
+ ```typescript
48
+ import { uuid } from '@spfn/core/db';
49
+
50
+ export const sessions = pgTable('sessions', {
51
+ id: uuid(), // uuid with gen_random_uuid()
52
+ ...
53
+ });
54
+ ```
55
+
56
+ ### timestamps()
57
+
58
+ Standard createdAt/updatedAt fields.
59
+
60
+ ```typescript
61
+ import { timestamps } from '@spfn/core/db';
62
+
63
+ export const users = pgTable('users', {
64
+ id: id(),
65
+ name: text('name'),
66
+ ...timestamps()
67
+ // createdAt: timestamptz, not null, default now()
68
+ // updatedAt: timestamptz, not null, default now()
69
+ });
70
+ ```
71
+
72
+ ### foreignKey()
73
+
74
+ Required foreign key with cascade delete (default).
75
+
76
+ ```typescript
77
+ import { foreignKey } from '@spfn/core/db';
78
+
79
+ export const posts = pgTable('posts', {
80
+ id: id(),
81
+ authorId: foreignKey('author', () => users.id),
82
+ // author_id bigserial NOT NULL REFERENCES users(id) ON DELETE CASCADE
83
+ });
84
+
85
+ // Custom options
86
+ authorId: foreignKey('author', () => users.id, {
87
+ onDelete: 'set null',
88
+ onUpdate: 'cascade'
89
+ })
90
+ ```
91
+
92
+ ### optionalForeignKey()
93
+
94
+ Nullable foreign key with set null (default).
95
+
96
+ ```typescript
97
+ import { optionalForeignKey } from '@spfn/core/db';
98
+
99
+ export const posts = pgTable('posts', {
100
+ id: id(),
101
+ categoryId: optionalForeignKey('category', () => categories.id),
102
+ // category_id bigserial REFERENCES categories(id) ON DELETE SET NULL
103
+ });
104
+ ```
105
+
106
+ ### auditFields()
107
+
108
+ User tracking fields.
109
+
110
+ ```typescript
111
+ import { auditFields } from '@spfn/core/db';
112
+
113
+ export const posts = pgTable('posts', {
114
+ id: id(),
115
+ ...auditFields()
116
+ // createdBy: text (nullable)
117
+ // updatedBy: text (nullable)
118
+ });
119
+ ```
120
+
121
+ ### publishingFields()
122
+
123
+ Content publishing fields.
124
+
125
+ ```typescript
126
+ import { publishingFields } from '@spfn/core/db';
127
+
128
+ export const articles = pgTable('articles', {
129
+ id: id(),
130
+ ...publishingFields()
131
+ // publishedAt: timestamptz (nullable)
132
+ // publishedBy: text (nullable)
133
+ });
134
+ ```
135
+
136
+ ### softDelete()
137
+
138
+ Soft deletion fields.
139
+
140
+ ```typescript
141
+ import { softDelete } from '@spfn/core/db';
142
+
143
+ export const posts = pgTable('posts', {
144
+ id: id(),
145
+ ...softDelete()
146
+ // deletedAt: timestamptz (nullable)
147
+ // deletedBy: text (nullable)
148
+ });
149
+ ```
150
+
151
+ ### verificationTimestamp()
152
+
153
+ Custom verification timestamp.
154
+
155
+ ```typescript
156
+ import { verificationTimestamp } from '@spfn/core/db';
157
+
158
+ export const users = pgTable('users', {
159
+ id: id(),
160
+ ...verificationTimestamp('emailVerified'), // emailVerifiedAt
161
+ ...verificationTimestamp('phoneVerified'), // phoneVerifiedAt
162
+ });
163
+ ```
164
+
165
+ ### utcTimestamp()
166
+
167
+ UTC timestamp field.
168
+
169
+ ```typescript
170
+ import { utcTimestamp } from '@spfn/core/db';
171
+
172
+ export const events = pgTable('events', {
173
+ id: id(),
174
+ scheduledAt: utcTimestamp('scheduled_at').notNull(),
175
+ processedAt: utcTimestamp('processed_at', 'string'), // ISO string mode
176
+ });
177
+ ```
178
+
179
+ ### enumText()
180
+
181
+ Type-safe enum text field.
182
+
183
+ ```typescript
184
+ import { enumText } from '@spfn/core/db';
185
+
186
+ const USER_STATUSES = ['active', 'inactive', 'suspended'] as const;
187
+
188
+ export const users = pgTable('users', {
189
+ id: id(),
190
+ status: enumText('status', USER_STATUSES).default('active').notNull(),
191
+ });
192
+
193
+ // TypeScript type: 'active' | 'inactive' | 'suspended'
194
+ ```
195
+
196
+ ### typedJsonb()
197
+
198
+ Type-safe JSONB field.
199
+
200
+ ```typescript
201
+ import { typedJsonb } from '@spfn/core/db';
202
+
203
+ type UserMetadata = {
204
+ preferences: { theme: 'light' | 'dark' };
205
+ settings: Record<string, any>;
206
+ };
207
+
208
+ export const users = pgTable('users', {
209
+ id: id(),
210
+ metadata: typedJsonb<UserMetadata>('metadata').notNull(),
211
+ });
212
+
213
+ // user.metadata.preferences.theme is typed
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Indexes & Constraints
219
+
220
+ ### Column-level Constraints
221
+
222
+ ```typescript
223
+ import { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core';
224
+
225
+ export const users = pgTable('users', {
226
+ id: id(),
227
+ email: text('email').notNull().unique(), // UNIQUE constraint
228
+ age: integer('age').notNull().default(0), // DEFAULT constraint
229
+ isActive: boolean('is_active').notNull(), // NOT NULL constraint
230
+ ...timestamps()
231
+ });
232
+ ```
233
+
234
+ ### Table-level Indexes
235
+
236
+ ```typescript
237
+ import { pgTable, text, index, uniqueIndex } from 'drizzle-orm/pg-core';
238
+
239
+ export const users = pgTable('users', {
240
+ id: id(),
241
+ email: text('email').notNull(),
242
+ name: text('name').notNull(),
243
+ organizationId: text('organization_id'),
244
+ ...timestamps()
245
+ }, (table) => [
246
+ // Single column index
247
+ index('users_email_idx').on(table.email),
248
+
249
+ // Unique index
250
+ uniqueIndex('users_email_unique').on(table.email),
251
+
252
+ // Composite index
253
+ index('users_org_name_idx').on(table.organizationId, table.name),
254
+ ]);
255
+ ```
256
+
257
+ ### Primary Key Constraints
258
+
259
+ ```typescript
260
+ import { pgTable, text, primaryKey } from 'drizzle-orm/pg-core';
261
+
262
+ // Composite primary key
263
+ export const userRoles = pgTable('user_roles', {
264
+ userId: text('user_id').notNull(),
265
+ roleId: text('role_id').notNull(),
266
+ }, (table) => [
267
+ primaryKey({ columns: [table.userId, table.roleId] })
268
+ ]);
269
+ ```
270
+
271
+ ### Unique Constraints
272
+
273
+ ```typescript
274
+ import { pgTable, text, unique } from 'drizzle-orm/pg-core';
275
+
276
+ export const profiles = pgTable('profiles', {
277
+ id: id(),
278
+ userId: text('user_id').notNull(),
279
+ type: text('type').notNull(),
280
+ ...timestamps()
281
+ }, (table) => [
282
+ // Composite unique constraint
283
+ unique('profiles_user_type_unique').on(table.userId, table.type)
284
+ ]);
285
+ ```
286
+
287
+ ### Foreign Key Constraints
288
+
289
+ ```typescript
290
+ import { pgTable, text, foreignKey as fk } from 'drizzle-orm/pg-core';
291
+
292
+ export const posts = pgTable('posts', {
293
+ id: id(),
294
+ authorId: text('author_id').notNull(),
295
+ editorId: text('editor_id'),
296
+ ...timestamps()
297
+ }, (table) => [
298
+ // Explicit foreign key with custom options
299
+ fk({
300
+ columns: [table.authorId],
301
+ foreignColumns: [users.id],
302
+ onDelete: 'cascade',
303
+ onUpdate: 'cascade'
304
+ }).name('posts_author_fk'),
305
+
306
+ fk({
307
+ columns: [table.editorId],
308
+ foreignColumns: [users.id],
309
+ onDelete: 'set null'
310
+ }).name('posts_editor_fk')
311
+ ]);
312
+ ```
313
+
314
+ ### Check Constraints
315
+
316
+ ```typescript
317
+ import { pgTable, integer, check } from 'drizzle-orm/pg-core';
318
+ import { sql } from 'drizzle-orm';
319
+
320
+ export const products = pgTable('products', {
321
+ id: id(),
322
+ price: integer('price').notNull(),
323
+ quantity: integer('quantity').notNull(),
324
+ ...timestamps()
325
+ }, (table) => [
326
+ check('price_positive', sql`${table.price} > 0`),
327
+ check('quantity_non_negative', sql`${table.quantity} >= 0`)
328
+ ]);
329
+ ```
330
+
331
+ ### Partial Indexes
332
+
333
+ ```typescript
334
+ import { pgTable, text, boolean, index } from 'drizzle-orm/pg-core';
335
+ import { sql } from 'drizzle-orm';
336
+
337
+ export const users = pgTable('users', {
338
+ id: id(),
339
+ email: text('email').notNull(),
340
+ isActive: boolean('is_active').notNull().default(true),
341
+ ...timestamps()
342
+ }, (table) => [
343
+ // Index only active users
344
+ index('users_active_email_idx')
345
+ .on(table.email)
346
+ .where(sql`${table.isActive} = true`)
347
+ ]);
348
+ ```
349
+
350
+ ### Expression Indexes
351
+
352
+ ```typescript
353
+ import { pgTable, text, index } from 'drizzle-orm/pg-core';
354
+ import { sql } from 'drizzle-orm';
355
+
356
+ export const users = pgTable('users', {
357
+ id: id(),
358
+ email: text('email').notNull(),
359
+ ...timestamps()
360
+ }, (table) => [
361
+ // Case-insensitive email index
362
+ index('users_email_lower_idx').on(sql`lower(${table.email})`)
363
+ ]);
364
+ ```
365
+
366
+ ---
367
+
368
+ ## Relations
369
+
370
+ ### One-to-Many
371
+
372
+ ```typescript
373
+ import { relations } from 'drizzle-orm';
374
+
375
+ export const users = pgTable('users', {
376
+ id: id(),
377
+ name: text('name').notNull(),
378
+ ...timestamps()
379
+ });
380
+
381
+ export const posts = pgTable('posts', {
382
+ id: id(),
383
+ title: text('title').notNull(),
384
+ authorId: foreignKey('author', () => users.id),
385
+ ...timestamps()
386
+ });
387
+
388
+ // Define relations
389
+ export const usersRelations = relations(users, ({ many }) => ({
390
+ posts: many(posts)
391
+ }));
392
+
393
+ export const postsRelations = relations(posts, ({ one }) => ({
394
+ author: one(users, {
395
+ fields: [posts.authorId],
396
+ references: [users.id]
397
+ })
398
+ }));
399
+ ```
400
+
401
+ ### Many-to-Many
402
+
403
+ ```typescript
404
+ export const users = pgTable('users', {
405
+ id: id(),
406
+ name: text('name').notNull()
407
+ });
408
+
409
+ export const roles = pgTable('roles', {
410
+ id: id(),
411
+ name: text('name').notNull()
412
+ });
413
+
414
+ // Junction table
415
+ export const userRoles = pgTable('user_roles', {
416
+ userId: foreignKey('user', () => users.id),
417
+ roleId: foreignKey('role', () => roles.id)
418
+ });
419
+
420
+ export const usersRelations = relations(users, ({ many }) => ({
421
+ userRoles: many(userRoles)
422
+ }));
423
+
424
+ export const rolesRelations = relations(roles, ({ many }) => ({
425
+ userRoles: many(userRoles)
426
+ }));
427
+
428
+ export const userRolesRelations = relations(userRoles, ({ one }) => ({
429
+ user: one(users, {
430
+ fields: [userRoles.userId],
431
+ references: [users.id]
432
+ }),
433
+ role: one(roles, {
434
+ fields: [userRoles.roleId],
435
+ references: [roles.id]
436
+ })
437
+ }));
438
+ ```
439
+
440
+ ---
441
+
442
+ ## Schema Namespacing
443
+
444
+ For package-based schema isolation.
445
+
446
+ ```typescript
447
+ import { createSchema, packageNameToSchema } from '@spfn/core/db';
448
+
449
+ // Create namespaced schema
450
+ const schema = createSchema('@spfn/cms');
451
+ // Creates PostgreSQL schema: spfn_cms
452
+
453
+ export const labels = schema.table('labels', {
454
+ id: id(),
455
+ name: text('name').notNull(),
456
+ ...timestamps()
457
+ });
458
+ // Creates table: spfn_cms.labels
459
+
460
+ // Utility functions
461
+ packageNameToSchema('@spfn/cms'); // 'spfn_cms'
462
+ packageNameToSchema('@company/auth'); // 'company_auth'
463
+ packageNameToSchema('spfn-storage'); // 'spfn_storage'
464
+ ```
465
+
466
+ ---
467
+
468
+ ## Entity Export Pattern
469
+
470
+ ```typescript
471
+ // src/server/entities/index.ts
472
+ export * from './users';
473
+ export * from './posts';
474
+ export * from './categories';
475
+
476
+ // Re-export relations
477
+ export * from './relations';
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Migration
483
+
484
+ ### Generate Migration
485
+
486
+ ```bash
487
+ npx spfn db generate
488
+ ```
489
+
490
+ ### Apply Migration
491
+
492
+ ```bash
493
+ npx spfn db migrate
494
+ ```
495
+
496
+ ### View Database
497
+
498
+ ```bash
499
+ pnpm drizzle-kit studio
500
+ ```
501
+
502
+ ---
503
+
504
+ ## Best Practices
505
+
506
+ ### Do
507
+
508
+ ```typescript
509
+ // 1. Use column helpers for consistency
510
+ export const users = pgTable('users', {
511
+ id: id(),
512
+ ...timestamps()
513
+ });
514
+
515
+ // 2. Define types from schema
516
+ export type User = typeof users.$inferSelect;
517
+ export type NewUser = typeof users.$inferInsert;
518
+
519
+ // 3. Use enum helpers for type safety
520
+ status: enumText('status', ['active', 'inactive']).notNull()
521
+
522
+ // 4. Use foreignKey helpers
523
+ authorId: foreignKey('author', () => users.id)
524
+ ```
525
+
526
+ ### Don't
527
+
528
+ ```typescript
529
+ // 1. Don't put business logic in entity files
530
+ export const users = pgTable('users', { ... });
531
+ export function validateUser() { ... } // Bad - move to repository
532
+
533
+ // 2. Don't define raw columns when helpers exist
534
+ id: bigserial('id', { mode: 'bigint' }).primaryKey() // Use id() instead
535
+ createdAt: timestamp('created_at').defaultNow() // Use timestamps() instead
536
+
537
+ // 3. Don't use magic strings for enums
538
+ role: text('role') // Bad - use enumText for type safety
539
+ ```