@livequery/client 1.0.92 → 2.0.0

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.
@@ -1,59 +1,47 @@
1
- import { Subject, Observable, ReplaySubject } from 'rxjs';
2
- import { ErrorInfo, LivequeryBaseEntity, QueryOption, Transporter, UpdatedData } from '@livequery/types';
1
+ import { Subject, Observable, BehaviorSubject } from 'rxjs';
2
+ import { LivequeryBaseEntity, QueryOption, Transporter, UpdatedData, Paging, Response } from '@livequery/types';
3
+ export type LoadingIndicator = false | 'backward' | 'forward' | 'both';
3
4
  export type CollectionOption<T extends LivequeryBaseEntity = LivequeryBaseEntity> = {
4
5
  transporter: Transporter;
5
6
  sync_delay?: number;
6
- filters?: Partial<QueryOption<T>>;
7
- reload_interval?: number;
7
+ options?: Partial<QueryOption<T>>;
8
8
  realtime?: boolean;
9
9
  };
10
10
  export type SmartQueryItem<T> = T & {
11
+ __remove: Function;
11
12
  __removing: boolean;
13
+ __update: (data: Partial<T>) => any;
12
14
  __updating: boolean;
13
15
  __adding: boolean;
14
- __remove: Function;
15
- __update: (data: Partial<T>) => any;
16
- __trigger: (name: string, payload?: any) => any;
16
+ __trigger: <R extends {}>(name: string, payload?: any) => Promise<Response<R>>;
17
17
  __ref: string;
18
18
  };
