@livestore/common 0.4.0-dev.14 → 0.4.0-dev.16
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/dist/.tsbuildinfo +1 -1
- package/dist/adapter-types.d.ts +4 -2
- package/dist/adapter-types.d.ts.map +1 -1
- package/dist/debug-info.d.ts.map +1 -1
- package/dist/debug-info.js +33 -6
- package/dist/debug-info.js.map +1 -1
- package/dist/devtools/devtools-messages-client-session.d.ts +21 -21
- package/dist/devtools/devtools-messages-common.d.ts +6 -6
- package/dist/devtools/devtools-messages-leader.d.ts +24 -24
- package/dist/devtools/devtools-sessioninfo.d.ts +2 -2
- package/dist/devtools/devtools-sessioninfo.d.ts.map +1 -1
- package/dist/devtools/devtools-sessioninfo.js +1 -4
- package/dist/devtools/devtools-sessioninfo.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.d.ts +5 -4
- package/dist/leader-thread/make-leader-thread-layer.d.ts.map +1 -1
- package/dist/leader-thread/make-leader-thread-layer.js +4 -3
- package/dist/leader-thread/make-leader-thread-layer.js.map +1 -1
- package/dist/logging.d.ts +40 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +33 -0
- package/dist/logging.js.map +1 -0
- package/dist/schema/state/sqlite/column-def.test.js +19 -1
- package/dist/schema/state/sqlite/column-def.test.js.map +1 -1
- package/dist/schema/state/sqlite/column-spec.d.ts.map +1 -1
- package/dist/schema/state/sqlite/column-spec.js +30 -12
- package/dist/schema/state/sqlite/column-spec.js.map +1 -1
- package/dist/schema/state/sqlite/column-spec.test.js +23 -14
- package/dist/schema/state/sqlite/column-spec.test.js.map +1 -1
- package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts +14 -8
- package/dist/schema/state/sqlite/db-schema/dsl/field-defs.d.ts.map +1 -1
- package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js +5 -3
- package/dist/schema/state/sqlite/db-schema/dsl/field-defs.js.map +1 -1
- package/dist/schema/state/sqlite/schema-helpers.d.ts +2 -2
- package/dist/schema/state/sqlite/schema-helpers.d.ts.map +1 -1
- package/dist/schema/state/sqlite/schema-helpers.js +22 -12
- package/dist/schema/state/sqlite/schema-helpers.js.map +1 -1
- package/dist/schema/state/sqlite/schema-helpers.test.d.ts +2 -0
- package/dist/schema/state/sqlite/schema-helpers.test.d.ts.map +1 -0
- package/dist/schema/state/sqlite/schema-helpers.test.js +36 -0
- package/dist/schema/state/sqlite/schema-helpers.test.js.map +1 -0
- package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts +2 -0
- package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.d.ts.map +1 -0
- package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js +73 -0
- package/dist/schema-management/__tests__/migrations-autoincrement-quoting.test.js.map +1 -0
- package/dist/schema-management/migrations.js +6 -4
- package/dist/schema-management/migrations.js.map +1 -1
- package/dist/sql-queries/sql-queries.d.ts.map +1 -1
- package/dist/sql-queries/sql-queries.js +11 -1
- package/dist/sql-queries/sql-queries.js.map +1 -1
- package/dist/sync/sync-backend.d.ts +3 -3
- package/dist/sync/sync-backend.d.ts.map +1 -1
- package/dist/sync/sync.d.ts +3 -2
- package/dist/sync/sync.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
- package/src/adapter-types.ts +4 -2
- package/src/debug-info.ts +37 -6
- package/src/devtools/devtools-sessioninfo.ts +2 -5
- package/src/index.ts +1 -0
- package/src/leader-thread/make-leader-thread-layer.ts +10 -4
- package/src/logging.ts +62 -0
- package/src/schema/state/sqlite/column-def.test.ts +24 -1
- package/src/schema/state/sqlite/column-spec.test.ts +29 -16
- package/src/schema/state/sqlite/column-spec.ts +36 -11
- package/src/schema/state/sqlite/db-schema/dsl/field-defs.ts +29 -12
- package/src/schema/state/sqlite/schema-helpers.test.ts +44 -0
- package/src/schema/state/sqlite/schema-helpers.ts +28 -20
- package/src/schema-management/__tests__/migrations-autoincrement-quoting.test.ts +86 -0
- package/src/schema-management/migrations.ts +6 -4
- package/src/sql-queries/sql-queries.ts +9 -1
- package/src/sync/sync-backend.ts +4 -4
- package/src/sync/sync.ts +3 -2
- package/src/version.ts +1 -1
- package/dist/schema/state/sqlite/system-tables.d.ts +0 -976
- package/dist/schema/state/sqlite/system-tables.d.ts.map +0 -1
- package/dist/schema/state/sqlite/system-tables.js +0 -81
- package/dist/schema/state/sqlite/system-tables.js.map +0 -1
package/src/adapter-types.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type * as Devtools from './devtools/mod.ts'
|
|
|
13
13
|
import type { IntentionalShutdownCause, MaterializeError, UnexpectedError } from './errors.ts'
|
|
14
14
|
import type { LiveStoreSchema } from './schema/mod.ts'
|
|
15
15
|
import type { SqliteDb } from './sqlite-types.ts'
|
|
16
|
-
import type { IsOfflineError, SyncError } from './sync/index.
|
|
16
|
+
import type { IsOfflineError, SyncError } from './sync/index.ts'
|
|
17
17
|
|
|
18
18
|
export * as ClientSessionLeaderThreadProxy from './ClientSessionLeaderThreadProxy.ts'
|
|
19
19
|
export * from './defs.ts'
|
|
@@ -138,5 +138,7 @@ export interface AdapterArgs {
|
|
|
138
138
|
*
|
|
139
139
|
* @default undefined
|
|
140
140
|
*/
|
|
141
|
-
|
|
141
|
+
syncPayloadSchema: Schema.Schema<any> | undefined
|
|
142
|
+
/** Encoded representation of the sync payload matching `syncPayloadSchema`. */
|
|
143
|
+
syncPayloadEncoded: Schema.JsonValue | undefined
|
|
142
144
|
}
|
package/src/debug-info.ts
CHANGED
|
@@ -22,6 +22,15 @@ export const SlowQueryInfo = Schema.Struct({
|
|
|
22
22
|
startTimePerfNow: Schema.Number,
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
const getSizeLimit = (value: unknown): number =>
|
|
26
|
+
typeof (value as { sizeLimit?: number }).sizeLimit === 'number'
|
|
27
|
+
? (value as { sizeLimit: number }).sizeLimit
|
|
28
|
+
: Number.POSITIVE_INFINITY
|
|
29
|
+
|
|
30
|
+
const isBoundArrayLike = (value: unknown): value is BoundArray<unknown> =>
|
|
31
|
+
value instanceof BoundArray ||
|
|
32
|
+
(value !== null && typeof value === 'object' && typeof (value as { sizeLimit?: number }).sizeLimit === 'number')
|
|
33
|
+
|
|
25
34
|
const BoundArraySchemaFromSelf = <A, I, R>(
|
|
26
35
|
item: Schema.Schema<A, I, R>,
|
|
27
36
|
): Schema.Schema<BoundArray<A>, BoundArray<I>, R> =>
|
|
@@ -29,16 +38,17 @@ const BoundArraySchemaFromSelf = <A, I, R>(
|
|
|
29
38
|
[item],
|
|
30
39
|
{
|
|
31
40
|
decode: (item) => (input, parseOptions, ast) => {
|
|
32
|
-
if (input
|
|
41
|
+
if (isBoundArrayLike(input)) {
|
|
33
42
|
const elements = ParseResult.decodeUnknown(Schema.Array(item))([...input], parseOptions)
|
|
34
|
-
return ParseResult.map(elements, (as): BoundArray<A> => BoundArray.make(input
|
|
43
|
+
return ParseResult.map(elements, (as): BoundArray<A> => BoundArray.make(getSizeLimit(input), as))
|
|
35
44
|
}
|
|
36
45
|
return ParseResult.fail(new ParseResult.Type(ast, input))
|
|
37
46
|
},
|
|
38
47
|
encode: (item) => (input, parseOptions, ast) => {
|
|
39
|
-
if (input
|
|
40
|
-
const
|
|
41
|
-
|
|
48
|
+
if (isBoundArrayLike(input)) {
|
|
49
|
+
const items = [...input]
|
|
50
|
+
const elements = ParseResult.encodeUnknown(Schema.Array(item))(items, parseOptions)
|
|
51
|
+
return ParseResult.map(elements, (is): BoundArray<I> => BoundArray.make(getSizeLimit(input), is))
|
|
42
52
|
}
|
|
43
53
|
return ParseResult.fail(new ParseResult.Type(ast, input))
|
|
44
54
|
},
|
|
@@ -47,7 +57,28 @@ const BoundArraySchemaFromSelf = <A, I, R>(
|
|
|
47
57
|
description: `BoundArray<${Schema.format(item)}>`,
|
|
48
58
|
pretty: () => (_) => `BoundArray(${_.length})`,
|
|
49
59
|
arbitrary: () => (fc) => fc.anything() as any,
|
|
50
|
-
equivalence: () =>
|
|
60
|
+
equivalence: () => {
|
|
61
|
+
const elementEquivalence = Schema.equivalence(item)
|
|
62
|
+
return (a: unknown, b: unknown) => {
|
|
63
|
+
if (a === b) {
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
if (!isBoundArrayLike(a) || !isBoundArrayLike(b)) {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
if (getSizeLimit(a) !== getSizeLimit(b) || a.length !== b.length) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
const itemsA = [...a]
|
|
73
|
+
const itemsB = [...b]
|
|
74
|
+
for (let i = 0; i < itemsA.length; i++) {
|
|
75
|
+
if (!elementEquivalence(itemsA[i] as any, itemsB[i] as any)) {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
},
|
|
51
82
|
},
|
|
52
83
|
)
|
|
53
84
|
|
|
@@ -61,7 +61,7 @@ export const requestSessionInfoSubscription = ({
|
|
|
61
61
|
webChannel: WebChannel.WebChannel<Message, Message>
|
|
62
62
|
pollInterval?: Duration.DurationInput
|
|
63
63
|
staleTimeout?: Duration.DurationInput
|
|
64
|
-
}): Effect.Effect<Subscribable.Subscribable<
|
|
64
|
+
}): Effect.Effect<Subscribable.Subscribable<HashSet.HashSet<SessionInfo>>, ParseResult.ParseError, Scope.Scope> =>
|
|
65
65
|
Effect.gen(function* () {
|
|
66
66
|
yield* webChannel
|
|
67
67
|
.send(RequestSessions.make({}))
|
|
@@ -100,8 +100,5 @@ export const requestSessionInfoSubscription = ({
|
|
|
100
100
|
Effect.forkScoped,
|
|
101
101
|
)
|
|
102
102
|
|
|
103
|
-
return Subscribable.make({
|
|
104
|
-
get: sessionInfoSubRef.get.pipe(Effect.map((sessionInfos) => new Set(sessionInfos))),
|
|
105
|
-
changes: sessionInfoSubRef.changes.pipe(Stream.map((sessionInfos) => new Set(sessionInfos))),
|
|
106
|
-
})
|
|
103
|
+
return Subscribable.make({ get: sessionInfoSubRef.get, changes: sessionInfoSubRef.changes })
|
|
107
104
|
})
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './adapter-types.ts'
|
|
|
2
2
|
export * from './bounded-collections.ts'
|
|
3
3
|
export * from './debug-info.ts'
|
|
4
4
|
export * as Devtools from './devtools/mod.ts'
|
|
5
|
+
export * as LogConfig from './logging.ts'
|
|
5
6
|
export * from './make-client-session.ts'
|
|
6
7
|
export * from './materializer-helper.ts'
|
|
7
8
|
export * from './otel.ts'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { omitUndefineds, shouldNeverHappen } from '@livestore/utils'
|
|
2
|
-
import type { HttpClient,
|
|
2
|
+
import type { HttpClient, Scope } from '@livestore/utils/effect'
|
|
3
3
|
import {
|
|
4
4
|
Deferred,
|
|
5
5
|
Effect,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
Layer,
|
|
8
8
|
PlatformError,
|
|
9
9
|
Queue,
|
|
10
|
+
Schema,
|
|
10
11
|
Stream,
|
|
11
12
|
Subscribable,
|
|
12
13
|
SubscriptionRef,
|
|
@@ -44,7 +45,8 @@ import { LeaderThreadCtx } from './types.ts'
|
|
|
44
45
|
|
|
45
46
|
export interface MakeLeaderThreadLayerParams {
|
|
46
47
|
storeId: string
|
|
47
|
-
|
|
48
|
+
syncPayloadSchema: Schema.Schema<any> | undefined
|
|
49
|
+
syncPayloadEncoded: Schema.JsonValue | undefined
|
|
48
50
|
clientId: string
|
|
49
51
|
schema: LiveStoreSchema
|
|
50
52
|
makeSqliteDb: MakeSqliteDb
|
|
@@ -70,7 +72,8 @@ export const makeLeaderThreadLayer = ({
|
|
|
70
72
|
schema,
|
|
71
73
|
storeId,
|
|
72
74
|
clientId,
|
|
73
|
-
|
|
75
|
+
syncPayloadSchema = Schema.JsonValue,
|
|
76
|
+
syncPayloadEncoded,
|
|
74
77
|
makeSqliteDb,
|
|
75
78
|
syncOptions,
|
|
76
79
|
dbState,
|
|
@@ -81,6 +84,9 @@ export const makeLeaderThreadLayer = ({
|
|
|
81
84
|
testing,
|
|
82
85
|
}: MakeLeaderThreadLayerParams): Layer.Layer<LeaderThreadCtx, UnexpectedError, Scope.Scope | HttpClient.HttpClient> =>
|
|
83
86
|
Effect.gen(function* () {
|
|
87
|
+
const syncPayloadDecoded =
|
|
88
|
+
syncPayloadEncoded === undefined ? undefined : yield* Schema.decodeUnknown(syncPayloadSchema)(syncPayloadEncoded)
|
|
89
|
+
|
|
84
90
|
const bootStatusQueue = yield* Queue.unbounded<BootStatus>().pipe(Effect.acquireRelease(Queue.shutdown))
|
|
85
91
|
|
|
86
92
|
const dbEventlogMissing = !hasEventlogTables(dbEventlog)
|
|
@@ -93,7 +99,7 @@ export const makeLeaderThreadLayer = ({
|
|
|
93
99
|
const syncBackend =
|
|
94
100
|
syncOptions?.backend === undefined
|
|
95
101
|
? undefined
|
|
96
|
-
: yield* syncOptions.backend({ storeId, clientId, payload:
|
|
102
|
+
: yield* syncOptions.backend({ storeId, clientId, payload: syncPayloadDecoded }).pipe(
|
|
97
103
|
Effect.provide(
|
|
98
104
|
Layer.succeed(
|
|
99
105
|
KeyValueStore.KeyValueStore,
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isDevEnv } from '@livestore/utils'
|
|
2
|
+
import { Effect, type Layer, Logger, LogLevel } from '@livestore/utils/effect'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Optional Effect logger configuration that LiveStore entry points accept.
|
|
6
|
+
*
|
|
7
|
+
* When provided, `logger` replaces the default pretty logger for the runtime.
|
|
8
|
+
* Use `logLevel` to control verbosity. Set to `LogLevel.None` to disable logging
|
|
9
|
+
* entirely while keeping the same logger implementation.
|
|
10
|
+
*/
|
|
11
|
+
export type WithLoggerOptions = {
|
|
12
|
+
/** Optional Effect logger layer to control logging output. */
|
|
13
|
+
logger?: Layer.Layer<never> | undefined
|
|
14
|
+
/** Optional minimum log level for the runtime. */
|
|
15
|
+
logLevel?: LogLevel.LogLevel | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Common defaults for resolving a logger configuration.
|
|
20
|
+
* - `threadName` is used by the default pretty logger when `logger` is not provided.
|
|
21
|
+
* - `mode` selects pretty logger mode (e.g. 'browser' for web workers).
|
|
22
|
+
* - `defaultLogLevel` is used when `logLevel` is not provided.
|
|
23
|
+
*/
|
|
24
|
+
export type LoggerDefaults = {
|
|
25
|
+
threadName?: string
|
|
26
|
+
mode?: 'tty' | 'browser'
|
|
27
|
+
defaultLogLevel?: LogLevel.LogLevel
|
|
28
|
+
/** Optional default logger layer to use when `config.logger` is not provided. */
|
|
29
|
+
defaultLogger?: Layer.Layer<never>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the logger layer to provide to the Effect runtime.
|
|
34
|
+
*/
|
|
35
|
+
export const resolveLoggerLayer = (config?: WithLoggerOptions, defaults?: LoggerDefaults): Layer.Layer<never> => {
|
|
36
|
+
if (config?.logger) return config.logger
|
|
37
|
+
if (defaults?.defaultLogger) return defaults.defaultLogger
|
|
38
|
+
const threadName = defaults?.threadName ?? 'livestore'
|
|
39
|
+
const mode = defaults?.mode
|
|
40
|
+
return Logger.prettyWithThread(threadName, mode ? { mode } : {})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the minimum log level, falling back to `defaults.defaultLogLevel` then `LogLevel.Debug`.
|
|
45
|
+
*/
|
|
46
|
+
export const resolveLogLevel = (config?: WithLoggerOptions, defaults?: LoggerDefaults): LogLevel.LogLevel => {
|
|
47
|
+
if (config?.logLevel !== undefined) return config.logLevel
|
|
48
|
+
if (defaults?.defaultLogLevel !== undefined) return defaults.defaultLogLevel
|
|
49
|
+
return isDevEnv() ? LogLevel.Debug : LogLevel.Info
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wrap an effect by applying the resolved minimum log level and providing the resolved logger layer.
|
|
54
|
+
*/
|
|
55
|
+
export const withLoggerConfig = <TEnv, TError, TOut>(
|
|
56
|
+
config?: WithLoggerOptions,
|
|
57
|
+
defaults?: LoggerDefaults,
|
|
58
|
+
): ((effect: Effect.Effect<TOut, TError, TEnv>) => Effect.Effect<TOut, TError, TEnv>) => {
|
|
59
|
+
const level = resolveLogLevel(config, defaults)
|
|
60
|
+
const layer = resolveLoggerLayer(config, defaults)
|
|
61
|
+
return (effect) => effect.pipe(Logger.withMinimumLogLevel(level), Effect.provide(layer))
|
|
62
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Schema } from '@livestore/utils/effect'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { assert, describe, expect, it } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import * as State from '../mod.ts'
|
|
5
5
|
import { withAutoIncrement, withColumnType, withDefault, withPrimaryKey, withUnique } from './column-annotations.ts'
|
|
@@ -636,6 +636,29 @@ describe('getColumnDefForSchema', () => {
|
|
|
636
636
|
table2.sqliteDef.columns.count.default._tag === 'Some' && table2.sqliteDef.columns.count.default.value,
|
|
637
637
|
).toBe(0)
|
|
638
638
|
})
|
|
639
|
+
|
|
640
|
+
it('should support thunk defaults without eager evaluation', () => {
|
|
641
|
+
let counter = 0
|
|
642
|
+
const UserSchema = Schema.Struct({
|
|
643
|
+
id: Schema.String.pipe(
|
|
644
|
+
withDefault(() => {
|
|
645
|
+
counter += 1
|
|
646
|
+
return `user-${counter}`
|
|
647
|
+
}),
|
|
648
|
+
),
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
const table = State.SQLite.table({ name: 'users_with_thunk', schema: UserSchema })
|
|
652
|
+
|
|
653
|
+
expect(counter).toBe(0)
|
|
654
|
+
expect(table.sqliteDef.columns.id.default._tag).toBe('Some')
|
|
655
|
+
if (table.sqliteDef.columns.id.default._tag === 'Some') {
|
|
656
|
+
const defaultThunk = table.sqliteDef.columns.id.default.value
|
|
657
|
+
assert(typeof defaultThunk === 'function')
|
|
658
|
+
expect(defaultThunk()).toBe('user-1')
|
|
659
|
+
expect(defaultThunk()).toBe('user-2')
|
|
660
|
+
}
|
|
661
|
+
})
|
|
639
662
|
})
|
|
640
663
|
|
|
641
664
|
describe('withUnique', () => {
|
|
@@ -56,9 +56,9 @@ describe('makeColumnSpec', () => {
|
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
const result = makeColumnSpec(table)
|
|
59
|
-
expect(result).toMatchInlineSnapshot(`"
|
|
60
|
-
expect(result).toContain("
|
|
61
|
-
expect(result).toContain("
|
|
59
|
+
expect(result).toMatchInlineSnapshot(`""order" integer not null , "group" text "`)
|
|
60
|
+
expect(result).toContain('"order"')
|
|
61
|
+
expect(result).toContain('"group"')
|
|
62
62
|
})
|
|
63
63
|
|
|
64
64
|
it('should handle basic columns with primary keys', () => {
|
|
@@ -69,8 +69,7 @@ describe('makeColumnSpec', () => {
|
|
|
69
69
|
)
|
|
70
70
|
|
|
71
71
|
const result = makeColumnSpec(table)
|
|
72
|
-
expect(result).toMatchInlineSnapshot(`"
|
|
73
|
-
expect(result).toContain("PRIMARY KEY ('id')")
|
|
72
|
+
expect(result).toMatchInlineSnapshot(`""id" text primary key , "name" text "`)
|
|
74
73
|
})
|
|
75
74
|
|
|
76
75
|
it('should handle multi-column primary keys', () => {
|
|
@@ -85,9 +84,9 @@ describe('makeColumnSpec', () => {
|
|
|
85
84
|
|
|
86
85
|
const result = makeColumnSpec(table)
|
|
87
86
|
expect(result).toMatchInlineSnapshot(
|
|
88
|
-
`"
|
|
87
|
+
`""tenant_id" text not null , "user_id" text not null , PRIMARY KEY ("tenant_id", "user_id")"`,
|
|
89
88
|
)
|
|
90
|
-
expect(result).toContain(
|
|
89
|
+
expect(result).toContain('PRIMARY KEY ("tenant_id", "user_id")')
|
|
91
90
|
})
|
|
92
91
|
|
|
93
92
|
it('should handle auto-increment columns', () => {
|
|
@@ -101,9 +100,9 @@ describe('makeColumnSpec', () => {
|
|
|
101
100
|
)
|
|
102
101
|
|
|
103
102
|
const result = makeColumnSpec(table)
|
|
104
|
-
expect(result).toMatchInlineSnapshot(`"
|
|
103
|
+
expect(result).toMatchInlineSnapshot(`""id" integer primary key autoincrement , "title" text "`)
|
|
105
104
|
expect(result).toContain('autoincrement')
|
|
106
|
-
expect(result).toContain("PRIMARY KEY ('id')")
|
|
105
|
+
expect(result).not.toContain("PRIMARY KEY ('id')")
|
|
107
106
|
})
|
|
108
107
|
|
|
109
108
|
it('should handle columns with default values', () => {
|
|
@@ -121,7 +120,7 @@ describe('makeColumnSpec', () => {
|
|
|
121
120
|
|
|
122
121
|
const result = makeColumnSpec(table)
|
|
123
122
|
expect(result).toMatchInlineSnapshot(
|
|
124
|
-
`"
|
|
123
|
+
`""id" integer primary key , "name" text not null , "price" real default 0, "active" integer default true, "description" text default 'No description'"`,
|
|
125
124
|
)
|
|
126
125
|
expect(result).toContain('default 0')
|
|
127
126
|
expect(result).toContain('default true')
|
|
@@ -141,12 +140,28 @@ describe('makeColumnSpec', () => {
|
|
|
141
140
|
|
|
142
141
|
const result = makeColumnSpec(table)
|
|
143
142
|
expect(result).toMatchInlineSnapshot(
|
|
144
|
-
`"
|
|
143
|
+
`""id" integer primary key , "created_at" text default CURRENT_TIMESTAMP, "random_value" real default RANDOM()"`,
|
|
145
144
|
)
|
|
146
145
|
expect(result).toContain('default CURRENT_TIMESTAMP')
|
|
147
146
|
expect(result).toContain('default RANDOM()')
|
|
148
147
|
})
|
|
149
148
|
|
|
149
|
+
it('should omit default clause for thunk defaults', () => {
|
|
150
|
+
const table = SqliteAst.table(
|
|
151
|
+
'thunks',
|
|
152
|
+
[
|
|
153
|
+
createColumn('id', 'integer', { nullable: false, primaryKey: true }),
|
|
154
|
+
createColumn('token', 'text', { defaultValue: () => 'dynamic-token' }),
|
|
155
|
+
],
|
|
156
|
+
[],
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const result = makeColumnSpec(table)
|
|
160
|
+
expect(result).toMatchInlineSnapshot(`""id" integer primary key , "token" text "`)
|
|
161
|
+
expect(result).not.toContain('dynamic-token')
|
|
162
|
+
expect(result).not.toMatch(/token" text\s+default/i)
|
|
163
|
+
})
|
|
164
|
+
|
|
150
165
|
it('should handle null default values', () => {
|
|
151
166
|
const table = SqliteAst.table(
|
|
152
167
|
'nullable_defaults',
|
|
@@ -158,9 +173,7 @@ describe('makeColumnSpec', () => {
|
|
|
158
173
|
)
|
|
159
174
|
|
|
160
175
|
const result = makeColumnSpec(table)
|
|
161
|
-
expect(result).toMatchInlineSnapshot(
|
|
162
|
-
`"'id' integer not null , 'optional_text' text default null, PRIMARY KEY ('id')"`,
|
|
163
|
-
)
|
|
176
|
+
expect(result).toMatchInlineSnapshot(`""id" integer primary key , "optional_text" text default null"`)
|
|
164
177
|
expect(result).toContain('default null')
|
|
165
178
|
})
|
|
166
179
|
|
|
@@ -190,7 +203,7 @@ describe('makeColumnSpec', () => {
|
|
|
190
203
|
|
|
191
204
|
const result = makeColumnSpec(table)
|
|
192
205
|
expect(result).toMatchInlineSnapshot(
|
|
193
|
-
`"
|
|
206
|
+
`""id" integer primary key autoincrement , "name" text not null default 'Unnamed', "created_at" text not null default CURRENT_TIMESTAMP, "status" text default 'pending'"`,
|
|
194
207
|
)
|
|
195
208
|
})
|
|
196
209
|
|
|
@@ -213,7 +226,7 @@ describe('makeColumnSpec', () => {
|
|
|
213
226
|
const result = makeColumnSpec(table)
|
|
214
227
|
// The makeColumnSpec function only generates column specifications, not indexes
|
|
215
228
|
expect(result).toMatchInlineSnapshot(
|
|
216
|
-
`"
|
|
229
|
+
`""id" integer primary key autoincrement , "email" text not null , "username" text not null , "created_at" text default CURRENT_TIMESTAMP"`,
|
|
217
230
|
)
|
|
218
231
|
// Verify the table has the indexes (even though they're not in the column spec)
|
|
219
232
|
expect(table.indexes).toHaveLength(3)
|
|
@@ -10,33 +10,58 @@ import { type SqliteAst, SqliteDsl } from './db-schema/mod.ts'
|
|
|
10
10
|
* ```
|
|
11
11
|
*/
|
|
12
12
|
export const makeColumnSpec = (tableAst: SqliteAst.Table) => {
|
|
13
|
-
const
|
|
14
|
-
const
|
|
13
|
+
const pkColumns = tableAst.columns.filter((_) => _.primaryKey)
|
|
14
|
+
const hasSinglePk = pkColumns.length === 1
|
|
15
|
+
const pkColumn = hasSinglePk ? pkColumns[0] : undefined
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// Build column definitions, handling the special SQLite rule that AUTOINCREMENT
|
|
18
|
+
// is only valid on a single column declared as INTEGER PRIMARY KEY (column-level).
|
|
19
|
+
const columnDefStrs = tableAst.columns.map((column) =>
|
|
20
|
+
toSqliteColumnSpec(column, {
|
|
21
|
+
inlinePrimaryKey: hasSinglePk && column === pkColumn && column.primaryKey === true,
|
|
22
|
+
}),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// For composite primary keys, add a table-level PRIMARY KEY clause.
|
|
26
|
+
if (pkColumns.length > 1) {
|
|
27
|
+
const quotedPkCols = pkColumns.map((_) => `"${_.name}"`)
|
|
28
|
+
columnDefStrs.push(`PRIMARY KEY (${quotedPkCols.join(', ')})`)
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
return columnDefStrs.join(', ')
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/** NOTE primary keys are applied on a table level not on a column level to account for multi-column primary keys */
|
|
24
|
-
const toSqliteColumnSpec = (column: SqliteAst.Column) => {
|
|
35
|
+
const toSqliteColumnSpec = (column: SqliteAst.Column, opts: { inlinePrimaryKey: boolean }) => {
|
|
25
36
|
const columnTypeStr = column.type._tag
|
|
26
|
-
|
|
27
|
-
|
|
37
|
+
// When PRIMARY KEY is declared inline, NOT NULL is implied and should not be emitted,
|
|
38
|
+
// and AUTOINCREMENT must immediately follow PRIMARY KEY within the same constraint.
|
|
39
|
+
const nullableStr = opts.inlinePrimaryKey ? '' : column.nullable === false ? 'not null' : ''
|
|
40
|
+
|
|
41
|
+
// Only include AUTOINCREMENT when it's valid: single-column INTEGER PRIMARY KEY
|
|
42
|
+
const includeAutoIncrement = opts.inlinePrimaryKey && column.type._tag === 'integer' && column.autoIncrement === true
|
|
43
|
+
|
|
44
|
+
const pkStr = opts.inlinePrimaryKey ? 'primary key' : ''
|
|
45
|
+
const autoIncrementStr = includeAutoIncrement ? 'autoincrement' : ''
|
|
46
|
+
|
|
28
47
|
const defaultValueStr = (() => {
|
|
29
48
|
if (column.default._tag === 'None') return ''
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
if (SqliteDsl.
|
|
50
|
+
const defaultValue = column.default.value
|
|
51
|
+
if (SqliteDsl.isDefaultThunk(defaultValue)) return ''
|
|
52
|
+
|
|
53
|
+
const resolvedDefault = SqliteDsl.resolveColumnDefault(defaultValue)
|
|
54
|
+
|
|
55
|
+
if (resolvedDefault === null) return 'default null'
|
|
56
|
+
if (SqliteDsl.isSqlDefaultValue(resolvedDefault)) return `default ${resolvedDefault.sql}`
|
|
33
57
|
|
|
34
58
|
const encodeValue = Schema.encodeSync(column.schema)
|
|
35
|
-
const encodedDefaultValue = encodeValue(
|
|
59
|
+
const encodedDefaultValue = encodeValue(resolvedDefault)
|
|
36
60
|
|
|
37
61
|
if (columnTypeStr === 'text') return `default '${encodedDefaultValue}'`
|
|
38
62
|
return `default ${encodedDefaultValue}`
|
|
39
63
|
})()
|
|
40
64
|
|
|
41
|
-
|
|
65
|
+
// Ensure order: PRIMARY KEY [AUTOINCREMENT] [NOT NULL] ...
|
|
66
|
+
return `"${column.name}" ${columnTypeStr} ${pkStr} ${autoIncrementStr} ${nullableStr} ${defaultValueStr}`
|
|
42
67
|
}
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { casesHandled } from '@livestore/utils'
|
|
2
2
|
import { Option, Schema } from '@livestore/utils/effect'
|
|
3
3
|
|
|
4
|
+
export type SqlDefaultValue = {
|
|
5
|
+
readonly sql: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const isSqlDefaultValue = (value: unknown): value is SqlDefaultValue => {
|
|
9
|
+
return typeof value === 'object' && value !== null && 'sql' in value && typeof (value as any).sql === 'string'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ColumnDefaultThunk<T> = () => T
|
|
13
|
+
|
|
14
|
+
export const isDefaultThunk = (value: unknown): value is ColumnDefaultThunk<unknown> => typeof value === 'function'
|
|
15
|
+
|
|
16
|
+
export type ColumnDefaultValue<T> = T | null | ColumnDefaultThunk<T | null> | SqlDefaultValue
|
|
17
|
+
|
|
18
|
+
export const resolveColumnDefault = <T>(value: ColumnDefaultValue<T>): T | null | SqlDefaultValue =>
|
|
19
|
+
isDefaultThunk(value) ? (value as ColumnDefaultThunk<T | null>)() : value
|
|
20
|
+
|
|
4
21
|
export type ColumnDefinition<TEncoded, TDecoded> = {
|
|
5
22
|
readonly columnType: FieldColumnType
|
|
6
23
|
readonly schema: Schema.Schema<TDecoded, TEncoded>
|
|
7
|
-
readonly default: Option.Option<
|
|
24
|
+
readonly default: Option.Option<ColumnDefaultValue<TDecoded>>
|
|
8
25
|
/** @default false */
|
|
9
26
|
readonly nullable: boolean
|
|
10
27
|
/** @default false */
|
|
@@ -27,9 +44,17 @@ export const isColumnDefinition = (value: unknown): value is ColumnDefinition.An
|
|
|
27
44
|
)
|
|
28
45
|
}
|
|
29
46
|
|
|
47
|
+
type MaybeNull<T, TNullable extends boolean> = T | (TNullable extends true ? null : never)
|
|
48
|
+
|
|
49
|
+
type ColumnDefaultArg<T, TNullable extends boolean> =
|
|
50
|
+
| MaybeNull<T, TNullable>
|
|
51
|
+
| ColumnDefaultThunk<MaybeNull<T, TNullable>>
|
|
52
|
+
| SqlDefaultValue
|
|
53
|
+
| NoDefault
|
|
54
|
+
|
|
30
55
|
export type ColumnDefinitionInput = {
|
|
31
56
|
readonly schema?: Schema.Schema<unknown>
|
|
32
|
-
readonly default?: unknown
|
|
57
|
+
readonly default?: ColumnDefaultArg<unknown, boolean>
|
|
33
58
|
readonly nullable?: boolean
|
|
34
59
|
readonly primaryKey?: boolean
|
|
35
60
|
readonly autoIncrement?: boolean
|
|
@@ -38,14 +63,6 @@ export type ColumnDefinitionInput = {
|
|
|
38
63
|
export const NoDefault = Symbol.for('NoDefault')
|
|
39
64
|
export type NoDefault = typeof NoDefault
|
|
40
65
|
|
|
41
|
-
export type SqlDefaultValue = {
|
|
42
|
-
readonly sql: string
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export const isSqlDefaultValue = (value: unknown): value is SqlDefaultValue => {
|
|
46
|
-
return typeof value === 'object' && value !== null && 'sql' in value && typeof value.sql === 'string'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
66
|
export type ColDefFn<TColumnType extends FieldColumnType> = {
|
|
50
67
|
(): {
|
|
51
68
|
columnType: TColumnType
|
|
@@ -59,7 +76,7 @@ export type ColDefFn<TColumnType extends FieldColumnType> = {
|
|
|
59
76
|
TEncoded extends DefaultEncodedForColumnType<TColumnType>,
|
|
60
77
|
TDecoded = DefaultEncodedForColumnType<TColumnType>,
|
|
61
78
|
const TNullable extends boolean = false,
|
|
62
|
-
const TDefault extends TDecoded
|
|
79
|
+
const TDefault extends ColumnDefaultArg<NoInfer<TDecoded>, TNullable> = NoDefault,
|
|
63
80
|
const TPrimaryKey extends boolean = false,
|
|
64
81
|
const TAutoIncrement extends boolean = false,
|
|
65
82
|
>(args: {
|
|
@@ -132,7 +149,7 @@ export type SpecializedColDefFn<
|
|
|
132
149
|
<
|
|
133
150
|
TDecoded = TBaseDecoded,
|
|
134
151
|
const TNullable extends boolean = false,
|
|
135
|
-
const TDefault extends TDecoded
|
|
152
|
+
const TDefault extends ColumnDefaultArg<NoInfer<TDecoded>, TNullable> = NoDefault,
|
|
136
153
|
const TPrimaryKey extends boolean = false,
|
|
137
154
|
const TAutoIncrement extends boolean = false,
|
|
138
155
|
>(
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import * as State from '../mod.ts'
|
|
4
|
+
import { getDefaultValuesDecoded, getDefaultValuesEncoded } from './schema-helpers.ts'
|
|
5
|
+
|
|
6
|
+
describe('schema-helpers', () => {
|
|
7
|
+
it('resolves thunk defaults when decoding values', () => {
|
|
8
|
+
let counter = 0
|
|
9
|
+
const table = State.SQLite.table({
|
|
10
|
+
name: 'sessions',
|
|
11
|
+
columns: {
|
|
12
|
+
id: State.SQLite.text({ primaryKey: true }),
|
|
13
|
+
token: State.SQLite.text({ default: () => `token-${++counter}` }),
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
expect(counter).toBe(0)
|
|
18
|
+
|
|
19
|
+
const firstDefaults = getDefaultValuesDecoded(table)
|
|
20
|
+
const secondDefaults = getDefaultValuesDecoded(table)
|
|
21
|
+
|
|
22
|
+
expect(firstDefaults.token).toBe('token-1')
|
|
23
|
+
expect(secondDefaults.token).toBe('token-2')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('resolves thunk defaults when encoding values', () => {
|
|
27
|
+
let counter = 0
|
|
28
|
+
const table = State.SQLite.table({
|
|
29
|
+
name: 'sessions_encoded',
|
|
30
|
+
columns: {
|
|
31
|
+
id: State.SQLite.text({ primaryKey: true }),
|
|
32
|
+
token: State.SQLite.text({ default: () => `encoded-${++counter}` }),
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(counter).toBe(0)
|
|
37
|
+
|
|
38
|
+
const firstDefaults = getDefaultValuesEncoded(table)
|
|
39
|
+
const secondDefaults = getDefaultValuesEncoded(table)
|
|
40
|
+
|
|
41
|
+
expect(firstDefaults.token).toBe('encoded-1')
|
|
42
|
+
expect(secondDefaults.token).toBe('encoded-2')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -2,9 +2,9 @@ import { shouldNeverHappen } from '@livestore/utils'
|
|
|
2
2
|
import { pipe, ReadonlyRecord, Schema } from '@livestore/utils/effect'
|
|
3
3
|
|
|
4
4
|
import { SqliteDsl } from './db-schema/mod.ts'
|
|
5
|
-
import type {
|
|
5
|
+
import type { TableDefBase } from './table-def.ts'
|
|
6
6
|
|
|
7
|
-
export const getDefaultValuesEncoded = <TTableDef extends
|
|
7
|
+
export const getDefaultValuesEncoded = <TTableDef extends TableDefBase>(
|
|
8
8
|
tableDef: TTableDef,
|
|
9
9
|
fallbackValues?: Record<string, any>,
|
|
10
10
|
) =>
|
|
@@ -15,15 +15,19 @@ export const getDefaultValuesEncoded = <TTableDef extends TableDef>(
|
|
|
15
15
|
if (key === 'id') return false
|
|
16
16
|
return col!.default._tag === 'None' || SqliteDsl.isSqlDefaultValue(col!.default.value) === false
|
|
17
17
|
}),
|
|
18
|
-
ReadonlyRecord.map((column, columnName) =>
|
|
19
|
-
fallbackValues?.[columnName]
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
ReadonlyRecord.map((column, columnName) => {
|
|
19
|
+
if (fallbackValues?.[columnName] !== undefined) return fallbackValues[columnName]
|
|
20
|
+
if (column!.default._tag === 'None') {
|
|
21
|
+
return column!.nullable === true
|
|
22
|
+
? null
|
|
23
|
+
: shouldNeverHappen(`Column ${columnName} has no default value and is not nullable`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultValue = column!.default.value
|
|
27
|
+
const resolvedDefault = SqliteDsl.resolveColumnDefault(defaultValue)
|
|
28
|
+
|
|
29
|
+
return Schema.encodeSync(column!.schema)(resolvedDefault)
|
|
30
|
+
}),
|
|
27
31
|
)
|
|
28
32
|
|
|
29
33
|
export const getDefaultValuesDecoded = <TTableDef extends TableDefBase>(
|
|
@@ -37,13 +41,17 @@ export const getDefaultValuesDecoded = <TTableDef extends TableDefBase>(
|
|
|
37
41
|
if (key === 'id') return false
|
|
38
42
|
return col!.default._tag === 'None' || SqliteDsl.isSqlDefaultValue(col!.default.value) === false
|
|
39
43
|
}),
|
|
40
|
-
ReadonlyRecord.map((column, columnName) =>
|
|
41
|
-
fallbackValues?.[columnName]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
ReadonlyRecord.map((column, columnName) => {
|
|
45
|
+
if (fallbackValues?.[columnName] !== undefined) return fallbackValues[columnName]
|
|
46
|
+
if (column!.default._tag === 'None') {
|
|
47
|
+
return column!.nullable === true
|
|
48
|
+
? null
|
|
49
|
+
: shouldNeverHappen(`Column ${columnName} has no default value and is not nullable`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const defaultValue = column!.default.value
|
|
53
|
+
const resolvedDefault = SqliteDsl.resolveColumnDefault(defaultValue)
|
|
54
|
+
|
|
55
|
+
return Schema.validateSync(column!.schema)(resolvedDefault)
|
|
56
|
+
}),
|
|
49
57
|
)
|