@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6

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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
@@ -0,0 +1,344 @@
1
+ # @spfn/core/db/schema — Drizzle entity column helpers & schema namespacing
2
+
3
+ Reusable column-definition helpers and package-scoped PostgreSQL schema utilities for
4
+ defining Drizzle ORM entities (`pgTable`) with consistent, type-safe boilerplate.
5
+
6
+ ## Import paths
7
+
8
+ There is **no** `@spfn/core/db/schema` subpath export. Everything in this module is
9
+ re-exported from `@spfn/core/db`. Import from there.
10
+
11
+ ```typescript
12
+ import {
13
+ id, uuid, timestamps,
14
+ foreignKey, optionalForeignKey,
15
+ auditFields, publishingFields, softDelete,
16
+ verificationTimestamp, utcTimestamp, enumText, typedJsonb,
17
+ createSchema, packageNameToSchema, getSchemaInfo,
18
+ } from '@spfn/core/db';
19
+
20
+ // Column primitives (text, integer, index, etc.) come from drizzle directly:
21
+ import { pgTable, text } from 'drizzle-orm/pg-core';
22
+ ```
23
+
24
+ > There is **no** root `.` export for `@spfn/core`. `import { id } from '@spfn/core'`
25
+ > does **not** resolve — always use `@spfn/core/db`. (Some JSDoc examples in the source
26
+ > still show `from '@spfn/core'`; that is stale.)
27
+
28
+ ---
29
+
30
+ ## Public API (complete)
31
+
32
+ Column helpers (`entity-helper.ts`):
33
+
34
+ - `id()` — bigserial primary key
35
+ - `uuid()` — uuid primary key (`gen_random_uuid()`)
36
+ - `timestamps()` — `createdAt` + `updatedAt` (timestamptz, not null, both written by the database clock)
37
+ - `foreignKey(name, reference, options?)` — required FK (bigint, cascade by default)
38
+ - `optionalForeignKey(name, reference, options?)` — nullable FK (set null by default)
39
+ - `auditFields()` — `createdBy` + `updatedBy` (text, nullable)
40
+ - `publishingFields()` — `publishedAt` (timestamptz, nullable) + `publishedBy` (text)
41
+ - `softDelete()` — `deletedAt` (timestamptz, nullable) + `deletedBy` (text)
42
+ - `verificationTimestamp(fieldName)` — single nullable timestamptz, `{fieldName}At`
43
+ - `utcTimestamp(fieldName, mode?)` — single timestamptz column (chainable)
44
+ - `enumText(fieldName, values)` — text column with enum constraint (chainable)
45
+ - `typedJsonb<T>(fieldName)` — jsonb column typed as `T` (chainable)
46
+
47
+ Schema namespacing (`schema-helper.ts`):
48
+
49
+ - `createSchema(packageName)` → `PgSchema` (drizzle `pgSchema`) for `schema.table(...)`
50
+ - `packageNameToSchema(packageName)` → `string` schema name
51
+ - `getSchemaInfo(packageName)` → `{ schemaName, isScoped, scope }`
52
+
53
+ > The following **do not exist** in the current code — do not use them:
54
+ > `autoUpdateTimestamp()`, `statusEnum()`, the `timestamps({ autoUpdate })` option, and
55
+ > the `__autoUpdate` marker. Earlier docs referenced these; they were removed. For
56
+ > auto-updating `updatedAt`, set it manually (`.set({ updatedAt: new Date() })`) — the
57
+ > helpers do not auto-update it.
58
+
59
+ ---
60
+
61
+ ## Quick Start
62
+
63
+ ```typescript
64
+ // src/server/entities/users.ts
65
+ import { pgTable, text, boolean } from 'drizzle-orm/pg-core';
66
+ import { id, timestamps, enumText } from '@spfn/core/db';
67
+
68
+ export const USER_ROLES = ['admin', 'user', 'guest'] as const;
69
+
70
+ export const users = pgTable('users', {
71
+ id: id(),
72
+ email: text('email').notNull().unique(),
73
+ name: text('name').notNull(),
74
+ role: enumText('role', USER_ROLES).notNull().default('user'),
75
+ isActive: boolean('is_active').notNull().default(true),
76
+ ...timestamps(),
77
+ });
78
+
79
+ // Type inference straight from the table
80
+ export type User = typeof users.$inferSelect;
81
+ export type NewUser = typeof users.$inferInsert;
82
+ ```
83
+
84
+ Spread helpers that return multiple columns (`timestamps()`, `auditFields()`,
85
+ `publishingFields()`, `softDelete()`, `verificationTimestamp()`); call directly the ones
86
+ that return a single column (`id()`, `uuid()`, `foreignKey()`, `enumText()`,
87
+ `utcTimestamp()`, `typedJsonb()`).
88
+
89
+ ---
90
+
91
+ ## Column helpers
92
+
93
+ ### Primary keys — `id()`, `uuid()`
94
+
95
+ ```typescript
96
+ id: id(), // bigserial('id', { mode: 'number' }).primaryKey() → bigserial PK
97
+ id: uuid(), // uuid('id').defaultRandom().primaryKey() → uuid PK
98
+ ```
99
+
100
+ `id()` is the default. Use `uuid()` for distributed/public-facing IDs. Both produce a
101
+ column named `id`.
102
+
103
+ ### `timestamps()`
104
+
105
+ ```typescript
106
+ ...timestamps(),
107
+ // createdAt: timestamptz, defaultNow(), notNull()
108
+ // updatedAt: timestamptz, defaultNow(), notNull(), $onUpdate(() => sql`now()`)
109
+ ```
110
+
111
+ DB column names are `created_at` / `updated_at`. Both come from the database clock —
112
+ `now()` on insert through the column default, and `now()` again on update, which Drizzle
113
+ adds to the SET clause for you. **Do not pass `updatedAt` yourself:**
114
+
115
+ ```typescript
116
+ await db.update(users)
117
+ .set({ name: 'new' }) // updated_at = now() is added automatically
118
+ .where(eq(users.id, userId));
119
+ ```
120
+
121
+ An explicit value wins over the automatic one, so stamping `new Date()` reads a second
122
+ clock. In production the application host and the database host are different machines:
123
+ a row could then record an update earlier than its own creation, and ordering rows by
124
+ `updated_at` would order by whichever host's clock ran ahead.
125
+
126
+ `now()` is the transaction's start time. It is not a commit-order sequence — a sync
127
+ cursor or anything else that needs true ordering needs a sequence column, not a
128
+ timestamp.
129
+
130
+ ### `foreignKey(name, reference, options?)` / `optionalForeignKey(...)`
131
+
132
+ ```typescript
133
+ authorId: foreignKey('author', () => users.id), // author_id BIGINT NOT NULL, ON DELETE CASCADE
134
+ categoryId: optionalForeignKey('category', () => cats.id), // category_id BIGINT (nullable), ON DELETE SET NULL
135
+ ```
136
+
137
+ - Column name is `{name}_id`. Both produce a **bigint** column (`mode: 'number'`) — pair
138
+ with `id()` (bigserial), not `uuid()`.
139
+ - `options.onDelete`: `'cascade' | 'set null' | 'restrict' | 'no action'`. Defaults:
140
+ `foreignKey` → `'cascade'`, `optionalForeignKey` → `'set null'`.
141
+ - There is **no** `onUpdate` option on these helpers. For `onUpdate` (or FK to a uuid PK),
142
+ use drizzle's table-level `foreignKey({ columns, foreignColumns, onUpdate })` instead.
143
+
144
+ ### `auditFields()`, `publishingFields()`, `softDelete()`
145
+
146
+ ```typescript
147
+ ...auditFields(), // created_by TEXT, updated_by TEXT (nullable)
148
+ ...publishingFields(), // published_at TIMESTAMPTZ (nullable), published_by TEXT
149
+ ...softDelete(), // deleted_at TIMESTAMPTZ (nullable), deleted_by TEXT
150
+ ```
151
+
152
+ Soft-delete query pattern — filter manually, there is no automatic scoping:
153
+
154
+ ```typescript
155
+ await db.select().from(posts).where(isNull(posts.deletedAt));
156
+ ```
157
+
158
+ ### `verificationTimestamp(fieldName)`
159
+
160
+ Single nullable timestamptz. The JS property is `{fieldName}At`, the DB column is
161
+ `snake_case(fieldName) + '_at'`.
162
+
163
+ ```typescript
164
+ ...verificationTimestamp('emailVerified'), // prop emailVerifiedAt → col email_verified_at
165
+ ...verificationTimestamp('phoneVerified'), // prop phoneVerifiedAt → col phone_verified_at
166
+ ```
167
+
168
+ ### `utcTimestamp(fieldName, mode?)`
169
+
170
+ Single timestamptz column. `fieldName` is the **DB column name** (snake_case, unlike
171
+ `verificationTimestamp`). `mode` is `'date'` (default, `Date`) or `'string'` (ISO string).
172
+ Chainable.
173
+
174
+ ```typescript
175
+ scheduledAt: utcTimestamp('scheduled_at').notNull(),
176
+ lastLoginAt: utcTimestamp('last_login_at').defaultNow().notNull(),
177
+ processedAt: utcTimestamp('processed_at', 'string'), // ISO string
178
+ ```
179
+
180
+ ### `enumText(fieldName, values)`
181
+
182
+ Text column constrained to a const tuple; the value type is inferred. Chainable.
183
+
184
+ ```typescript
185
+ export const USER_STATUSES = ['active', 'inactive', 'suspended'] as const;
186
+ export type UserStatus = typeof USER_STATUSES[number];
187
+
188
+ status: enumText('status', USER_STATUSES).notNull().default('active'),
189
+ ```
190
+
191
+ Backing type is plain `text` (no PG enum type), so adding values needs **no migration**.
192
+
193
+ ### `typedJsonb<T>(fieldName)`
194
+
195
+ jsonb column typed as `T` — avoids `unknown` / `as any` on reads. Chainable.
196
+
197
+ ```typescript
198
+ type Metadata = { theme: 'light' | 'dark'; settings: Record<string, unknown> };
199
+
200
+ metadata: typedJsonb<Metadata>('metadata').notNull(),
201
+ tags: typedJsonb<string[]>('tags'),
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Schema namespacing
207
+
208
+ Isolate a package's tables under a dedicated PostgreSQL schema so multiple SPFN packages
209
+ share one database without table-name collisions.
210
+
211
+ ```typescript
212
+ import { createSchema, id, timestamps } from '@spfn/core/db';
213
+ import { text } from 'drizzle-orm/pg-core';
214
+
215
+ const schema = createSchema('@spfn/cms'); // PG schema: spfn_cms
216
+
217
+ export const labels = schema.table('labels', {
218
+ id: id(),
219
+ name: text('name').notNull(),
220
+ ...timestamps(),
221
+ });
222
+ // → table spfn_cms.labels
223
+ ```
224
+
225
+ Naming rules (`packageNameToSchema`): strip `@`, replace `/` and `-` with `_`.
226
+
227
+ ```typescript
228
+ packageNameToSchema('@spfn/cms'); // 'spfn_cms'
229
+ packageNameToSchema('@company/auth'); // 'company_auth'
230
+ packageNameToSchema('spfn-storage'); // 'spfn_storage'
231
+
232
+ getSchemaInfo('@spfn/cms');
233
+ // { schemaName: 'spfn_cms', isScoped: true, scope: 'spfn' }
234
+ ```
235
+
236
+ Use `schema.table(...)` instead of `pgTable(...)` for every table in that package. The
237
+ column helpers above work identically inside `schema.table`.
238
+
239
+ ---
240
+
241
+ ## Pitfalls & anti-patterns
242
+
243
+ - **`@spfn/core/db/schema` is not an export path; `@spfn/core` (bare) has no root export
244
+ either.** Import everything from `@spfn/core/db`.
245
+ - **`id` is bigint, not uuid.** `id()` and `foreignKey()`/`optionalForeignKey()` are all
246
+ bigint-based. Don't point a `foreignKey()` (bigint) at a `uuid()` PK — types won't match.
247
+ For a uuid FK, use drizzle's table-level `foreignKey({ columns, foreignColumns })`.
248
+ - **`timestamps()` takes no arguments and does not auto-update `updatedAt`.** Calls like
249
+ `timestamps({ autoUpdate: true })` are from an old API and will fail/no-op. Set
250
+ `updatedAt: new Date()` yourself on updates.
251
+ - **`autoUpdateTimestamp()`, `statusEnum()`, and the `__autoUpdate` marker do not exist.**
252
+ Replace `statusEnum([...])` with `enumText('status', [...] as const).notNull().default(...)`.
253
+ - **`verificationTimestamp(name)` takes a camelCase logical name; `utcTimestamp(col)` takes
254
+ a snake_case DB column name.** They differ — `verificationTimestamp('emailVerified')`
255
+ yields column `email_verified_at`, but `utcTimestamp('emailVerified')` would create a
256
+ column literally named `emailVerified`.
257
+ - **`enumText` is plain text + CHECK, not a PG enum type.** Adding values requires no
258
+ migration; renaming/removing the column still does.
259
+ - **Exported tables must be reachable by drizzle-kit to land in migrations.** Re-export
260
+ every entity (e.g. `src/server/entities/index.ts → export * from './users'`) and point
261
+ drizzle config at it; an unexported table generates no migration.
262
+ - **When using `createSchema`, all tables in that package must use `schema.table(...)`.**
263
+ Mixing `pgTable(...)` puts that table in the default `public` schema.
264
+
265
+ ---
266
+
267
+ ## Complete example
268
+
269
+ ```typescript
270
+ // src/server/entities/posts.ts
271
+ import { pgTable, text, index } from 'drizzle-orm/pg-core';
272
+ import { sql } from 'drizzle-orm';
273
+ import {
274
+ id, foreignKey, optionalForeignKey,
275
+ enumText, typedJsonb,
276
+ timestamps, auditFields, publishingFields, softDelete,
277
+ } from '@spfn/core/db';
278
+ import { users } from './users';
279
+
280
+ export const POST_STATUSES = ['draft', 'published', 'archived'] as const;
281
+ export type PostStatus = typeof POST_STATUSES[number];
282
+
283
+ type PostMeta = { seoTitle?: string; tags: string[] };
284
+
285
+ export const posts = pgTable('posts', {
286
+ id: id(),
287
+ title: text('title').notNull(),
288
+ slug: text('slug').notNull().unique(),
289
+
290
+ authorId: foreignKey('author', () => users.id), // NOT NULL, cascade
291
+ reviewerId: optionalForeignKey('reviewer', () => users.id), // nullable, set null
292
+
293
+ status: enumText('status', POST_STATUSES).notNull().default('draft'),
294
+ meta: typedJsonb<PostMeta>('meta'),
295
+
296
+ ...timestamps(),
297
+ ...auditFields(),
298
+ ...publishingFields(),
299
+ ...softDelete(),
300
+ }, (table) => [
301
+ index('posts_status_idx').on(table.status),
302
+ index('posts_active_idx').on(table.slug).where(sql`${table.deletedAt} is null`),
303
+ ]);
304
+
305
+ export type Post = typeof posts.$inferSelect;
306
+ export type NewPost = typeof posts.$inferInsert;
307
+ ```
308
+
309
+ ```typescript
310
+ // src/server/entities/index.ts — must re-export so drizzle-kit sees every table
311
+ export * from './users';
312
+ export * from './posts';
313
+ export * from './relations';
314
+ ```
315
+
316
+ Indexes, composite primary keys, unique/check constraints, and `relations()` are standard
317
+ drizzle features (table callback / `drizzle-orm`) — this module does not wrap them; use
318
+ drizzle directly.
319
+
320
+ ---
321
+
322
+ ## Types reference
323
+
324
+ ```typescript
325
+ // foreignKey / optionalForeignKey options
326
+ type FkOptions = { onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action' };
327
+
328
+ // utcTimestamp mode
329
+ type UtcMode = 'date' | 'string'; // default 'date'
330
+
331
+ // enumText values
332
+ type EnumValues = readonly [string, ...string[]];
333
+
334
+ // getSchemaInfo return
335
+ type SchemaInfo = { schemaName: string; isScoped: boolean; scope: string | null };
336
+ ```
337
+
338
+ Column return types are drizzle column builders (`PgColumn`-based); chain
339
+ `.notNull()`, `.default(...)`, `.unique()`, `.references(...)` as usual.
340
+
341
+ ## Related
342
+
343
+ - [@spfn/core/db](../README.md) — DB manager, transactions, repository, postgres errors
344
+ - [Drizzle ORM](https://orm.drizzle.team/) — `pgTable`, `pgSchema`, indexes, relations