@parsrun/entity 0.1.35

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/src/define.ts ADDED
@@ -0,0 +1,313 @@
1
+ import { type, type Type } from 'arktype'
2
+ import type {
3
+ EntityDefinition,
4
+ Field,
5
+ FieldDefinition,
6
+ Entity,
7
+ } from './types.js'
8
+
9
+ /**
10
+ * Convert a field definition to an ArkType type string
11
+ */
12
+ function fieldToArkType(field: Field): string {
13
+ const def: FieldDefinition = typeof field === 'string'
14
+ ? { type: field }
15
+ : field
16
+
17
+ let typeStr = def.type
18
+
19
+ // Handle min/max constraints
20
+ if (def.min !== undefined || def.max !== undefined) {
21
+ if (typeStr === 'string' || typeStr.startsWith('string')) {
22
+ if (def.min !== undefined && def.max !== undefined) {
23
+ typeStr = `string >= ${def.min} <= ${def.max}`
24
+ } else if (def.min !== undefined) {
25
+ typeStr = `string >= ${def.min}`
26
+ } else if (def.max !== undefined) {
27
+ typeStr = `string <= ${def.max}`
28
+ }
29
+ } else if (typeStr === 'number' || typeStr.startsWith('number')) {
30
+ const base = typeStr.includes('.integer') ? 'number.integer' : 'number'
31
+ if (def.min !== undefined && def.max !== undefined) {
32
+ typeStr = `${base} >= ${def.min} <= ${def.max}`
33
+ } else if (def.min !== undefined) {
34
+ typeStr = `${base} >= ${def.min}`
35
+ } else if (def.max !== undefined) {
36
+ typeStr = `${base} <= ${def.max}`
37
+ }
38
+ }
39
+ }
40
+
41
+ // Handle optional fields
42
+ if (def.optional) {
43
+ // For optional fields, allow undefined or the type
44
+ typeStr = `${typeStr} | undefined`
45
+ }
46
+
47
+ return typeStr
48
+ }
49
+
50
+ /**
51
+ * Build the full schema object for ArkType
52
+ */
53
+ function buildSchemaObject(
54
+ definition: EntityDefinition<Record<string, Field>>,
55
+ mode: 'full' | 'create' | 'update' | 'query'
56
+ ): Record<string, string> {
57
+ const schema: Record<string, string> = {}
58
+
59
+ // Add id field for full schema
60
+ if (mode === 'full') {
61
+ schema['id'] = 'string.uuid'
62
+ }
63
+
64
+ // Add tenantId if tenant-scoped
65
+ if (definition.tenant) {
66
+ if (mode === 'full' || mode === 'create') {
67
+ schema['tenantId'] = 'string.uuid'
68
+ }
69
+ if (mode === 'query') {
70
+ schema['tenantId?'] = 'string.uuid'
71
+ }
72
+ }
73
+
74
+ // Add user-defined fields
75
+ for (const [name, field] of Object.entries(definition.fields)) {
76
+ const def: FieldDefinition = typeof field === 'string'
77
+ ? { type: field }
78
+ : field
79
+
80
+ if (mode === 'update' || mode === 'query') {
81
+ // All fields optional for update/query
82
+ schema[`${name}?`] = fieldToArkType(field)
83
+ } else if (mode === 'create') {
84
+ // Skip fields with defaults or optional fields
85
+ if (def.default !== undefined || def.optional) {
86
+ schema[`${name}?`] = fieldToArkType(field)
87
+ } else {
88
+ schema[name] = fieldToArkType(field)
89
+ }
90
+ } else {
91
+ // Full schema
92
+ if (def.optional) {
93
+ schema[`${name}?`] = fieldToArkType(field)
94
+ } else {
95
+ schema[name] = fieldToArkType(field)
96
+ }
97
+ }
98
+ }
99
+
100
+ // Add timestamp fields
101
+ if (definition.timestamps) {
102
+ if (mode === 'full') {
103
+ schema['insertedAt'] = 'Date'
104
+ schema['updatedAt'] = 'Date'
105
+ }
106
+ if (mode === 'query') {
107
+ schema['insertedAt?'] = 'Date'
108
+ schema['updatedAt?'] = 'Date'
109
+ schema['insertedAfter?'] = 'Date'
110
+ schema['insertedBefore?'] = 'Date'
111
+ }
112
+ }
113
+
114
+ // Add soft delete field
115
+ if (definition.softDelete) {
116
+ if (mode === 'full') {
117
+ schema['deletedAt?'] = 'Date'
118
+ }
119
+ if (mode === 'query') {
120
+ schema['includeDeleted?'] = 'boolean'
121
+ }
122
+ }
123
+
124
+ // Add pagination for query
125
+ if (mode === 'query') {
126
+ schema['limit?'] = 'number.integer > 0'
127
+ schema['offset?'] = 'number.integer >= 0'
128
+ schema['cursor?'] = 'string'
129
+ schema['orderBy?'] = 'string'
130
+ schema['orderDirection?'] = "'asc' | 'desc'"
131
+ schema['search?'] = 'string'
132
+ }
133
+
134
+ return schema
135
+ }
136
+
137
+ /**
138
+ * Define an entity with single-source schema generation
139
+ *
140
+ * @example
141
+ * ```typescript
142
+ * const Product = defineEntity({
143
+ * name: 'products',
144
+ * tenant: true,
145
+ * timestamps: true,
146
+ * softDelete: true,
147
+ * fields: {
148
+ * name: 'string >= 1',
149
+ * price: { type: 'number', min: 0 },
150
+ * status: "'draft' | 'active' | 'archived'",
151
+ * },
152
+ * indexes: [
153
+ * { fields: ['tenantId', 'status'] },
154
+ * ],
155
+ * })
156
+ *
157
+ * // Use schemas
158
+ * const validated = Product.createSchema(input)
159
+ * const products = await db.select().from(Product.table)
160
+ * ```
161
+ */
162
+ export function defineEntity<
163
+ TName extends string,
164
+ TFields extends Record<string, Field>,
165
+ >(
166
+ definition: EntityDefinition<TFields> & { name: TName }
167
+ ): Entity<TName, TFields, Record<string, unknown>> {
168
+ // Build schema objects
169
+ const fullSchemaObj = buildSchemaObject(definition, 'full')
170
+ const createSchemaObj = buildSchemaObject(definition, 'create')
171
+ const updateSchemaObj = buildSchemaObject(definition, 'update')
172
+ const querySchemaObj = buildSchemaObject(definition, 'query')
173
+
174
+ // Create ArkType schemas
175
+ const schema = type(fullSchemaObj as Record<string, string>)
176
+ const createSchema = type(createSchemaObj as Record<string, string>)
177
+ const updateSchema = type(updateSchemaObj as Record<string, string>)
178
+ const querySchema = type(querySchemaObj as Record<string, string>)
179
+
180
+ // Determine auto and required fields
181
+ const autoFields: string[] = ['id']
182
+ if (definition.timestamps) {
183
+ autoFields.push('insertedAt', 'updatedAt')
184
+ }
185
+ if (definition.softDelete) {
186
+ autoFields.push('deletedAt')
187
+ }
188
+
189
+ const requiredFields: string[] = []
190
+ const optionalFields: string[] = []
191
+
192
+ if (definition.tenant) {
193
+ requiredFields.push('tenantId')
194
+ }
195
+
196
+ for (const [name, field] of Object.entries(definition.fields)) {
197
+ const def: FieldDefinition = typeof field === 'string'
198
+ ? { type: field }
199
+ : field
200
+
201
+ if (def.optional || def.default !== undefined) {
202
+ optionalFields.push(name)
203
+ } else {
204
+ requiredFields.push(name)
205
+ }
206
+ }
207
+
208
+ return {
209
+ name: definition.name as TName,
210
+ definition,
211
+ schema: schema as Type<Record<string, unknown>>,
212
+ createSchema: createSchema as Type<Record<string, unknown>>,
213
+ updateSchema: updateSchema as Type<Record<string, unknown>>,
214
+ querySchema: querySchema as Type<Record<string, unknown>>,
215
+ infer: {} as Record<string, unknown>,
216
+ autoFields,
217
+ requiredFields,
218
+ optionalFields,
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Create a reference field to another entity
224
+ */
225
+ export function ref(
226
+ entity: string | { name: string },
227
+ options?: {
228
+ field?: string
229
+ onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action'
230
+ optional?: boolean
231
+ }
232
+ ): FieldDefinition {
233
+ const entityName = typeof entity === 'string' ? entity : entity.name
234
+ const result: FieldDefinition = {
235
+ type: 'string.uuid',
236
+ db: {
237
+ references: {
238
+ entity: entityName,
239
+ field: options?.field ?? 'id',
240
+ onDelete: options?.onDelete ?? 'restrict',
241
+ },
242
+ },
243
+ }
244
+ if (options?.optional !== undefined) {
245
+ result.optional = options.optional
246
+ }
247
+ return result
248
+ }
249
+
250
+ /**
251
+ * Create an enum field from a list of values
252
+ */
253
+ export function enumField<T extends string>(
254
+ values: readonly T[],
255
+ options?: { default?: T; optional?: boolean }
256
+ ): FieldDefinition {
257
+ const typeStr = values.map(v => `'${v}'`).join(' | ')
258
+ const result: FieldDefinition = {
259
+ type: typeStr as `'${string}'`,
260
+ }
261
+ if (options?.default !== undefined) {
262
+ result.default = options.default
263
+ }
264
+ if (options?.optional !== undefined) {
265
+ result.optional = options.optional
266
+ }
267
+ return result
268
+ }
269
+
270
+ /**
271
+ * Create a JSON field
272
+ */
273
+ export function jsonField<T = Record<string, unknown>>(
274
+ options?: { optional?: boolean; default?: T }
275
+ ): FieldDefinition {
276
+ const result: FieldDefinition = {
277
+ type: 'json',
278
+ }
279
+ if (options?.optional !== undefined) {
280
+ result.optional = options.optional
281
+ }
282
+ if (options?.default !== undefined) {
283
+ result.default = options.default
284
+ }
285
+ return result
286
+ }
287
+
288
+ /**
289
+ * Create a decimal field with precision
290
+ */
291
+ export function decimal(
292
+ precision: number,
293
+ scale: number,
294
+ options?: { min?: number; max?: number; optional?: boolean }
295
+ ): FieldDefinition {
296
+ const result: FieldDefinition = {
297
+ type: 'number',
298
+ db: {
299
+ precision,
300
+ scale,
301
+ },
302
+ }
303
+ if (options?.min !== undefined) {
304
+ result.min = options.min
305
+ }
306
+ if (options?.max !== undefined) {
307
+ result.max = options.max
308
+ }
309
+ if (options?.optional !== undefined) {
310
+ result.optional = options.optional
311
+ }
312
+ return result
313
+ }
package/src/index.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @parsrun/entity
3
+ *
4
+ * Single-source entity definitions for Pars framework.
5
+ * Define once, generate ArkType schemas and Drizzle tables.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { defineEntity, ref, enumField } from '@parsrun/entity'
10
+ * import { toPgTable } from '@parsrun/entity/pg'
11
+ *
12
+ * // Define entity
13
+ * const Product = defineEntity({
14
+ * name: 'products',
15
+ * tenant: true,
16
+ * timestamps: true,
17
+ * softDelete: true,
18
+ * fields: {
19
+ * name: 'string >= 1',
20
+ * slug: 'string',
21
+ * description: { type: 'string', optional: true },
22
+ * price: { type: 'number', min: 0 },
23
+ * stock: { type: 'number.integer', min: 0, default: 0 },
24
+ * status: enumField(['draft', 'active', 'archived'], { default: 'draft' }),
25
+ * categoryId: ref('categories', { onDelete: 'set null', optional: true }),
26
+ * },
27
+ * indexes: [
28
+ * { fields: ['tenantId', 'slug'], unique: true },
29
+ * { fields: ['tenantId', 'status'] },
30
+ * ],
31
+ * })
32
+ *
33
+ * // Use ArkType schemas
34
+ * const input = Product.createSchema(requestBody)
35
+ * if (input instanceof type.errors) throw new ValidationError(input)
36
+ *
37
+ * // Generate Drizzle table
38
+ * const productsTable = toPgTable(Product)
39
+ * await db.insert(productsTable).values({ tenantId, ...input })
40
+ * ```
41
+ */
42
+
43
+ export { defineEntity, ref, enumField, jsonField, decimal } from './define.js'
44
+
45
+ export type {
46
+ FieldType,
47
+ FieldDefinition,
48
+ SimpleFieldDefinition,
49
+ Field,
50
+ IndexDefinition,
51
+ EntityDefinition,
52
+ EntitySchemas,
53
+ Entity,
54
+ DrizzleOptions,
55
+ InferEntity,
56
+ InferCreateInput,
57
+ InferUpdateInput,
58
+ } from './types.js'
59
+
60
+ // Re-export arktype type function for convenience
61
+ export { type } from 'arktype'
package/src/pg.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * PostgreSQL Drizzle table generation from entity definitions
3
+ */
4
+ import {
5
+ pgTable,
6
+ uuid,
7
+ text,
8
+ integer,
9
+ boolean,
10
+ timestamp,
11
+ numeric,
12
+ json,
13
+ index,
14
+ uniqueIndex,
15
+ } from 'drizzle-orm/pg-core'
16
+ import { sql } from 'drizzle-orm'
17
+ import type { Entity, Field, FieldDefinition } from './types.js'
18
+
19
+ /**
20
+ * Convert field definition to Drizzle column builder
21
+ */
22
+ function fieldToColumn(name: string, field: Field) {
23
+ const def: FieldDefinition = typeof field === 'string' ? { type: field } : field
24
+
25
+ const columnName = def.db?.column ?? toSnakeCase(name)
26
+ let column: any
27
+
28
+ // Determine column type based on field type
29
+ const fieldType = def.type.split(' ')[0] ?? 'string'
30
+
31
+ switch (fieldType) {
32
+ case 'string.uuid':
33
+ column = uuid(columnName)
34
+ break
35
+
36
+ case 'string.email':
37
+ case 'string.url':
38
+ case 'string':
39
+ column = text(columnName)
40
+ break
41
+
42
+ case 'number.integer':
43
+ column = integer(columnName)
44
+ break
45
+
46
+ case 'number':
47
+ if (def.db?.precision) {
48
+ column = numeric(columnName, {
49
+ precision: def.db.precision,
50
+ scale: def.db.scale ?? 2,
51
+ })
52
+ } else {
53
+ column = numeric(columnName)
54
+ }
55
+ break
56
+
57
+ case 'boolean':
58
+ column = boolean(columnName)
59
+ break
60
+
61
+ case 'Date':
62
+ column = timestamp(columnName, { withTimezone: true })
63
+ break
64
+
65
+ case 'json':
66
+ column = json(columnName)
67
+ break
68
+
69
+ default:
70
+ // Union types like "'draft' | 'active'" become text
71
+ column = text(columnName)
72
+ }
73
+
74
+ // Apply modifiers
75
+ if (!def.optional && def.default === undefined) {
76
+ column = column.notNull()
77
+ }
78
+
79
+ if (def.default !== undefined) {
80
+ column = column.default(def.default)
81
+ }
82
+
83
+ return column
84
+ }
85
+
86
+ /**
87
+ * Convert camelCase to snake_case
88
+ */
89
+ function toSnakeCase(str: string): string {
90
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
91
+ }
92
+
93
+ /**
94
+ * Generate a Drizzle pgTable from an entity definition
95
+ */
96
+ export function toPgTable<E extends Entity<string, Record<string, Field>, unknown>>(
97
+ entity: E,
98
+ tableRefs?: Record<string, any>
99
+ ): any {
100
+ const def = entity.definition
101
+ const columns: Record<string, any> = {}
102
+
103
+ // Add id column
104
+ columns['id'] = uuid('id').primaryKey().defaultRandom()
105
+
106
+ // Add tenantId if tenant-scoped
107
+ if (def.tenant) {
108
+ const tenantsTable = tableRefs?.['tenants']
109
+ if (tenantsTable) {
110
+ columns['tenantId'] = uuid('tenant_id')
111
+ .notNull()
112
+ .references(() => tenantsTable.id, { onDelete: 'cascade' })
113
+ } else {
114
+ columns['tenantId'] = uuid('tenant_id').notNull()
115
+ }
116
+ }
117
+
118
+ // Add user-defined fields
119
+ for (const [name, field] of Object.entries(def.fields)) {
120
+ const fieldDef: FieldDefinition = typeof field === 'string' ? { type: field } : field
121
+
122
+ // Handle references
123
+ if (fieldDef.db?.references && tableRefs) {
124
+ const refTable = tableRefs[fieldDef.db.references.entity]
125
+ if (refTable) {
126
+ const refField = fieldDef.db.references.field ?? 'id'
127
+ const onDelete = fieldDef.db.references.onDelete ?? 'restrict'
128
+
129
+ let col = uuid(toSnakeCase(name)).references(() => refTable[refField], { onDelete })
130
+ if (!fieldDef.optional) {
131
+ col = col.notNull()
132
+ }
133
+ columns[name] = col
134
+ } else {
135
+ columns[name] = fieldToColumn(name, field)
136
+ }
137
+ } else {
138
+ columns[name] = fieldToColumn(name, field)
139
+ }
140
+ }
141
+
142
+ // Add timestamp fields
143
+ if (def.timestamps) {
144
+ columns['insertedAt'] = timestamp('inserted_at', { withTimezone: true })
145
+ .notNull()
146
+ .defaultNow()
147
+
148
+ columns['updatedAt'] = timestamp('updated_at', { withTimezone: true })
149
+ .notNull()
150
+ .defaultNow()
151
+ .$onUpdate(() => new Date())
152
+ }
153
+
154
+ // Add soft delete field
155
+ if (def.softDelete) {
156
+ columns['deletedAt'] = timestamp('deleted_at', { withTimezone: true })
157
+ }
158
+
159
+ // Create table with indexes
160
+ return pgTable(def.name, columns, (t: any) => {
161
+ const indexes: Record<string, any> = {}
162
+
163
+ // Add indexes from definition
164
+ if (def.indexes) {
165
+ for (const idx of def.indexes) {
166
+ const idxName = idx.name ?? `${def.name}_${idx.fields.join('_')}_idx`
167
+ const idxColumns = idx.fields.map((f) => t[f]).filter(Boolean)
168
+
169
+ if (idxColumns.length === 0) continue
170
+
171
+ if (idx.unique) {
172
+ if (idx.where) {
173
+ indexes[idxName] = uniqueIndex(idxName)
174
+ .on(...(idxColumns as [any, ...any[]]))
175
+ .where(sql.raw(idx.where))
176
+ } else {
177
+ indexes[idxName] = uniqueIndex(idxName).on(...(idxColumns as [any, ...any[]]))
178
+ }
179
+ } else {
180
+ if (idx.where) {
181
+ indexes[idxName] = index(idxName)
182
+ .on(...(idxColumns as [any, ...any[]]))
183
+ .where(sql.raw(idx.where))
184
+ } else {
185
+ indexes[idxName] = index(idxName).on(...(idxColumns as [any, ...any[]]))
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ // Add default tenant index if tenant-scoped
192
+ if (def.tenant && t.tenantId) {
193
+ const tenantIdxName = `${def.name}_tenant_id_idx`
194
+ if (!indexes[tenantIdxName]) {
195
+ indexes[tenantIdxName] = index(tenantIdxName).on(t.tenantId)
196
+ }
197
+ }
198
+
199
+ return indexes
200
+ })
201
+ }
202
+
203
+ /**
204
+ * Create multiple tables with resolved references
205
+ */
206
+ export function createPgSchema<
207
+ T extends Record<string, Entity<string, Record<string, Field>, unknown>>,
208
+ >(entities: T): { [K in keyof T]: any } {
209
+ const tables: Record<string, any> = {}
210
+
211
+ // First pass: create tables without references
212
+ for (const [key, entity] of Object.entries(entities)) {
213
+ tables[key] = toPgTable(entity)
214
+ }
215
+
216
+ // Second pass: recreate tables with resolved references
217
+ for (const [key, entity] of Object.entries(entities)) {
218
+ tables[key] = toPgTable(entity, tables)
219
+ }
220
+
221
+ return tables as { [K in keyof T]: any }
222
+ }
223
+
224
+ export { toPgTable as toTable }