on-zero 0.6.2 → 0.6.3

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.
Files changed (31) hide show
  1. package/dist/cjs/helpers/recoverZeroClient.cjs +64 -7
  2. package/dist/cjs/helpers/recoverZeroClient.native.js +72 -9
  3. package/dist/cjs/helpers/recoverZeroClient.native.js.map +1 -1
  4. package/dist/cjs/helpers/recoverZeroClient.test.cjs +60 -0
  5. package/dist/cjs/helpers/recoverZeroClient.test.native.js +60 -0
  6. package/dist/cjs/helpers/recoverZeroClient.test.native.js.map +1 -1
  7. package/dist/cjs/index.cjs +1 -0
  8. package/dist/cjs/index.native.js +1 -0
  9. package/dist/cjs/index.native.js.map +1 -1
  10. package/dist/esm/helpers/recoverZeroClient.mjs +64 -8
  11. package/dist/esm/helpers/recoverZeroClient.mjs.map +1 -1
  12. package/dist/esm/helpers/recoverZeroClient.native.js +72 -10
  13. package/dist/esm/helpers/recoverZeroClient.native.js.map +1 -1
  14. package/dist/esm/helpers/recoverZeroClient.test.mjs +61 -1
  15. package/dist/esm/helpers/recoverZeroClient.test.mjs.map +1 -1
  16. package/dist/esm/helpers/recoverZeroClient.test.native.js +61 -1
  17. package/dist/esm/helpers/recoverZeroClient.test.native.js.map +1 -1
  18. package/dist/esm/index.js +2 -2
  19. package/dist/esm/index.js.map +1 -1
  20. package/dist/esm/index.mjs +2 -2
  21. package/dist/esm/index.mjs.map +1 -1
  22. package/dist/esm/index.native.js +2 -2
  23. package/dist/esm/index.native.js.map +1 -1
  24. package/package.json +2 -2
  25. package/src/helpers/recoverZeroClient.test.ts +66 -0
  26. package/src/helpers/recoverZeroClient.ts +91 -15
  27. package/src/index.ts +3 -0
  28. package/types/helpers/recoverZeroClient.d.ts +9 -1
  29. package/types/helpers/recoverZeroClient.d.ts.map +1 -1
  30. package/types/index.d.ts +1 -1
  31. package/types/index.d.ts.map +1 -1
@@ -4,6 +4,7 @@ import { UpdateNeededReasonType } from '@rocicorp/zero'
4
4
  import { beforeEach, describe, expect, test, vi } from 'vitest'
5
5
 