19
- type CollectionStream<T extends LivequeryBaseEntity = LivequeryBaseEntity> = {
19
+ export type CollectionStream<T extends LivequeryBaseEntity = LivequeryBaseEntity> = {
20
20
  items: SmartQueryItem<T>[];
21
- error?: ErrorInfo;
22
- has_more: boolean;
23
- loading?: boolean;
21
+ paging: Partial<Paging>;
22
+ loading?: LoadingIndicator;
24
23
  options: Partial<QueryOption<T>>;
24
+ error?: boolean;
25
+ code?: string;
26
+ message?: string;
25
27
  };
26
28
  export declare class CollectionObservable<T extends LivequeryBaseEntity = LivequeryBaseEntity> extends Observable<CollectionStream<T>> {
27
29
  #private;
28
30
  private ref;
29
31
  private collection_options;
30
32
  readonly $changes: Subject<UpdatedData<T>>;
31
- value: CollectionStream<T>;
32
- $: ReplaySubject<CollectionStream<T>>;
33
+ $: BehaviorSubject<CollectionStream<T>>;
33
34
  constructor(ref: string | false | null | '' | undefined, collection_options: CollectionOption<T>);
34
35
  set_realtime(realtime: boolean): void;
35
36
  private fetch_data;
36
- reload(): void;
37
37
  reset(): void;
38
38
  fetch_more(): void;
39
+ fetch_prev(): void;
39
40
  filter(filters: Partial<QueryOption<T>>): void;
40
- add<R = {
41
- data: {
42
- item: T;
43
- };
44
- }>(payload: Partial<T>): Promise<R>;
45
- update<R = {
46
- data: {
47
- item: T;
48
- };
49
- }>({ id: update_payload_id, ...payload }: Partial<T>): Promise<R | undefined>;
50
- remove<R = {
51
- data: {
52
- item: T;
53
- };
54
- }>(remove_document_id?: string): Promise<void>;
55
- trigger<R>(name: string, payload?: object, trigger_document_id?: string, query?: {
41
+ add(payload: Partial<T>): Promise<import("@livequery/types").DocumentResponse<T & LivequeryBaseEntity>>;
42
+ update({ id: update_payload_id, ...payload }: Partial<T>): Promise<import("@livequery/types").DocumentResponse<T & LivequeryBaseEntity> | undefined>;
43
+ remove(remove_document_id?: string): Promise<import("@livequery/types").DocumentResponse<LivequeryBaseEntity> | undefined>;
44
+ trigger<R extends {}>(name: string, payload?: object, trigger_document_id?: string, query?: {
56
45
  [key: string]: string | number | boolean;
57
- }): Promise<R | undefined>;
46
+ }): Promise<Response<R>>;
58
47
  }
59
- export {};
@@ -1,35 +1,30 @@
1
- import { Subject, Observable, merge, ReplaySubject } from 'rxjs';
2
- import { bufferTime, filter, map } from 'rxjs/operators';
1
+ import { Subject, Observable, merge, BehaviorSubject } from 'rxjs';
2
+ import { bufferTime, filter, finalize, first, map, share, skip, tap, toArray } from 'rxjs/operators';
3
3
  export class CollectionObservable extends Observable {
4
4
  ref;
5
5
  collection_options;
6
6
  $changes = new Subject();
7
+ #pages = new Map();
7
8
  #queries = new Set();
8
- #next_cursor = {};
9
+ #sorters = new Array;
9
10
  #IdMap = new Map();
10
11
  #refs = [];
11
- value = {
12
- has_more: false,
12
+ $ = new BehaviorSubject({
13
13
  items: [],
14
+ loading: false,
14
15
  options: {},
15
- loading: false
16
- };
17
- $ = new ReplaySubject(1);
16
+ paging: {}
17
+ });
18
18
  constructor(ref, collection_options) {
19
19
  super(o => {
20
- const subscription = this.$.subscribe(o);
21
- const auto_reload_interval = collection_options.reload_interval && setInterval(() => this.reload(), collection_options.reload_interval);
20
+ const linker = this.$.subscribe(o);
22
21
  return () => {
23
- subscription.unsubscribe();
22
+ linker.unsubscribe();
24
23
  this.#queries.forEach(s => s.unsubscribe());
25
- clearInterval(auto_reload_interval);
26
24
  };
27
25
  });
28
26
  this.ref = ref;
29
27
  this.collection_options = collection_options;
30
- this.collection_options.filters = this.collection_options.filters || {};
31
- if (collection_options.filters)
32
- this.value.options = collection_options.filters;
33
28
  if (ref && (ref.startsWith('/') || ref.endsWith('/')))
34
29
  throw 'INVAILD_REF_FORMAT';
35
30
  this.#refs = this.#ref_parser(ref);
@@ -50,19 +45,20 @@ export class CollectionObservable extends Observable {
50
45
  set_realtime(realtime) {
51
46
  this.collection_options.realtime = realtime;
52
47
  }
53
- #sync(stream, from_local = false) {
48
+ #sync(stream, from_local = false, direction) {
49
+ const state = this.$.getValue();
54
50
  const realtime = this.collection_options.realtime ?? true;
55
51
  const actions = { update: false, reindex: false };
56
- for (const { data, error, ref } of stream) {
57
- // Error & paging
58
- if (error) {
59
- this.value.error = error;
52
+ for (const { data, error, code, message } of stream) {
53
+ if (!from_local) {
54
+ state.loading = false;
60
55
  actions.update = true;
61
56
  }
62
- if (data?.paging?.n == 0) {
63
- this.#next_cursor[ref] = data?.paging?.next_cursor;
64
- this.value.has_more = Object.values(this.#next_cursor).some(v => v && v != '#');
65
- this.value.loading = false;
57
+ // Error & paging
58
+ if (error) {
59
+ state.error = true;
60
+ state.code = code;
61
+ state.message = message;
66
62
  actions.update = true;
67
63
  }
68
64
  // Sync
@@ -75,38 +71,34 @@ export class CollectionObservable extends Observable {
75
71
  if (index == -1 && type == 'added') {
76
72
  if (
77
73
  // Is first value from HTTP query
78
- data?.paging?.n == 0
79
- || (
80
- // Is realtime update that match filters
81
- (realtime || from_local) && Object
82
- .keys(this.value.options || {})
83
- .filter(key => !key.includes('_'))
84
- .every(key => {
85
- try {
86
- const [field, expression] = key.split(':');
87
- const a = payload[field];
88
- const b = this.value.options?.[field];
89
- if (!expression)
90
- return a == b;
91
- if (expression == 'ne')
92
- return a != b;
93
- if (expression == 'lt')
94
- return typeof a == 'number' && typeof b == 'number' && a < b;
95
- if (expression == 'lte')
96
- return typeof a == 'number' && typeof b == 'number' && a <= b;
97
- if (expression == 'gt')
98
- return typeof a == 'number' && typeof b == 'number' && a > b;
99
- if (expression == 'gte')
100
- return typeof a == 'number' && typeof b == 'number' && a >= b;
101
- if (expression == 'in' || expression == 'like')
102
- return Array.isArray(a) && a?.includes(b);
103
- if (expression == 'between')
104
- return (b[0] <= a && a <= b[1]);
105
- }
106
- catch (e) { }
107
- return false;
108
- }))) {
109
- this.value.items.push({
74
+ true
75
+ // || (
76
+ // // Is realtime update that match filters
77
+ // (realtime || from_local) && Object
78
+ // .keys(state.options || {})
79
+ // .filter(key => !key.includes('_'))
80
+ // .every(key => {
81
+ // try {
82
+ // const [field, expression] = key.split(':')
83
+ // const a = payload[field as keyof typeof payload] as number
84
+ // const b = state.options?.[field as keyof QueryOption<T>] as any as number
85
+ // if (!expression) return a == b
86
+ // if (expression == 'ne') return a != b
87
+ // if (expression == 'lt') return typeof a == 'number' && typeof b == 'number' && a < b
88
+ // if (expression == 'lte') return typeof a == 'number' && typeof b == 'number' && a <= b
89
+ // if (expression == 'gt') return typeof a == 'number' && typeof b == 'number' && a > b
90
+ // if (expression == 'gte') return typeof a == 'number' && typeof b == 'number' && a >= b
91
+ // if (expression == 'in' || expression == 'like') return Array.isArray(a) && a?.includes(b)
92
+ // if (expression == 'between') {
93
+ // const [x, y] = b as any as number[]
94
+ // return x <= a && a <= y
95
+ // }
96
+ // } catch (e) { }
97
+ // return false
98
+ // })
99
+ // )
100
+ ) {
101
+ const item = {
110
102
  ...payload,
111
103
  __adding: false,
112
104
  __updating: false,
@@ -115,7 +107,8 @@ export class CollectionObservable extends Observable {
115
107
  __trigger: (name, input) => this.trigger(name, input, payload?.id),
116
108
  __update: (input) => this.update({ ...input, id: payload?.id }),
117
109
  __ref: change.ref
118
- });
110
+ };
111
+ direction == 'forward' ? state.items.push(item) : state.items.unshift(item);
119
112
  actions.reindex = true;
120
113
  actions.update = true;
121
114
  }
@@ -123,11 +116,16 @@ export class CollectionObservable extends Observable {
123
116
  if (index >= 0 && (realtime || from_local)) {
124
117
  if (type == 'added' || type == 'modified') {
125
118
  actions.update = true;
126
- if (Object.keys(payload).some(key => ['created_at', this.collection_options?.filters?._order_by].includes(key))) {
119
+ const sort_key_value_updated = this.#sorters.some(({ key }) => {
120
+ const value = payload[key];
121
+ if (typeof value == 'string' || typeof value == 'number')
122
+ return true;
123
+ });
124
+ if (sort_key_value_updated) {
127
125
  actions.reindex = true;
128
126
  }
129
- this.value.items[index] = {
130
- ...this.value.items[index],
127
+ state.items[index] = {
128
+ ...state.items[index],
131
129
  __adding: false,
132
130
  __updating: false,
133
131
  __removing: false,
@@ -135,9 +133,8 @@ export class CollectionObservable extends Observable {
135
133
  };
136
134
  }
137
135
  if (type == 'removed') {
138
- actions.reindex = true;
139
136
  actions.update = true;
140
- this.value.items.splice(index, 1);
137
+ state.items.splice(index, 1);
141
138
  for (const [document_id, i] of this.#IdMap) {
142
139
  i == index && this.#IdMap.delete(document_id);
143
140
  i > index && this.#IdMap.set(document_id, i - 1);
@@ -147,65 +144,127 @@ export class CollectionObservable extends Observable {
147
144
  }
148
145
  }
149
146
  if (actions.reindex) {
150
- const _sort = this.collection_options?.filters?._sort || 'desc';
151
- const _order_by = this.collection_options?.filters?._order_by || 'created_at';
152
- this.value.items = this.value.items.sort((a, b) => {
153
- const ka = a[_order_by];
154
- const kb = b[_order_by];
155
- if (typeof ka == 'string' && typeof kb == 'string')
156
- return ka.localeCompare(ka) * (_sort == 'desc' ? -1 : 1);
157
- if ((typeof ka == 'number' || typeof ka == 'number')
158
- && (typeof kb == 'number' || typeof kb == 'number'))
159
- return (ka - kb) * (_sort == 'desc' ? -1 : 1);
160
- return 1;
147
+ state.items = state.items.sort((a, b) => {
148
+ for (const { key, order } of this.#sorters) {
149
+ const aa = a[key];
150
+ const bb = b[key];
151
+ if (typeof aa == 'number' && typeof bb == 'number') {
152
+ const rs = aa - bb;
153
+ if (rs == 0)
154
+ continue;
155
+ return rs * order;
156
+ }
157
+ if (typeof aa == 'string' && typeof bb == 'string') {
158
+ const rs = aa.localeCompare(bb);
159
+ if (rs == 0)
160
+ continue;
161
+ return rs * order;
162
+ }
163
+ }
164
+ return -1;
161
165
  });
162
166
  this.#IdMap.clear();
163
- this.value.items.map((item, index) => this.#IdMap.set(item.id, index));
167
+ state.items.map((item, index) => this.#IdMap.set(item.id, index));
168
+ }
169
+ if (direction) {
170
+ // Cache paging
171
+ this.#pages.clear();
172
+ stream.forEach(s => s.data?.paging && this.#pages.set(s.ref, s.data?.paging));
173
+ // Caculate paging here
174
+ const last_page_total = state.items.length;
175
+ const total = stream.reduce((p, c) => p + (c.data?.paging?.count?.total || 0), 0);
176
+ const prev = stream.reduce((p, c) => p + (c.data?.paging?.count?.prev || 0), 0);
177
+ const next = stream.reduce((p, c) => p + (c.data?.paging?.count?.next || 0), 0);
178
+ state.paging = {
179
+ count: {
180
+ current: state.items.length,
181
+ next: next - (direction == 'backward' ? last_page_total : 0),
182
+ prev: prev - (direction == 'forward' ? last_page_total : 0),
183
+ total
184
+ },
185
+ has: {
186
+ next: stream.some(s => s.data?.paging?.has?.next),
187
+ prev: stream.some(s => s.data?.paging?.has?.prev)
188
+ },
189
+ page: {
190
+ current: Math.min(...stream.map(s => s.data?.paging?.page.current || 0)),
191
+ total: Math.max(...stream.map(s => s.data?.paging?.page.total || 0))
192
+ }
193
+ };
164
194
  }
165
- actions.update && this.$.next(this.value);
195
+ actions.update && this.$.next(state);
166
196
  }
167
- fetch_data(options = {}, flush = false) {
197
+ fetch_data(options = {}, loading, flush = false) {
168
198
  if (!this.ref)
169
199
  return;
170
200
  if (this.#refs.length == 0)
171
201
  return;
202
+ if (this.$.getValue().loading)
203
+ return;
204
+ this.collection_options.options = options;
205
+ this.#sorters = Object.keys(options).filter(k => k.endsWith(':sort')).map(k => {
206
+ const key = k.split(':sort')[0];
207
+ const order = options[k] == 1 ? 1 : -1;
208
+ return { key, order };
209
+ });
210
+ this.#sorters.every(a => a.key != 'id') && this.#sorters.push({ key: 'id', order: -1 });
211
+ const state = {
212
+ ...this.$.getValue(),
213
+ items: flush ? [] : this.$.getValue().items,
214
+ loading
215
+ };
172
216
  if (flush) {
173
- this.#next_cursor = {};
217
+ this.#pages.clear();
174
218
  this.#queries.forEach(s => s.unsubscribe());
175
219
  this.#queries.clear();
176
220
  this.#IdMap.clear();
177
221
  }
178
- const has_more_data_refs = this.#refs.filter(ref => this.#next_cursor[ref] === undefined || (this.#next_cursor[ref] && this.#next_cursor[ref] != '#'));
179
- if (!flush && (has_more_data_refs.length == 0 || this.value.loading))
222
+ const remain_data_refs = this.#refs.filter(ref => {
223
+ const paging = this.#pages.get(ref);
224
+ if (!paging)
225
+ return true;
226
+ return loading == 'forward' ? paging.has.next : paging.has.prev;
227
+ });
228
+ const no_more_data = !flush && (remain_data_refs.length == 0 || this.$.getValue().loading);
229
+ if (no_more_data)
180
230
  return;
181
- this.value = {
182
- ...this.value,
183
- items: flush ? [] : this.value.items,
184
- error: undefined,
185
- loading: true,
186
- options
187
- };
188
- this.$.next(this.value);
189
- const queries = has_more_data_refs.map(ref => (this
190
- .collection_options
191
- .transporter
192
- .query(ref, { ...options, _cursor: this.#next_cursor[ref] })));
193
- const reload = () => queries.map(q => q.reload());
194
- const $ = merge(...queries.map((q, index) => q.pipe(map(data => ({ ...data, ref: has_more_data_refs[index] }))))).pipe(bufferTime(500), filter(list => list.length > 0), map(data => this.#sync(data)));
195
- const subscription = Object.assign($.subscribe(), { reload });
231
+ this.$.next(state);
232
+ const queries = remain_data_refs.map((ref, index) => {
233
+ const cursor = this.#pages.get(ref)?.cursor;
234
+ const opts = {
235
+ ...options,
236
+ ...loading == 'backward' && cursor?.first ? { ':before': cursor?.first } : {},
237
+ ...loading == 'forward' && cursor?.last ? { ':after': cursor?.last } : {},
238
+ };
239
+ return this
240
+ .collection_options
241
+ .transporter
242
+ .query(ref, opts).pipe(map(data => ({
243
+ ...data,
244
+ ref
245
+ })), share());
246
+ });
247
+ const first_values = merge(...queries.map(q => q.pipe(filter(r => !!r.data?.paging || !!r.error), first()))).pipe(toArray(), tap(list => this.#sync(list, false, loading))).subscribe();
248
+ const subscription = merge(...queries.map(q => q.pipe(skip(1)))).pipe(bufferTime(500), filter(list => list.length > 0), map(data => this.#sync(data, false, loading)), finalize(() => first_values.unsubscribe())).subscribe();
196
249
  this.#queries.add(subscription);
197
250
  }
198
- reload() {
199
- this.#queries.forEach(s => s.reload());
200
- }
201
251
  reset() {
202
- this.fetch_data({}, true);
252
+ this.fetch_data({}, 'both', true);
203
253
  }
204
254
  fetch_more() {
205
- this.fetch_data(this.value?.options);
255
+ const { options } = this.$.getValue();
256
+ this.fetch_data(options, 'forward');
257
+ }
258
+ fetch_prev() {
259
+ const { options } = this.$.getValue();
260
+ this.fetch_data(options, 'backward');
206
261
  }
262
+ // public fetch_around_cursor(cursor: string) {
263
+ // const state = this.$.getValue()
264
+ // this.fetch_data(state.options, 'both')
265
+ // }
207
266
  filter(filters) {
208
- this.fetch_data(filters, true);
267
+ this.fetch_data(filters, 'forward', true);
209
268
  }
210
269
  #find_ref_by_id(id) {
211
270
  if (!id || !this.ref)
@@ -213,7 +272,7 @@ export class CollectionObservable extends Observable {
213
272
  const index = this.#IdMap.get(id);
214
273
  if (index == undefined)
215
274
  return {};
216
- const origin_ref = this.value.items[index].__ref;
275
+ const origin_ref = this.$.getValue().items[index].__ref;
217
276
  if (!origin_ref)
218
277
  throw 'COLLECTION_REF_NOT_FOUND';
219
278
  const refs = origin_ref.split('/');
@@ -291,7 +350,7 @@ export class CollectionObservable extends Observable {
291
350
  async trigger(name, payload, trigger_document_id, query = {}) {
292
351
  const { ref } = this.#find_ref_by_id(trigger_document_id);
293
352
  if (!ref)
294
- return;
353
+ throw new Error('INVAILD_REF');
295
354
  return await this.collection_options.transporter.trigger(ref, name, query, payload);
296
355
  }
297
356
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "repository": {
5
5
  "url": "https://github.com/livequery/client"
6
6
  },
7
- "version": "1.0.92",
7
+ "version": "2.0.0",
8
8
  "description": "",
9
9
  "main": "build/index.js",
10
10
  "types": "build/index.d.ts",
@@ -12,12 +12,12 @@
12
12
  "build/**/*"
13
13
  ],
14
14
  "dependencies": {
15
- "rxjs": "^7.1.0",
16
- "typescript": "^4.9.5",
17
15
  "uuid": "^8.3.2"
18
16
  },
19
17
  "devDependencies": {
20
- "@livequery/types": "^1.0.79"
18
+ "typescript": "^4.9.5",
19
+ "rxjs": "^7.1.0",
20
+ "@livequery/types": "^2.0.0"
21
21
  },
22
22
  "scripts": {
23
23
  "test": "echo \"Error: no test specified\" && exit 1",