@korajs/svelte 1.0.0-beta.12 → 1.0.0-beta.14

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,106 @@
1
+ import { asRichTextSyncEngine, createRichTextController } from '@korajs/store'
2
+ import type { AwarenessUser } from '@korajs/sync'
3
+ import { onDestroy } from 'svelte'
4
+ import { type Readable, get, readable } from 'svelte/store'
5
+ import { getKoraContext } from '../context'
6
+ import type { UseRichTextResult } from '../types'
7
+
8
+ export interface UseRichTextOptions {
9
+ user?: AwarenessUser
10
+ useDocChannel?: boolean
11
+ }
12
+
13
+ /**
14
+ * Binds a richtext field to a shared Yjs document for editor integration.
15
+ *
16
+ * Must be called during component initialization. For reactive target changes,
17
+ * use {@link KoraRichText} instead.
18
+ */
19
+ export function createRichTextBinding(
20
+ collectionName: string,
21
+ recordId: string,
22
+ fieldName: string,
23
+ options?: UseRichTextOptions,
24
+ ): UseRichTextResult & { subscribe: Readable<UseRichTextResult>['subscribe'] } {
25
+ const { store, syncEngine } = getKoraContext()
26
+
27
+ const controller = createRichTextController({
28
+ collection: store.collection(collectionName),
29
+ collectionName,
30
+ recordId,
31
+ fieldName,
32
+ store,
33
+ syncEngine: asRichTextSyncEngine(syncEngine),
34
+ useDocChannel: options?.useDocChannel,
35
+ user: options?.user,
36
+ })
37
+
38
+ onDestroy(() => {
39
+ controller.destroy()
40
+ })
41
+
42
+ const resultStore = readable<UseRichTextResult>(buildResult(controller), (set) => {
43
+ const sync = (): void => {
44
+ set(buildResult(controller))
45
+ }
46
+ sync()
47
+ return controller.subscribe(sync)
48
+ })
49
+
50
+ return {
51
+ get doc() {
52
+ return controller.doc
53
+ },
54
+ get text() {
55
+ return controller.text
56
+ },
57
+ undo: () => controller.undo(),
58
+ redo: () => controller.redo(),
59
+ get ready() {
60
+ return get(resultStore).ready
61
+ },
62
+ get error() {
63
+ return get(resultStore).error
64
+ },
65
+ get canUndo() {
66
+ return get(resultStore).canUndo
67
+ },
68
+ get canRedo() {
69
+ return get(resultStore).canRedo
70
+ },
71
+ get cursors() {
72
+ return get(resultStore).cursors
73
+ },
74
+ setCursor: (anchor: number, head: number) => controller.setCursor(anchor, head),
75
+ clearCursor: () => controller.clearCursor(),
76
+ get hasUnsavedChanges() {
77
+ return get(resultStore).hasUnsavedChanges
78
+ },
79
+ retrySave: () => controller.retrySave(),
80
+ getUnsavedState: () => controller.getUnsavedState(),
81
+ subscribe: resultStore.subscribe,
82
+ }
83
+ }
84
+
85
+ /** @alias createRichTextBinding */
86
+ export const useRichText = createRichTextBinding
87
+
88
+ function buildResult(controller: ReturnType<typeof createRichTextController>): UseRichTextResult {
89
+ const snapshot = controller.getSnapshot()
90
+ return {
91
+ doc: controller.doc,
92
+ text: controller.text,
93
+ undo: () => controller.undo(),
94
+ redo: () => controller.redo(),
95
+ ready: snapshot.ready,
96
+ error: snapshot.error,
97
+ canUndo: snapshot.canUndo,
98
+ canRedo: snapshot.canRedo,
99
+ cursors: [...snapshot.cursors],
100
+ setCursor: (anchor, head) => controller.setCursor(anchor, head),
101
+ clearCursor: () => controller.clearCursor(),
102
+ hasUnsavedChanges: snapshot.hasUnsavedChanges,
103
+ retrySave: () => controller.retrySave(),
104
+ getUnsavedState: () => controller.getUnsavedState(),
105
+ }
106
+ }
@@ -0,0 +1,30 @@
1
+ import { OFFLINE_SYNC_STATUS, createSyncStatusController } from '@korajs/sync'
2
+ import type { SyncStatusInfo } from '@korajs/sync'
3
+ import { type Readable, readable } from 'svelte/store'
4
+ import { getKoraContext } from '../context'
5
+
6
+ /**
7
+ * Readable store of sync engine status.
8
+ */
9
+ export function createSyncStatusStore(): Readable<SyncStatusInfo> {
10
+ const { syncEngine, subscribeSyncStatus, events } = getKoraContext()
11
+
12
+ return readable<SyncStatusInfo>(OFFLINE_SYNC_STATUS, (set) => {
13
+ const controller = createSyncStatusController({
14
+ syncEngine,
15
+ subscribeSyncStatus,
16
+ events: subscribeSyncStatus ? null : events,
17
+ })
18
+ set(controller.getSnapshot())
19
+ const unsubscribe = controller.subscribe(() => {
20
+ set(controller.getSnapshot())
21
+ })
22
+ return () => {
23
+ unsubscribe()
24
+ controller.destroy()
25
+ }
26
+ })
27
+ }
28
+
29
+ /** Alias for {@link createSyncStatusStore}. */
30
+ export const useSyncStatus = createSyncStatusStore
package/src/context.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { KoraError } from '@korajs/core'
2
+ import { QueryStoreCache } from '@korajs/store'
3
+ import { getContext, setContext } from 'svelte'
4
+ import type { KoraAppLike, KoraContextValue } from './types'
5
+
6
+ // Registered (Symbol.for) keys: the .svelte components are compiled from src/ by the app
7
+ // (the "svelte" export condition) while helpers such as getCollection come from dist/, so
8
+ // two copies of this module exist in every app. Module-local Symbol() keys made the
9
+ // provider's context invisible to the helpers ("Kora context missing").
10
+ const koraContextKey = Symbol.for('@korajs/svelte:context')
11
+ const koraAppContextKey = Symbol.for('@korajs/svelte:app')
12
+
13
+ export function setKoraContext(value: KoraContextValue): void {
14
+ setContext(koraContextKey, value)
15
+ }
16
+
17
+ export function getKoraContext(): KoraContextValue {
18
+ const context = getContext<KoraContextValue | undefined>(koraContextKey)
19
+ if (!context) {
20
+ throw new KoraError(
21
+ 'Kora context missing. Wrap your app with <KoraProvider app={kora}> or <KoraStoreProvider store={store}>.',
22
+ 'KORA_NOT_PROVIDED',
23
+ { fix: 'Wrap your app with <KoraProvider app={kora}> or <KoraStoreProvider store={store}>.' },
24
+ )
25
+ }
26
+ return context
27
+ }
28
+
29
+ /** @deprecated Use {@link setKoraContext} via {@link KoraProvider} or {@link KoraStoreProvider}. */
30
+ export function setKoraAppContext(koraApp: KoraAppLike): void {
31
+ setContext(koraAppContextKey, koraApp)
32
+ }
33
+
34
+ /** @deprecated Use {@link getKoraContext} and {@link getApp}. */
35
+ export function getKoraApp(): KoraAppLike {
36
+ const app = getContext<KoraAppLike | undefined>(koraAppContextKey)
37
+ if (!app) {
38
+ throw new KoraError(
39
+ 'getKoraApp() requires setKoraAppContext(koraApp) on an ancestor.',
40
+ 'KORA_NOT_PROVIDED',
41
+ )
42
+ }
43
+ return app
44
+ }
45
+
46
+ /**
47
+ * Initialize Kora provider context after `app.ready`.
48
+ *
49
+ * @deprecated Prefer {@link KoraProvider} or {@link KoraStoreProvider} in root layout.
50
+ */
51
+ export async function initKoraProvider(app: KoraAppLike): Promise<KoraContextValue> {
52
+ await app.ready
53
+ const queryStoreCache =
54
+ typeof app.getQueryStoreCache === 'function' ? app.getQueryStoreCache() : new QueryStoreCache()
55
+ const value: KoraContextValue = {
56
+ store: app.getStore(),
57
+ syncEngine: app.getSyncEngine(),
58
+ app,
59
+ events: app.events ?? null,
60
+ subscribeSyncStatus: app.sync?.subscribeStatus ?? null,
61
+ queryStoreCache,
62
+ }
63
+ setKoraContext(value)
64
+ setKoraAppContext(app)
65
+ return value
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ export type {
2
+ KoraAppHandle,
3
+ KoraAppLike,
4
+ KoraContextValue,
5
+ UseMutationOptions,
6
+ UseMutationResult,
7
+ QueryState,
8
+ UseQueryOptions,
9
+ UseRichTextResult,
10
+ } from './types'
11
+
12
+ export {
13
+ getKoraApp,
14
+ getKoraContext,
15
+ initKoraProvider,
16
+ setKoraAppContext,
17
+ setKoraContext,
18
+ } from './context'
19
+
20
+ export {
21
+ createQueryStateStore,
22
+ createQueryStore,
23
+ useQuery,
24
+ useQueryState,
25
+ } from './stores/query-store'
26
+ export type { QueryInput } from './stores/query-store'
27
+ export { createMutation, useMutation } from './composables/use-mutation'
28
+ export { createSyncStatusStore, useSyncStatus } from './composables/use-sync-status'
29
+ export { getApp, useApp } from './composables/use-app'
30
+ export { getCollection, useCollection } from './composables/use-collection'
31
+ export { createRichTextBinding, useRichText } from './composables/use-rich-text'
32
+ export type { UseRichTextOptions } from './composables/use-rich-text'
33
+ export { applyPresence, usePresence } from './composables/use-presence'
34
+ export { createCollaboratorsStore, useCollaborators } from './composables/use-collaborators'
@@ -0,0 +1,186 @@
1
+ import type { CollectionRecord, QueryBuilder, QueryStore, QueryStoreCache } from '@korajs/store'
2
+ import { assertQueryReady, queryKey } from '@korajs/store'
3
+ import { type Readable, readable } from 'svelte/store'
4
+ import { getKoraContext } from '../context'
5
+ import type { QueryState, UseQueryOptions } from '../types'
6
+
7
+ const EMPTY_ARRAY: readonly unknown[] = Object.freeze([])
8
+
9
+ /** A query input: a builder, or a readable store of one (null disables). */
10
+ export type QueryInput<T> = QueryBuilder<T> | Readable<QueryBuilder<T> | null | undefined>
11
+
12
+ function isQueryBuilder<T>(input: QueryInput<T>): input is QueryBuilder<T> {
13
+ return typeof (input as { getDescriptor?: unknown }).getDescriptor === 'function'
14
+ }
15
+
16
+ function isReadable<V>(input: V | Readable<V>): input is Readable<V> {
17
+ return (
18
+ typeof input === 'object' &&
19
+ input !== null &&
20
+ typeof (input as { subscribe?: unknown }).subscribe === 'function'
21
+ )
22
+ }
23
+
24
+ /** The STORE-12 error channel of a QueryStore, read structurally (older store builds lack it). */
25
+ function readQueryError(queryStore: QueryStore<unknown>): Error | null {
26
+ const channel = queryStore as unknown as { getError?: () => Error | null }
27
+ return typeof channel.getError === 'function' ? channel.getError() : null
28
+ }
29
+
30
+ /**
31
+ * Follows the query and `enabled` inputs (values or readable stores), holding one
32
+ * ref-counted QueryStore per descriptor and switching when the descriptor changes
33
+ * (DX-7). Returns the teardown.
34
+ */
35
+ function followQuery<T>(
36
+ cache: QueryStoreCache,
37
+ query: QueryInput<T>,
38
+ options: UseQueryOptions | undefined,
39
+ emit: (state: QueryState<T>) => void,
40
+ ): () => void {
41
+ let current: QueryBuilder<T> | null = isQueryBuilder(query) ? query : null
42
+ const enabledInput = options?.enabled
43
+ let enabled = isReadable(enabledInput) ? true : enabledInput !== false
44
+ let activeKey: string | null = null
45
+ let release: (() => void) | null = null
46
+ let state: QueryState<T> = {
47
+ data: EMPTY_ARRAY as readonly T[],
48
+ error: null,
49
+ ready: false,
50
+ }
51
+
52
+ const publish = (next: QueryState<T>): void => {
53
+ if (next.data === state.data && next.error === state.error && next.ready === state.ready) {
54
+ return
55
+ }
56
+ if (next.error && next.error !== state.error) options?.onError?.(next.error)
57
+ state = next
58
+ emit(state)
59
+ }
60
+
61
+ const apply = (): void => {
62
+ const key = enabled && current ? queryKey(current.getDescriptor()) : null
63
+ if (key === activeKey) return
64
+ release?.()
65
+ release = null
66
+ activeKey = key
67
+ if (!key || !current) {
68
+ publish({ data: EMPTY_ARRAY as readonly T[], error: null, ready: false })
69
+ return
70
+ }
71
+ const builder = current
72
+ assertQueryReady(builder as QueryBuilder<unknown>)
73
+ const queryStore = cache.getOrCreate(builder)
74
+ const sync = (): void => {
75
+ const loaded = queryStore.hasSnapshot()
76
+ publish({
77
+ // A descriptor switch keeps the previous rows until the new query answers.
78
+ data: loaded ? queryStore.getSnapshot() : state.data,
79
+ error: readQueryError(queryStore as QueryStore<unknown>),
80
+ ready: loaded,
81
+ })
82
+ }
83
+ const unsubscribe = queryStore.subscribe(sync)
84
+ sync()
85
+ release = () => {
86
+ unsubscribe()
87
+ cache.release(builder as QueryBuilder<unknown>)
88
+ }
89
+ }
90
+
91
+ const stops: Array<() => void> = []
92
+ if (!isQueryBuilder(query)) {
93
+ stops.push(
94
+ query.subscribe((next) => {
95
+ current = next ?? null
96
+ apply()
97
+ }),
98
+ )
99
+ }
100
+ if (isReadable(enabledInput)) {
101
+ stops.push(
102
+ enabledInput.subscribe((next) => {
103
+ enabled = next !== false
104
+ apply()
105
+ }),
106
+ )
107
+ }
108
+ apply()
109
+
110
+ return () => {
111
+ for (const stop of stops) stop()
112
+ release?.()
113
+ release = null
114
+ activeKey = null
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Create a Svelte readable store of a Kora query's rows.
120
+ *
121
+ * The query and `enabled` may be plain values or readable stores: pass a store (for
122
+ * example a `derived` of your filter) and the rows follow it, re-subscribing when the
123
+ * query's descriptor changes. A failed query is reported through `options.onError`,
124
+ * or `console.error` without one; use {@link createQueryStateStore} to render it.
125
+ *
126
+ * @param query - A query builder, or a readable store of one (null disables)
127
+ * @param options - `enabled` (value or readable store; default true) and `onError`
128
+ * @returns A readable store of the current rows
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * const filter = writable(false)
133
+ * const todos = createQueryStore(derived(filter, (done) => app.todos.where({ completed: done })))
134
+ * ```
135
+ */
136
+ export function createQueryStore<T = CollectionRecord>(
137
+ query: QueryInput<T>,
138
+ options?: UseQueryOptions,
139
+ ): Readable<readonly T[]> {
140
+ const { queryStoreCache } = getKoraContext()
141
+ const reporting: UseQueryOptions = {
142
+ ...options,
143
+ onError:
144
+ options?.onError ??
145
+ ((failure: Error) => {
146
+ // Never swallowed: without a handler the failure is at least logged.
147
+ reportUnhandled(failure)
148
+ }),
149
+ }
150
+ return readable<readonly T[]>(EMPTY_ARRAY as readonly T[], (set) =>
151
+ followQuery(queryStoreCache, query, reporting, (state) => set(state.data)),
152
+ )
153
+ }
154
+
155
+ /**
156
+ * Like {@link createQueryStore}, but the store holds `{ data, error, ready }`.
157
+ *
158
+ * @param query - A query builder, or a readable store of one
159
+ * @param options - `enabled` and `onError`
160
+ * @returns A readable store of the query state
161
+ */
162
+ export function createQueryStateStore<T = CollectionRecord>(
163
+ query: QueryInput<T>,
164
+ options?: UseQueryOptions,
165
+ ): Readable<QueryState<T>> {
166
+ const { queryStoreCache } = getKoraContext()
167
+ const initial: QueryState<T> = { data: EMPTY_ARRAY as readonly T[], error: null, ready: false }
168
+ return readable<QueryState<T>>(initial, (set) =>
169
+ followQuery(queryStoreCache, query, options, set),
170
+ )
171
+ }
172
+
173
+ /**
174
+ * Logs a query failure nobody handles. Read through globalThis because this package
175
+ * builds without DOM or Node typings.
176
+ */
177
+ function reportUnhandled(failure: Error): void {
178
+ const host = globalThis as { console?: { error(...args: unknown[]): void } }
179
+ host.console?.error('[Kora] createQueryStore failed:', failure)
180
+ }
181
+
182
+ /** Alias for {@link createQueryStore}. */
183
+ export const useQuery = createQueryStore
184
+
185
+ /** Alias for {@link createQueryStateStore}. */
186
+ export const useQueryState = createQueryStateStore
package/src/types.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type {
2
+ KoraAppLike as CoreKoraAppLike,
3
+ KoraContextValue as CoreKoraContextValue,
4
+ UseMutationOptions as CoreUseMutationOptions,
5
+ UseQueryOptions as CoreUseQueryOptions,
6
+ UseMutationResultBase,
7
+ } from '@korajs/core/bindings'
8
+ import type { QueryStoreCache, Store } from '@korajs/store'
9
+ import type { CursorInfo, SyncEngine, SyncStatusInfo } from '@korajs/sync'
10
+ import type { Readable } from 'svelte/store'
11
+ import type * as Y from 'yjs'
12
+
13
+ export type KoraAppLike = CoreKoraAppLike<Store, SyncEngine, QueryStoreCache>
14
+ export type KoraContextValue = CoreKoraContextValue<Store, SyncEngine, QueryStoreCache>
15
+ /** Options for `createQueryStore` and `createQueryStateStore`. */
16
+ export interface UseQueryOptions extends Omit<CoreUseQueryOptions, 'enabled'> {
17
+ /** When false, the query subscription is disabled. A value or a readable store. Defaults to true. */
18
+ enabled?: boolean | Readable<boolean>
19
+ /** Called when the query fails (STORE-12). `createQueryStore` logs to `console.error` without it. */
20
+ onError?: (error: Error) => void
21
+ }
22
+
23
+ /** Value of `createQueryStateStore`. */
24
+ export interface QueryState<T> {
25
+ /** The current rows (the last good rows while `error` is set). */
26
+ data: readonly T[]
27
+ /** The query's failure, or null. Cleared when results flow again. */
28
+ error: Error | null
29
+ /** False until the first result for the current query arrived. */
30
+ ready: boolean
31
+ }
32
+ export type UseMutationOptions<
33
+ TData,
34
+ TArgs extends unknown[],
35
+ TContext = void,
36
+ > = CoreUseMutationOptions<TData, TArgs, TContext>
37
+
38
+ export interface UseMutationResult<TData, TArgs extends unknown[]>
39
+ extends UseMutationResultBase<TData, TArgs> {
40
+ subscribeLoading: (fn: (value: boolean) => void) => () => void
41
+ subscribeError: (fn: (value: Error | null) => void) => () => void
42
+ readonly loading: boolean
43
+ readonly isLoading: boolean
44
+ readonly error: Error | null
45
+ }
46
+
47
+ /** @deprecated Use {@link KoraAppLike}. */
48
+ export type KoraAppHandle = KoraAppLike
49
+
50
+ export interface UseRichTextResult {
51
+ doc: Y.Doc
52
+ text: Y.Text
53
+ undo: () => void
54
+ redo: () => void
55
+ canUndo: boolean
56
+ canRedo: boolean
57
+ ready: boolean
58
+ error: Error | null
59
+ cursors: CursorInfo[]
60
+ setCursor: (anchor: number, head: number) => void
61
+ clearCursor: () => void
62
+ /**
63
+ * True while local edits are not saved yet (a save pending or refused; see
64
+ * `error`). The edits stay in the document and the next edit saves them again.
65
+ */
66
+ hasUnsavedChanges: boolean
67
+ /** Save the document now, for example after a refused save. */
68
+ retrySave: () => Promise<void>
69
+ /** The full document state while edits are unsaved (for a recovery copy), else null. */
70
+ getUnsavedState: () => Uint8Array | null
71
+ }