6
6
  import {
7
+ classifyZeroRecoveryLog,
7
8
  composeRecoveryLogSink,
8
9
  makeZeroRecovery,
9
10
  resetRecoveryStateForTests,
@@ -131,6 +132,38 @@ describe('zero recovery', () => {
131
132
  expect(reload).toHaveBeenCalledTimes(1)
132
133
  })
133
134
 
135
+ test('logSink recovers on native sqlite finalized-statement local-store loss', async () => {
136
+ const { deps, deleteLocalState, reload } = setup()
137
+ const sqliteError = new Error('This statement has been finalized')
138
+ sqliteError.name = 'SqliteError'
139
+ const sink = composeRecoveryLogSink(deps)
140
+ sink.log('error', { bgIntervalProcess: 'Heartbeat' }, 'Error running.', sqliteError)
141
+ await flush()
142
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
143
+ expect(reload).toHaveBeenCalledTimes(1)
144
+ })
145
+
146
+ test('logSink recovers on repeated store-closed local-store loss', async () => {
147
+ const nowSpy = vi.spyOn(Date, 'now')
148
+ const { deps, deleteLocalState, reload } = setup()
149
+ try {
150
+ const sink = composeRecoveryLogSink(deps)
151
+ nowSpy.mockReturnValue(10_000)
152
+ sink.log('error', undefined, 'Failed to connect.', new Error('Store is closed'))
153
+ await flush()
154
+ expect(deleteLocalState).not.toHaveBeenCalled()
155
+ expect(reload).not.toHaveBeenCalled()
156
+
157
+ nowSpy.mockReturnValue(13_000)
158
+ sink.log('error', undefined, 'Failed to connect.', new Error('Store is closed'))
159
+ await flush()
160
+ expect(deleteLocalState).toHaveBeenCalledTimes(1)
161
+ expect(reload).toHaveBeenCalledTimes(1)
162
+ } finally {
163
+ nowSpy.mockRestore()
164
+ }
165
+ })
166
+
134
167
  test('logSink with no consumer preserves console output and still watches', async () => {
135
168
  const { deps, deleteLocalState, reload } = setup()
136
169
  const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
@@ -170,6 +203,39 @@ describe('zero recovery', () => {
170
203
  expect(reload).not.toHaveBeenCalled()
171
204
  })
172
205
 
206
+ test('recovery log classification is narrow', () => {
207
+ const sqliteError = new Error('This statement has been finalized')
208
+ sqliteError.name = 'SqliteError'
209
+ expect(
210
+ classifyZeroRecoveryLog('error', ['Error running.', sqliteError]),
211
+ ).toMatchObject({
212
+ reason: 'sqlite-statement-finalized',
213
+ dropLocalState: true,
214
+ })
215
+ expect(
216
+ classifyZeroRecoveryLog('error', [
217
+ { name: 'SqliteError', message: 'database is locked' },
218
+ ]),
219
+ ).toBeUndefined()
220
+ expect(
221
+ classifyZeroRecoveryLog('warn', ['Expected IndexedDB not found']),
222
+ ).toBeUndefined()
223
+ expect(classifyZeroRecoveryLog('error', ['Store is closed'], 10_000)).toBeUndefined()
224
+ expect(classifyZeroRecoveryLog('error', ['Store is closed'], 11_000)).toBeUndefined()
225
+ expect(classifyZeroRecoveryLog('error', ['Store is closed'], 14_000)).toMatchObject({
226
+ reason: 'store-closed-repeat',
227
+ dropLocalState: true,
228
+ })
229
+ resetRecoveryStateForTests()
230
+ expect(
231
+ classifyZeroRecoveryLog(
232
+ 'error',
233
+ ['Mutator "send" error on server', 'Store is closed'],
234
+ 20_000,
235
+ ),
236
+ ).toBeUndefined()
237
+ })
238
+
173
239
  // a host with a `window` shim but no real `location` — the sootsim tenant
174
240
  // render-worker, which hides `location` for isolation. the DEFAULT reload
175
241
  // path (no injected `deps.reload`) must still drop stale IDB and not throw
@@ -10,6 +10,23 @@ const RECOVER_GUARD_MS = 60_000
10
10
  // the fatal local-store-loss signature Zero logs when the IndexedDB it expects
11
11
  // is gone (evicted, or deleted by another tab).
12
12
  const LOCAL_STORE_LOST = 'Expected IndexedDB not found'
13
+ const SQLITE_ERROR_NAME = 'SqliteError'
14
+ const SQLITE_STATEMENT_FINALIZED = 'This statement has been finalized'
15
+ const STORE_CLOSED = 'Store is closed'
16
+ const STORE_CLOSED_REPEAT_MIN_MS = 2_000
17
+ const STORE_CLOSED_REPEAT_MAX_MS = 60_000
18
+
19
+ export type ZeroRecoveryLogReason =
20
+ | 'indexeddb-not-found'
21
+ | 'sqlite-statement-finalized'
22
+ | 'store-closed-repeat'
23
+
24
+ export type ZeroRecoveryLogClassification = {
25
+ reason: ZeroRecoveryLogReason
26
+ reasonKey: string
27
+ message: string
28
+ dropLocalState: boolean
29
+ }
13
30
 
14
31
  export type ZeroRecoveryDeps = {
15
32
  // delete THIS client's local persistent state (its own scoped IDB). pass the
@@ -38,15 +55,22 @@ const pendingDeletes: Array<Promise<unknown>> = []
38
55
  function recoveryGuardOpen(reasonKey: string): boolean {
39
56
  try {
40
57
  const key = `on-zero-recover-${reasonKey}`
41
- const last = Number(window.sessionStorage.getItem(key) || 0)
42
- if (Date.now() - last < RECOVER_GUARD_MS) return false
43
- window.sessionStorage.setItem(key, String(Date.now()))
58
+ const rawLast = window.sessionStorage.getItem(key)
59
+ const now = Date.now()
60
+ const last = rawLast ? Number(rawLast) : 0
61
+ if (last > 0 && now - last < RECOVER_GUARD_MS) return false
62
+ window.sessionStorage.setItem(key, String(now))
44
63
  } catch {
45
64
  // no sessionStorage (private mode) — fall back to the in-memory latch.
46
65
  }
47
66
  return true
48
67
  }
49
68
 
69
+ // "Store is closed" is noisy during intentional instance replacement. One log is
70
+ // teardown noise; repeated logs seconds apart mean a live client is stuck using a
71
+ // closed local store and needs fresh local state.
72
+ let lastStoreClosedAtMs = 0
73
+
50
74
  function recover(
51
75
  deps: ZeroRecoveryDeps,
52
76
  reasonKey: string,
@@ -137,6 +161,66 @@ function logToConsole(
137
161
  console[method](...(prefix ? [prefix] : []), ...args)
138
162
  }
139
163
 
164
+ function logArgText(arg: unknown): string {
165
+ if (typeof arg === 'string') return arg
166
+ if (arg instanceof Error) return `${arg.name} ${arg.message} ${arg.stack || ''}`
167
+ if (arg && typeof arg === 'object') {
168
+ const message = 'message' in arg ? (arg as { message?: unknown }).message : undefined
169
+ const name = 'name' in arg ? (arg as { name?: unknown }).name : undefined
170
+ if (typeof message === 'string')
171
+ return `${typeof name === 'string' ? name : ''} ${message}`
172
+ }
173
+ return ''
174
+ }
175
+
176
+ function isBenignStoreClosedLog(text: string): boolean {
177
+ if (/Mutator\s+".*"\s+error on server/i.test(text)) return true
178
+ if (/Mutator\s+".*"\s+app error on client/i.test(text)) return true
179
+ return false
180
+ }
181
+
182
+ export function classifyZeroRecoveryLog(
183
+ level: LogLevel | string,
184
+ args: readonly unknown[],
185
+ nowMs = Date.now(),
186
+ ): ZeroRecoveryLogClassification | undefined {
187
+ if (level !== 'error') return undefined
188
+ const text = args.map(logArgText).join(' ')
189
+ if (text.includes(LOCAL_STORE_LOST)) {
190
+ return {
191
+ reason: 'indexeddb-not-found',
192
+ reasonKey: 'local-store',
193
+ message: 'local store lost',
194
+ dropLocalState: true,
195
+ }
196
+ }
197
+ if (text.includes(SQLITE_ERROR_NAME) && text.includes(SQLITE_STATEMENT_FINALIZED)) {
198
+ return {
199
+ reason: 'sqlite-statement-finalized',
200
+ reasonKey: 'sqlite-statement-finalized',
201
+ message: 'sqlite statement finalized',
202
+ dropLocalState: true,
203
+ }
204
+ }
205
+ if (text.includes(STORE_CLOSED) && !isBenignStoreClosedLog(text)) {
206
+ const prevMs = lastStoreClosedAtMs
207
+ lastStoreClosedAtMs = nowMs
208
+ if (
209
+ prevMs > 0 &&
210
+ nowMs - prevMs >= STORE_CLOSED_REPEAT_MIN_MS &&
211
+ nowMs - prevMs <= STORE_CLOSED_REPEAT_MAX_MS
212
+ ) {
213
+ return {
214
+ reason: 'store-closed-repeat',
215
+ reasonKey: 'store-closed-repeat',
216
+ message: 'local store closed repeatedly',
217
+ dropLocalState: true,
218
+ }
219
+ }
220
+ }
221
+ return undefined
222
+ }
223
+
140
224
  // watch error-level logs for the local-store-lost signature (the one fatal Zero
141
225
  // surfaces only through the log, not the structured callbacks) and trigger the
142
226
  // same recovery. forwards to the consumer sink, or to the console when there is
@@ -151,18 +235,9 @@ export function composeRecoveryLogSink(
151
235
  log(level: LogLevel, context: Context | undefined, ...args: unknown[]): void {
152
236
  if (consumerLogSink) consumerLogSink.log(level, context, ...args)
153
237
  else logToConsole(level, context, ...args)
154
- if (level !== 'error') return
155
- const text = args
156
- .map((arg) =>
157
- typeof arg === 'string'
158
- ? arg
159
- : arg && typeof arg === 'object' && 'message' in arg
160
- ? String((arg as { message: unknown }).message)
161
- : '',
162
- )
163
- .join(' ')
164
- if (text.includes(LOCAL_STORE_LOST)) {
165
- recover(deps, 'local-store', 'local store lost', true)
238
+ const recovery = classifyZeroRecoveryLog(level, args)
239
+ if (recovery) {
240
+ recover(deps, recovery.reasonKey, recovery.message, recovery.dropLocalState)
166
241
  }
167
242
  },
168
243
  // call through the consumer sink so a class-based sink keeps its `this`,
@@ -176,4 +251,5 @@ export function composeRecoveryLogSink(
176
251
  export function resetRecoveryStateForTests() {
177
252
  reloadScheduled = false
178
253
  pendingDeletes.length = 0
254
+ lastStoreClosedAtMs = 0
179
255
  }
package/src/index.ts CHANGED
@@ -42,7 +42,10 @@ export {
42
42
  type ZeroClientErrorInfo,
43
43
  } from './helpers/showZeroClientError'
44
44
  export {
45
+ classifyZeroRecoveryLog,
45
46
  makeZeroRecovery,
46
47
  composeRecoveryLogSink,
48
+ type ZeroRecoveryLogClassification,
49
+ type ZeroRecoveryLogReason,
47
50
  type ZeroRecoveryDeps,
48
51
  } from './helpers/recoverZeroClient'
@@ -1,7 +1,14 @@
1
1
  import type { Emitter } from '@take-out/helpers';
2
2
  import type { ZeroEvent } from '../types';
3
3
  import type { UpdateNeededReason } from '@rocicorp/zero';
4
- import type { LogSink } from '@rocicorp/logger';
4
+ import type { LogLevel, LogSink } from '@rocicorp/logger';
5
+ export type ZeroRecoveryLogReason = 'indexeddb-not-found' | 'sqlite-statement-finalized' | 'store-closed-repeat';
6
+ export type ZeroRecoveryLogClassification = {
7
+ reason: ZeroRecoveryLogReason;
8
+ reasonKey: string;
9
+ message: string;
10
+ dropLocalState: boolean;
11
+ };
5
12
  export type ZeroRecoveryDeps = {
6
13
  deleteLocalState: () => Promise<unknown>;
7
14
  zeroEvents: Emitter<ZeroEvent | null>;
@@ -12,6 +19,7 @@ export declare function makeZeroRecovery(deps: ZeroRecoveryDeps): {
12
19
  onUpdateNeeded(reason: UpdateNeededReason): void;
13
20
  onClientStateNotFound(): void;
14
21
  };
22
+ export declare function classifyZeroRecoveryLog(level: LogLevel | string, args: readonly unknown[], nowMs?: number): ZeroRecoveryLogClassification | undefined;
15
23
  export declare function composeRecoveryLogSink(deps: ZeroRecoveryDeps, consumerLogSink?: LogSink): LogSink;
16
24
  export declare function resetRecoveryStateForTests(): void;
17
25
  //# sourceMappingURL=recoverZeroClient.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"recoverZeroClient.d.ts","sourceRoot":"","sources":["../../src/helpers/recoverZeroClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACzC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACxD,OAAO,KAAK,EAAqB,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAUlE,MAAM,MAAM,gBAAgB,GAAG;IAI7B,gBAAgB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IAExC,UAAU,EAAE,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;IAGrC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAElC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AAsED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB;2BAE5B,kBAAkB;;EAsB5C;AAyBD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,gBAAgB,EACtB,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAwBT;AAID,wBAAgB,0BAA0B,SAGzC"}
1
+ {"version":3,"file":"recoverZeroClient.d.ts","sourceRoot":"","sources":["../../src/helpers/recoverZeroClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACzC,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACxD,OAAO,KAAK,EAAW,QAAQ,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAelE,MAAM,MAAM,qBAAqB,GAC7B,qBAAqB,GACrB,4BAA4B,GAC5B,qBAAqB,CAAA;AAEzB,MAAM,MAAM,6BAA6B,GAAG;IAC1C,MAAM,EAAE,qBAAqB,CAAA;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,cAAc,EAAE,OAAO,CAAA;CACxB,CAAA;AAED,MAAM,MAAM,gBAAgB,GAAG;IAI7B,gBAAgB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAA;IAExC,UAAU,EAAE,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAAA;IAGrC,YAAY,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAElC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACpB,CAAA;AA6ED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,gBAAgB;2BAE5B,kBAAkB;;EAsB5C;AAsCD,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,QAAQ,GAAG,MAAM,EACxB,IAAI,EAAE,SAAS,OAAO,EAAE,EACxB,KAAK,SAAa,GACjB,6BAA6B,GAAG,SAAS,CAoC3C;AAOD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,gBAAgB,EACtB,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAeT;AAID,wBAAgB,0BAA0B,SAIzC"}
package/types/index.d.ts CHANGED
@@ -21,5 +21,5 @@ export { defineQuery, defineQueries } from '@rocicorp/zero';
21
21
  export type * from './types';
22
22
  export { clearZeroClientData, type ClearZeroClientDataOptions, } from './helpers/clearZeroClientData';
23
23
  export { showZeroClientErrorOnce, resetShownZeroClientError, type ShowZeroClientErrorOptions, type ZeroClientErrorInfo, } from './helpers/showZeroClientError';
24
- export { makeZeroRecovery, composeRecoveryLogSink, type ZeroRecoveryDeps, } from './helpers/recoverZeroClient';
24
+ export { classifyZeroRecoveryLog, makeZeroRecovery, composeRecoveryLogSink, type ZeroRecoveryLogClassification, type ZeroRecoveryLogReason, type ZeroRecoveryDeps, } from './helpers/recoverZeroClient';
25
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,uBAAuB,CAAA;AACrC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAErD,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,cAAc,EACnB,KAAK,kBAAkB,GACxB,MAAM,oBAAoB,CAAA;AAC3B,cAAc,qBAAqB,CAAA;AACnC,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAA;AACzD,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,eAAe,CAAA;AAC7B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAM3D,mBAAmB,SAAS,CAAA;AAE5B,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,GAChC,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,KAAK,0BAA0B,EAC/B,KAAK,mBAAmB,GACzB,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,gBAAgB,GACtB,MAAM,6BAA6B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,0BAA0B,CAAA;AACxC,cAAc,uBAAuB,CAAA;AACrC,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAErD,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,cAAc,EACnB,KAAK,kBAAkB,GACxB,MAAM,oBAAoB,CAAA;AAC3B,cAAc,qBAAqB,CAAA;AACnC,cAAc,kBAAkB,CAAA;AAChC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,SAAS,EAAE,KAAK,UAAU,EAAE,MAAM,cAAc,CAAA;AACzD,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,eAAe,CAAA;AAC7B,cAAc,OAAO,CAAA;AACrB,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAA;AAM3D,mBAAmB,SAAS,CAAA;AAE5B,OAAO,EACL,mBAAmB,EACnB,KAAK,0BAA0B,GAChC,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,KAAK,0BAA0B,EAC/B,KAAK,mBAAmB,GACzB,MAAM,+BAA+B,CAAA;AACtC,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,6BAA6B,EAClC,KAAK,qBAAqB,EAC1B,KAAK,gBAAgB,GACtB,MAAM,6BAA6B,CAAA"}