@livestore/common 0.4.0-dev.0 → 0.4.0-dev.2

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.
@@ -0,0 +1,215 @@
1
+ import { shouldNeverHappen } from '@livestore/utils'
2
+ import { Option, Schema, SchemaAST } from '@livestore/utils/effect'
3
+
4
+ import { AutoIncrement, ColumnType, Default, PrimaryKeyId, Unique } from './column-annotations.ts'
5
+ import { SqliteDsl } from './db-schema/mod.ts'
6
+
7
+ /**
8
+ * Maps a schema to a SQLite column definition, respecting column annotations.
9
+ *
10
+ * Note: When used with schema-based table definitions, optional fields (| undefined)
11
+ * are transformed to nullable fields (| null) to match SQLite's NULL semantics.
12
+ * Fields with both null and undefined will emit a warning as this is a lossy conversion.
13
+ */
14
+ export const getColumnDefForSchema = (
15
+ schema: Schema.Schema.AnyNoContext,
16
+ propertySignature?: SchemaAST.PropertySignature,
17
+ forceNullable = false,
18
+ ): SqliteDsl.ColumnDefinition.Any => {
19
+ const ast = schema.ast
20
+
21
+ // Extract annotations
22
+ const getAnnotation = <T>(annotationId: symbol): Option.Option<T> =>
23
+ propertySignature
24
+ ? hasPropertyAnnotation<T>(propertySignature, annotationId)
25
+ : SchemaAST.getAnnotation<T>(annotationId)(ast)
26
+
27
+ const columnType = SchemaAST.getAnnotation<SqliteDsl.FieldColumnType>(ColumnType)(ast)
28
+
29
+ // Check if schema has null (e.g., Schema.NullOr) or undefined or if it's forced nullable (optional field)
30
+ const isNullable = forceNullable || hasNull(ast) || hasUndefined(ast)
31
+
32
+ // Get base column definition with nullable flag
33
+ const baseColumn = Option.isSome(columnType)
34
+ ? getColumnForType(columnType.value, isNullable)
35
+ : getColumnForSchema(schema, isNullable)
36
+
37
+ // Apply annotations
38
+ const primaryKey = getAnnotation<boolean>(PrimaryKeyId).pipe(Option.getOrElse(() => false))
39
+ const autoIncrement = getAnnotation<boolean>(AutoIncrement).pipe(Option.getOrElse(() => false))
40
+ const defaultValue = getAnnotation<unknown>(Default)
41
+
42
+ return {
43
+ ...baseColumn,
44
+ ...(primaryKey && { primaryKey: true }),
45
+ ...(autoIncrement && { autoIncrement: true }),
46
+ ...(Option.isSome(defaultValue) && { default: Option.some(defaultValue.value) }),
47
+ }
48
+ }
49
+
50
+ const hasPropertyAnnotation = <T>(
51
+ propertySignature: SchemaAST.PropertySignature,
52
+ annotationId: symbol,
53
+ ): Option.Option<T> => {
54
+ if ('annotations' in propertySignature && propertySignature.annotations) {
55
+ const annotation = SchemaAST.getAnnotation<T>(annotationId)(propertySignature as any)
56
+ if (Option.isSome(annotation)) return annotation
57
+ }
58
+ return SchemaAST.getAnnotation<T>(annotationId)(propertySignature.type)
59
+ }
60
+
61
+ /**
62
+ * Maps schema property signatures to SQLite column definitions.
63
+ * Optional fields (| undefined) become nullable columns (| null).
64
+ */
65
+ export const schemaFieldsToColumns = (
66
+ propertySignatures: ReadonlyArray<SchemaAST.PropertySignature>,
67
+ ): { columns: SqliteDsl.Columns; uniqueColumns: string[] } => {
68
+ const columns: SqliteDsl.Columns = {}
69
+ const uniqueColumns: string[] = []
70
+
71
+ for (const prop of propertySignatures) {
72
+ if (typeof prop.name !== 'string') continue
73
+
74
+ const fieldSchema = Schema.make(prop.type)
75
+
76
+ // Warn about lossy conversion for fields with both null and undefined
77
+ if (prop.isOptional) {
78
+ const { hasNull, hasUndefined } = checkNullUndefined(fieldSchema.ast)
79
+ if (hasNull && hasUndefined) {
80
+ console.warn(`Field '${prop.name}' has both null and undefined - treating | undefined as | null`)
81
+ }
82
+ }
83
+
84
+ // Get column definition - pass nullable flag for optional fields
85
+ const columnDef = getColumnDefForSchema(fieldSchema, prop, prop.isOptional)
86
+
87
+ // Check for primary key and unique annotations
88
+ const hasPrimaryKey = hasPropertyAnnotation<boolean>(prop, PrimaryKeyId).pipe(Option.getOrElse(() => false))
89
+ const hasUnique = hasPropertyAnnotation<boolean>(prop, Unique).pipe(Option.getOrElse(() => false))
90
+
91
+ // Build final column
92
+ columns[prop.name] = {
93
+ ...columnDef,
94
+ ...(hasPrimaryKey && { primaryKey: true }),
95
+ }
96
+
97
+ // Validate primary key + nullable
98
+ const column = columns[prop.name]
99
+ if (column?.primaryKey && column.nullable) {
100
+ throw new Error('Primary key columns cannot be nullable')
101
+ }
102
+
103
+ if (hasUnique) uniqueColumns.push(prop.name)
104
+ }
105
+
106
+ return { columns, uniqueColumns }
107
+ }
108
+
109
+ const checkNullUndefined = (ast: SchemaAST.AST): { hasNull: boolean; hasUndefined: boolean } => {
110
+ let hasNull = false
111
+ let hasUndefined = false
112
+
113
+ const visit = (type: SchemaAST.AST): void => {
114
+ if (SchemaAST.isUndefinedKeyword(type)) hasUndefined = true
115
+ else if (SchemaAST.isLiteral(type) && type.literal === null) hasNull = true
116
+ else if (SchemaAST.isUnion(type)) type.types.forEach(visit)
117
+ }
118
+
119
+ visit(ast)
120
+ return { hasNull, hasUndefined }
121
+ }
122
+
123
+ const hasNull = (ast: SchemaAST.AST): boolean => {
124
+ if (SchemaAST.isLiteral(ast) && ast.literal === null) return true
125
+ if (SchemaAST.isUnion(ast)) {
126
+ return ast.types.some((type) => hasNull(type))
127
+ }
128
+ return false
129
+ }
130
+
131
+ const hasUndefined = (ast: SchemaAST.AST): boolean => {
132
+ if (SchemaAST.isUndefinedKeyword(ast)) return true
133
+ if (SchemaAST.isUnion(ast)) {
134
+ return ast.types.some((type) => hasUndefined(type))
135
+ }
136
+ return false
137
+ }
138
+
139
+ const getColumnForType = (columnType: string, nullable = false): SqliteDsl.ColumnDefinition.Any => {
140
+ switch (columnType) {
141
+ case 'text':
142
+ return SqliteDsl.text({ nullable })
143
+ case 'integer':
144
+ return SqliteDsl.integer({ nullable })
145
+ case 'real':
146
+ return SqliteDsl.real({ nullable })
147
+ case 'blob':
148
+ return SqliteDsl.blob({ nullable })
149
+ default:
150
+ return shouldNeverHappen(`Unsupported column type: ${columnType}`)
151
+ }
152
+ }
153
+
154
+ const getColumnForSchema = (schema: Schema.Schema.AnyNoContext, nullable = false): SqliteDsl.ColumnDefinition.Any => {
155
+ const ast = schema.ast
156
+ // Strip nullable wrapper to get core type
157
+ const coreAst = stripNullable(ast)
158
+ const coreSchema = stripNullable(ast) === ast ? schema : Schema.make(coreAst)
159
+
160
+ // Special case: Boolean is transformed to integer in SQLite
161
+ if (SchemaAST.isBooleanKeyword(coreAst)) {
162
+ return SqliteDsl.boolean({ nullable })
163
+ }
164
+
165
+ // Get the encoded AST - what actually gets stored in SQLite
166
+ const encodedAst = Schema.encodedSchema(coreSchema).ast
167
+
168
+ // Check if the encoded type matches SQLite native types
169
+ if (SchemaAST.isStringKeyword(encodedAst)) {
170
+ return SqliteDsl.text({ schema: coreSchema, nullable })
171
+ }
172
+
173
+ if (SchemaAST.isNumberKeyword(encodedAst)) {
174
+ // Special cases for integer columns
175
+ const id = SchemaAST.getIdentifierAnnotation(coreAst).pipe(Option.getOrElse(() => ''))
176
+ if (id === 'Int' || id === 'DateFromNumber') {
177
+ return SqliteDsl.integer({ schema: coreSchema, nullable })
178
+ }
179
+ return SqliteDsl.real({ schema: coreSchema, nullable })
180
+ }
181
+
182
+ // Literals based on their type
183
+ if (SchemaAST.isLiteral(coreAst)) {
184
+ const value = coreAst.literal
185
+ if (typeof value === 'boolean') return SqliteDsl.boolean({ nullable })
186
+ }
187
+
188
+ // Literals based on their encoded type
189
+ if (SchemaAST.isLiteral(encodedAst)) {
190
+ const value = encodedAst.literal
191
+ if (typeof value === 'string') return SqliteDsl.text({ schema: coreSchema, nullable })
192
+ if (typeof value === 'number') {
193
+ // Check if the original schema is Int
194
+ const id = SchemaAST.getIdentifierAnnotation(coreAst).pipe(Option.getOrElse(() => ''))
195
+ if (id === 'Int') {
196
+ return SqliteDsl.integer({ schema: coreSchema, nullable })
197
+ }
198
+ return SqliteDsl.real({ schema: coreSchema, nullable })
199
+ }
200
+ }
201
+
202
+ // Everything else needs JSON encoding
203
+ return SqliteDsl.json({ schema: coreSchema, nullable })
204
+ }
205
+
206
+ const stripNullable = (ast: SchemaAST.AST): SchemaAST.AST => {
207
+ if (!SchemaAST.isUnion(ast)) return ast
208
+
209
+ // Find non-null/undefined type
210
+ const core = ast.types.find(
211
+ (type) => !(SchemaAST.isLiteral(type) && type.literal === null) && !SchemaAST.isUndefinedKeyword(type),
212
+ )
213
+
214
+ return core || ast
215
+ }