@lark-sh/client 0.1.11 → 0.1.12

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/index.mjs CHANGED
@@ -13,7 +13,8 @@ var ErrorCode = {
13
13
  INVALID_OPERATION: "invalid_operation",
14
14
  INTERNAL_ERROR: "internal_error",
15
15
  CONDITION_FAILED: "condition_failed",
16
- INVALID_QUERY: "invalid_query"
16
+ INVALID_QUERY: "invalid_query",
17
+ AUTH_REQUIRED: "auth_required"
17
18
  };
18
19
  var DEFAULT_COORDINATOR_URL = "https://db.lark.sh";
19
20
 
@@ -419,6 +420,9 @@ function isAckMessage(msg) {
419
420
  function isJoinCompleteMessage(msg) {
420
421
  return "jc" in msg;
421
422
  }
423
+ function isAuthCompleteMessage(msg) {
424
+ return "ac" in msg;
425
+ }
422
426
  function isNackMessage(msg) {
423
427
  return "n" in msg;
424
428
  }
@@ -484,6 +488,18 @@ var MessageQueue = class {
484
488
  return true;
485
489
  }
486
490
  }
491
+ if (isAuthCompleteMessage(message)) {
492
+ const pending = this.pending.get(message.ac);
493
+ if (pending) {
494
+ clearTimeout(pending.timeout);
495
+ this.pending.delete(message.ac);
496
+ const response = {
497
+ uid: message.au || null
498
+ };
499
+ pending.resolve(response);
500
+ return true;
501
+ }
502
+ }
487
503
  if (isAckMessage(message)) {
488
504
  const pending = this.pending.get(message.a);
489
505
  if (pending) {
@@ -507,7 +523,7 @@ var MessageQueue = class {
507
523
  if (pending) {
508
524
  clearTimeout(pending.timeout);
509
525
  this.pending.delete(message.oc);
510
- pending.resolve(message.ov);
526
+ pending.resolve(message.ov ?? null);
511
527
  return true;
512
528
  }
513
529
  }
@@ -805,7 +821,7 @@ function getNestedValue(obj, path) {
805
821
  }
806
822
  function getSortValue(value, queryParams) {
807
823
  if (!queryParams) {
808
- return null;
824
+ return getNestedValue(value, ".priority");
809
825
  }
810
826
  if (queryParams.orderBy === "priority") {
811
827
  return getNestedValue(value, ".priority");
@@ -820,13 +836,14 @@ function getSortValue(value, queryParams) {
820
836
  return null;
821
837
  }
822
838
  const hasRangeFilter = queryParams.startAt !== void 0 || queryParams.startAfter !== void 0 || queryParams.endAt !== void 0 || queryParams.endBefore !== void 0 || queryParams.equalTo !== void 0;
823
- if (hasRangeFilter) {
839
+ const hasLimit = queryParams.limitToFirst !== void 0 || queryParams.limitToLast !== void 0;
840
+ if (hasRangeFilter || hasLimit) {
824
841
  return getNestedValue(value, ".priority");
825
842
  }
826
843
  return null;
827
844
  }
828
845
  function compareEntries(a, b, queryParams) {
829
- if (!queryParams || queryParams.orderBy === "key" || !queryParams.orderBy && !queryParams.orderByChild) {
846
+ if (queryParams?.orderBy === "key") {
830
847
  return compareKeys(a.key, b.key);
831
848
  }
832
849
  const cmp = compareValues(a.sortValue, b.sortValue);
@@ -1284,7 +1301,7 @@ var View = class {
1284
1301
  return { value: displayCache, found: true };
1285
1302
  }
1286
1303
  if (this._hasReceivedInitialSnapshot || this._pendingWriteData.length > 0) {
1287
- return { value: displayCache, found: true };
1304
+ return { value: displayCache ?? null, found: true };
1288
1305
  }
1289
1306
  return { value: void 0, found: false };
1290
1307
  }
@@ -1292,7 +1309,7 @@ var View = class {
1292
1309
  const relativePath = this.path === "/" ? normalized : normalized.slice(this.path.length);
1293
1310
  if (this._hasReceivedInitialSnapshot || this._pendingWriteData.length > 0) {
1294
1311
  const extractedValue = getValueAtPath(displayCache, relativePath);
1295
- return { value: extractedValue, found: true };
1312
+ return { value: extractedValue ?? null, found: true };
1296
1313
  }
1297
1314
  }
1298
1315
  return { value: void 0, found: false };
@@ -1385,6 +1402,17 @@ var View = class {
1385
1402
  if (!this.queryParams) return false;
1386
1403
  return !!(this.queryParams.limitToFirst || this.queryParams.limitToLast);
1387
1404
  }
1405
+ /**
1406
+ * Check if this View loads all data (no limits, no range filters).
1407
+ * A View that loads all data can serve as a "complete" cache for child paths.
1408
+ * This matches Firebase's loadsAllData() semantics.
1409
+ */
1410
+ loadsAllData() {
1411
+ if (!this.queryParams) return true;
1412
+ const hasLimit = !!(this.queryParams.limitToFirst || this.queryParams.limitToLast);
1413
+ const hasRangeFilter = this.queryParams.startAt !== void 0 || this.queryParams.endAt !== void 0 || this.queryParams.equalTo !== void 0 || this.queryParams.startAfter !== void 0 || this.queryParams.endBefore !== void 0;
1414
+ return !hasLimit && !hasRangeFilter;
1415
+ }
1388
1416
  // ============================================
1389
1417
  // Pending Writes (for local-first)
1390
1418
  // ============================================
@@ -1557,10 +1585,13 @@ var SubscriptionManager = class {
1557
1585
  this.unsubscribeCallback(normalizedPath, eventType, callback, queryId);
1558
1586
  };
1559
1587
  if (isNewView || isNewEventType || queryParamsChanged) {
1560
- const allEventTypes = view.getEventTypes();
1561
- this.sendSubscribe?.(normalizedPath, allEventTypes, view.queryParams ?? void 0, tag).catch((err) => {
1562
- console.error("Failed to subscribe:", err);
1563
- });
1588
+ const hasAncestorComplete = this.hasAncestorCompleteView(normalizedPath);
1589
+ if (!hasAncestorComplete) {
1590
+ const allEventTypes = view.getEventTypes();
1591
+ this.sendSubscribe?.(normalizedPath, allEventTypes, view.queryParams ?? void 0, tag).catch((err) => {
1592
+ console.error("Failed to subscribe:", err);
1593
+ });
1594
+ }
1564
1595
  }
1565
1596
  if (!isNewView && view.hasReceivedInitialSnapshot) {
1566
1597
  this.fireInitialEventsToCallback(view, eventType, callback);
@@ -1788,18 +1819,23 @@ var SubscriptionManager = class {
1788
1819
  }
1789
1820
  }
1790
1821
  /**
1791
- * Detect and fire child_moved events for children that changed position.
1822
+ * Detect and fire child_moved events for children that changed position OR sort value.
1823
+ *
1824
+ * Firebase fires child_moved for ANY priority/sort value change, regardless of whether
1825
+ * the position actually changes. This is Case 2003 behavior.
1792
1826
  *
1793
- * IMPORTANT: child_moved should only fire for children whose VALUE changed
1794
- * and caused a position change. Children that are merely "displaced" by
1795
- * another child moving should NOT fire child_moved.
1827
+ * IMPORTANT: child_moved should only fire for children whose VALUE or PRIORITY changed.
1828
+ * Children that are merely "displaced" by another child moving should NOT fire child_moved.
1796
1829
  *
1797
1830
  * @param affectedChildren - Only check these children for moves. If not provided,
1798
1831
  * checks all children (for full snapshots where we compare values).
1832
+ * @param previousDisplayCache - Previous display cache for comparing sort values
1833
+ * @param currentDisplayCache - Current display cache for comparing sort values
1799
1834
  */
1800
- detectAndFireMoves(view, previousOrder, currentOrder, previousPositions, currentPositions, previousChildSet, currentChildSet, childMovedSubs, isVolatile, serverTimestamp, affectedChildren) {
1835
+ detectAndFireMoves(view, previousOrder, currentOrder, previousPositions, currentPositions, previousChildSet, currentChildSet, childMovedSubs, isVolatile, serverTimestamp, affectedChildren, previousDisplayCache, currentDisplayCache) {
1801
1836
  if (childMovedSubs.length === 0) return;
1802
1837
  const childrenToCheck = affectedChildren ?? new Set(currentOrder);
1838
+ const queryParams = view.queryParams;
1803
1839
  for (const key of childrenToCheck) {
1804
1840
  if (!previousChildSet.has(key) || !currentChildSet.has(key)) {
1805
1841
  continue;
@@ -1811,7 +1847,24 @@ var SubscriptionManager = class {
1811
1847
  }
1812
1848
  const oldPrevKey = oldPos > 0 ? previousOrder[oldPos - 1] : null;
1813
1849
  const newPrevKey = newPos > 0 ? currentOrder[newPos - 1] : null;
1814
- if (oldPrevKey !== newPrevKey) {
1850
+ let positionChanged = oldPrevKey !== newPrevKey;
1851
+ let sortValueChanged = false;
1852
+ let isPriorityOrdering = false;
1853
+ if (previousDisplayCache && currentDisplayCache) {
1854
+ const prevValue = previousDisplayCache[key];
1855
+ const currValue = currentDisplayCache[key];
1856
+ const prevSortValue = getSortValue(prevValue, queryParams);
1857
+ const currSortValue = getSortValue(currValue, queryParams);
1858
+ sortValueChanged = JSON.stringify(prevSortValue) !== JSON.stringify(currSortValue);
1859
+ isPriorityOrdering = !queryParams?.orderBy || queryParams.orderBy === "priority";
1860
+ }
1861
+ let shouldFire;
1862
+ if (affectedChildren) {
1863
+ shouldFire = positionChanged || isPriorityOrdering && sortValueChanged;
1864
+ } else {
1865
+ shouldFire = isPriorityOrdering && sortValueChanged;
1866
+ }
1867
+ if (shouldFire) {
1815
1868
  this.fireChildMoved(view, key, childMovedSubs, newPrevKey, isVolatile, serverTimestamp);
1816
1869
  }
1817
1870
  }
@@ -1855,10 +1908,10 @@ var SubscriptionManager = class {
1855
1908
  /**
1856
1909
  * Fire child_removed callbacks for a child key.
1857
1910
  */
1858
- fireChildRemoved(view, childKey, subs, isVolatile, serverTimestamp) {
1911
+ fireChildRemoved(view, childKey, subs, isVolatile, serverTimestamp, previousValue) {
1859
1912
  if (subs.length === 0) return;
1860
1913
  const childPath = joinPath(view.path, childKey);
1861
- const snapshot = this.createSnapshot?.(childPath, null, isVolatile, serverTimestamp);
1914
+ const snapshot = this.createSnapshot?.(childPath, previousValue ?? null, isVolatile, serverTimestamp);
1862
1915
  if (snapshot) {
1863
1916
  for (const entry of subs) {
1864
1917
  try {
@@ -1887,6 +1940,30 @@ var SubscriptionManager = class {
1887
1940
  }
1888
1941
  }
1889
1942
  }
1943
+ /**
1944
+ * Handle subscription revocation due to auth change.
1945
+ * The server has already removed the subscription, so we just clean up locally.
1946
+ * This is different from unsubscribeAll which sends an unsubscribe message.
1947
+ */
1948
+ handleSubscriptionRevoked(path) {
1949
+ const normalizedPath = path;
1950
+ const queryIds = this.pathToQueryIds.get(normalizedPath);
1951
+ if (!queryIds || queryIds.size === 0) return;
1952
+ for (const queryId of queryIds) {
1953
+ const viewKey = this.makeViewKey(normalizedPath, queryId);
1954
+ const view = this.views.get(viewKey);
1955
+ if (view) {
1956
+ const tag = this.viewKeyToTag.get(viewKey);
1957
+ if (tag !== void 0) {
1958
+ this.tagToViewKey.delete(tag);
1959
+ this.viewKeyToTag.delete(viewKey);
1960
+ }
1961
+ view.clear();
1962
+ this.views.delete(viewKey);
1963
+ }
1964
+ }
1965
+ this.pathToQueryIds.delete(normalizedPath);
1966
+ }
1890
1967
  /**
1891
1968
  * Clear all subscriptions (e.g., on disconnect).
1892
1969
  */
@@ -1908,35 +1985,71 @@ var SubscriptionManager = class {
1908
1985
  return queryIds !== void 0 && queryIds.size > 0;
1909
1986
  }
1910
1987
  /**
1911
- * Check if a path is "covered" by an active subscription.
1988
+ * Check if a path is "covered" by an active subscription that has received data.
1912
1989
  *
1913
1990
  * A path is covered if:
1914
- * - There's an active 'value' subscription at that exact path, OR
1915
- * - There's an active 'value' subscription at an ancestor path
1991
+ * - There's an active subscription at that exact path that has data, OR
1992
+ * - There's an active subscription at an ancestor path that has data
1993
+ *
1994
+ * Note: Any subscription type (value, child_added, child_moved, etc.) receives
1995
+ * the initial snapshot from the server and thus has cached data.
1916
1996
  */
1917
1997
  isPathCovered(path) {
1918
1998
  const normalized = normalizePath(path);
1919
- if (this.hasValueSubscription(normalized)) {
1999
+ if (this.hasActiveSubscriptionWithData(normalized)) {
1920
2000
  return true;
1921
2001
  }
1922
2002
  const segments = normalized.split("/").filter((s) => s.length > 0);
1923
2003
  for (let i = segments.length - 1; i >= 0; i--) {
1924
2004
  const ancestorPath = i === 0 ? "/" : "/" + segments.slice(0, i).join("/");
1925
- if (this.hasValueSubscription(ancestorPath)) {
2005
+ if (this.hasActiveSubscriptionWithData(ancestorPath)) {
1926
2006
  return true;
1927
2007
  }
1928
2008
  }
1929
- if (normalized !== "/" && this.hasValueSubscription("/")) {
2009
+ if (normalized !== "/" && this.hasActiveSubscriptionWithData("/")) {
1930
2010
  return true;
1931
2011
  }
1932
2012
  return false;
1933
2013
  }
1934
2014
  /**
1935
- * Check if there's a 'value' subscription at a path (any query identifier).
2015
+ * Check if there's an active subscription at a path that has data.
2016
+ * A View has data if it has received the initial snapshot OR has pending writes.
2017
+ * Any subscription type (value, child_added, child_moved, etc.) counts.
1936
2018
  */
1937
- hasValueSubscription(path) {
2019
+ hasActiveSubscriptionWithData(path) {
1938
2020
  const views = this.getViewsAtPath(path);
1939
- return views.some((view) => view.hasCallbacksForType("value"));
2021
+ return views.some((view) => view.hasCallbacks() && (view.hasReceivedInitialSnapshot || view.hasPendingWrites()));
2022
+ }
2023
+ /**
2024
+ * Check if any ancestor path has a "complete" View (one that loadsAllData).
2025
+ * A complete View has no limits and no range filters, so it contains all data
2026
+ * for its subtree. Child subscriptions can use the ancestor's data instead
2027
+ * of creating their own server subscription.
2028
+ *
2029
+ * This matches Firebase's behavior where child listeners don't need their own
2030
+ * server subscription if an ancestor has an unlimited listener.
2031
+ */
2032
+ hasAncestorCompleteView(path) {
2033
+ const normalized = normalizePath(path);
2034
+ const segments = normalized.split("/").filter((s) => s.length > 0);
2035
+ for (let i = segments.length - 1; i >= 0; i--) {
2036
+ const ancestorPath = i === 0 ? "/" : "/" + segments.slice(0, i).join("/");
2037
+ const views = this.getViewsAtPath(ancestorPath);
2038
+ for (const view of views) {
2039
+ if (view.hasCallbacks() && view.loadsAllData() && view.hasReceivedInitialSnapshot) {
2040
+ return true;
2041
+ }
2042
+ }
2043
+ }
2044
+ if (normalized !== "/") {
2045
+ const rootViews = this.getViewsAtPath("/");
2046
+ for (const view of rootViews) {
2047
+ if (view.hasCallbacks() && view.loadsAllData() && view.hasReceivedInitialSnapshot) {
2048
+ return true;
2049
+ }
2050
+ }
2051
+ }
2052
+ return false;
1940
2053
  }
1941
2054
  /**
1942
2055
  * Get a cached value if the path is covered by an active subscription.
@@ -1959,7 +2072,7 @@ var SubscriptionManager = class {
1959
2072
  const ancestorPath = i === 0 ? "/" : "/" + segments.slice(0, i).join("/");
1960
2073
  const ancestorViews = this.getViewsAtPath(ancestorPath);
1961
2074
  for (const view of ancestorViews) {
1962
- if (view.hasCallbacksForType("value")) {
2075
+ if (view.hasCallbacks() && (view.hasReceivedInitialSnapshot || view.hasPendingWrites())) {
1963
2076
  const result = view.getCacheValue(normalized);
1964
2077
  if (result.found) {
1965
2078
  return result;
@@ -1970,7 +2083,7 @@ var SubscriptionManager = class {
1970
2083
  if (normalized !== "/") {
1971
2084
  const rootViews = this.getViewsAtPath("/");
1972
2085
  for (const view of rootViews) {
1973
- if (view.hasCallbacksForType("value")) {
2086
+ if (view.hasCallbacks() && (view.hasReceivedInitialSnapshot || view.hasPendingWrites())) {
1974
2087
  const result = view.getCacheValue(normalized);
1975
2088
  if (result.found) {
1976
2089
  return result;
@@ -2079,8 +2192,11 @@ var SubscriptionManager = class {
2079
2192
  const previousChildSet = new Set(previousOrder);
2080
2193
  const isFirstSnapshot = !view.hasReceivedInitialSnapshot && !view.hasPendingWrites();
2081
2194
  let previousCacheJson = null;
2195
+ let previousDisplayCache = null;
2082
2196
  if (!isVolatile) {
2083
- previousCacheJson = this.serializeCacheForComparison(view.getDisplayCache());
2197
+ const cache = view.getDisplayCache();
2198
+ previousDisplayCache = cache && typeof cache === "object" && !Array.isArray(cache) ? cache : null;
2199
+ previousCacheJson = this.serializeCacheForComparison(cache);
2084
2200
  }
2085
2201
  const affectedChildren = /* @__PURE__ */ new Set();
2086
2202
  let isFullSnapshot = false;
@@ -2138,7 +2254,8 @@ var SubscriptionManager = class {
2138
2254
  }
2139
2255
  for (const key of previousOrder) {
2140
2256
  if (!currentChildSet.has(key)) {
2141
- this.fireChildRemoved(view, key, childRemovedSubs, isVolatile, serverTimestamp);
2257
+ const prevValue = previousDisplayCache?.[key];
2258
+ this.fireChildRemoved(view, key, childRemovedSubs, isVolatile, serverTimestamp, prevValue);
2142
2259
  }
2143
2260
  }
2144
2261
  } else {
@@ -2149,7 +2266,8 @@ var SubscriptionManager = class {
2149
2266
  const prevKey = view.getPreviousChildKey(childKey);
2150
2267
  this.fireChildAdded(view, childKey, childAddedSubs, prevKey, isVolatile, serverTimestamp);
2151
2268
  } else if (wasPresent && !isPresent) {
2152
- this.fireChildRemoved(view, childKey, childRemovedSubs, isVolatile, serverTimestamp);
2269
+ const prevValue = previousDisplayCache?.[childKey];
2270
+ this.fireChildRemoved(view, childKey, childRemovedSubs, isVolatile, serverTimestamp, prevValue);
2153
2271
  } else if (wasPresent && isPresent) {
2154
2272
  const prevKey = view.getPreviousChildKey(childKey);
2155
2273
  this.fireChildChanged(view, childKey, childChangedSubs, prevKey, isVolatile, serverTimestamp);
@@ -2160,6 +2278,8 @@ var SubscriptionManager = class {
2160
2278
  previousOrder.forEach((key, idx) => previousPositions.set(key, idx));
2161
2279
  const currentPositions = /* @__PURE__ */ new Map();
2162
2280
  currentOrder.forEach((key, idx) => currentPositions.set(key, idx));
2281
+ const currentCache = view.getDisplayCache();
2282
+ const currentDisplayCache = currentCache && typeof currentCache === "object" && !Array.isArray(currentCache) ? currentCache : null;
2163
2283
  this.detectAndFireMoves(
2164
2284
  view,
2165
2285
  previousOrder,
@@ -2171,7 +2291,9 @@ var SubscriptionManager = class {
2171
2291
  childMovedSubs,
2172
2292
  isVolatile,
2173
2293
  serverTimestamp,
2174
- isFullSnapshot ? void 0 : affectedChildren
2294
+ isFullSnapshot ? void 0 : affectedChildren,
2295
+ previousDisplayCache,
2296
+ currentDisplayCache
2175
2297
  );
2176
2298
  }
2177
2299
  // ============================================
@@ -2190,8 +2312,12 @@ var SubscriptionManager = class {
2190
2312
  views.push(view);
2191
2313
  } else if (normalized.startsWith(viewPath + "/")) {
2192
2314
  views.push(view);
2315
+ } else if (viewPath.startsWith(normalized + "/")) {
2316
+ views.push(view);
2193
2317
  } else if (viewPath === "/") {
2194
2318
  views.push(view);
2319
+ } else if (normalized === "/") {
2320
+ views.push(view);
2195
2321
  }
2196
2322
  }
2197
2323
  return views;
@@ -2255,7 +2381,48 @@ var SubscriptionManager = class {
2255
2381
  }
2256
2382
  }
2257
2383
  }
2258
- this.fireChildEvents(view, previousOrder, previousChildSet, currentOrder, currentChildSet, "/", false, void 0, previousDisplayCache);
2384
+ this.fireChildEventsForAck(view, previousOrder, previousChildSet, currentOrder, currentChildSet, previousDisplayCache);
2385
+ }
2386
+ }
2387
+ /**
2388
+ * Fire child events for ACK handling, skipping child_moved.
2389
+ * This is a variant of fireChildEvents that doesn't fire moves because:
2390
+ * 1. Moves were already fired optimistically
2391
+ * 2. If server modifies data, PUT event will fire correct moves
2392
+ * 3. ACK can arrive before PUT, causing incorrect intermediate state
2393
+ */
2394
+ fireChildEventsForAck(view, previousOrder, previousChildSet, currentOrder, currentChildSet, previousDisplayCache) {
2395
+ const childAddedSubs = view.getCallbacks("child_added");
2396
+ const childChangedSubs = view.getCallbacks("child_changed");
2397
+ const childRemovedSubs = view.getCallbacks("child_removed");
2398
+ if (childAddedSubs.length === 0 && childChangedSubs.length === 0 && childRemovedSubs.length === 0) {
2399
+ return;
2400
+ }
2401
+ const displayCache = view.getDisplayCache();
2402
+ for (const key of currentOrder) {
2403
+ if (!previousChildSet.has(key)) {
2404
+ if (childAddedSubs.length > 0 && displayCache) {
2405
+ const prevKey = view.getPreviousChildKey(key);
2406
+ this.fireChildAdded(view, key, childAddedSubs, prevKey, false, void 0);
2407
+ }
2408
+ } else if (previousDisplayCache && childChangedSubs.length > 0 && displayCache) {
2409
+ const prevValue = previousDisplayCache[key];
2410
+ const currentValue = displayCache[key];
2411
+ const prevJson = this.serializeCacheForComparison(prevValue);
2412
+ const currJson = this.serializeCacheForComparison(currentValue);
2413
+ if (prevJson !== currJson) {
2414
+ const prevKey = view.getPreviousChildKey(key);
2415
+ this.fireChildChanged(view, key, childChangedSubs, prevKey, false, void 0);
2416
+ }
2417
+ }
2418
+ }
2419
+ for (const key of previousOrder) {
2420
+ if (!currentChildSet.has(key)) {
2421
+ if (childRemovedSubs.length > 0) {
2422
+ const prevValue = previousDisplayCache?.[key];
2423
+ this.fireChildRemoved(view, key, childRemovedSubs, false, void 0, prevValue);
2424
+ }
2425
+ }
2259
2426
  }
2260
2427
  }
2261
2428
  /**
@@ -2324,18 +2491,28 @@ var SubscriptionManager = class {
2324
2491
  const updatedViews = [];
2325
2492
  for (const view of affectedViews) {
2326
2493
  let relativePath;
2494
+ let effectiveValue = value;
2327
2495
  if (normalized === view.path) {
2328
2496
  relativePath = "/";
2329
2497
  } else if (view.path === "/") {
2330
2498
  relativePath = normalized;
2331
- } else {
2499
+ } else if (normalized.startsWith(view.path + "/")) {
2332
2500
  relativePath = normalized.slice(view.path.length);
2501
+ } else if (view.path.startsWith(normalized + "/")) {
2502
+ const pathDiff = view.path.slice(normalized.length);
2503
+ effectiveValue = getValueAtPath(value, pathDiff);
2504
+ if (operation === "update" && effectiveValue === void 0) {
2505
+ continue;
2506
+ }
2507
+ relativePath = "/";
2508
+ } else {
2509
+ continue;
2333
2510
  }
2334
2511
  const previousDisplayCache = view.getDisplayCache();
2335
2512
  const previousOrder = view.orderedChildren;
2336
2513
  const previousChildSet = new Set(previousOrder);
2337
2514
  const previousCacheJson = this.serializeCacheForComparison(previousDisplayCache);
2338
- view.addPendingWriteData(requestId, relativePath, value, operation);
2515
+ view.addPendingWriteData(requestId, relativePath, effectiveValue, operation);
2339
2516
  const currentOrder = view.orderedChildren;
2340
2517
  const currentChildSet = new Set(currentOrder);
2341
2518
  const currentCacheJson = this.serializeCacheForComparison(view.getDisplayCache());
@@ -2432,7 +2609,8 @@ var SubscriptionManager = class {
2432
2609
  for (const key of previousOrder) {
2433
2610
  if (!currentChildSet.has(key)) {
2434
2611
  if (childRemovedSubs.length > 0) {
2435
- const snapshot = this.createSnapshot?.(joinPath(view.path, key), null, isVolatile, serverTimestamp);
2612
+ const prevValue = previousDisplayCache?.[key];
2613
+ const snapshot = this.createSnapshot?.(joinPath(view.path, key), prevValue ?? null, isVolatile, serverTimestamp);
2436
2614
  if (snapshot) {
2437
2615
  for (const entry of childRemovedSubs) {
2438
2616
  try {
@@ -2446,6 +2624,23 @@ var SubscriptionManager = class {
2446
2624
  }
2447
2625
  }
2448
2626
  } else {
2627
+ if (childRemovedSubs.length > 0) {
2628
+ for (const key of previousOrder) {
2629
+ if (affectedChildren.has(key)) continue;
2630
+ if (currentChildSet.has(key)) continue;
2631
+ const prevValue = previousDisplayCache ? previousDisplayCache[key] : null;
2632
+ const snapshot = this.createSnapshot?.(joinPath(view.path, key), prevValue, isVolatile, serverTimestamp);
2633
+ if (snapshot) {
2634
+ for (const entry of childRemovedSubs) {
2635
+ try {
2636
+ entry.callback(snapshot, void 0);
2637
+ } catch (err) {
2638
+ console.error("Error in child_removed callback:", err);
2639
+ }
2640
+ }
2641
+ }
2642
+ }
2643
+ }
2449
2644
  for (const key of affectedChildren) {
2450
2645
  const wasPresent = previousChildSet.has(key);
2451
2646
  const isPresent = currentChildSet.has(key);
@@ -2471,7 +2666,8 @@ var SubscriptionManager = class {
2471
2666
  }
2472
2667
  } else if (wasPresent && !isPresent) {
2473
2668
  if (childRemovedSubs.length > 0) {
2474
- const snapshot = this.createSnapshot?.(joinPath(view.path, key), null, isVolatile, serverTimestamp);
2669
+ const prevValue = previousDisplayCache ? previousDisplayCache[key] : null;
2670
+ const snapshot = this.createSnapshot?.(joinPath(view.path, key), prevValue, isVolatile, serverTimestamp);
2475
2671
  if (snapshot) {
2476
2672
  for (const entry of childRemovedSubs) {
2477
2673
  try {
@@ -2504,18 +2700,24 @@ var SubscriptionManager = class {
2504
2700
  }
2505
2701
  }
2506
2702
  }
2507
- if (childRemovedSubs.length > 0) {
2508
- for (const key of previousOrder) {
2703
+ if (childAddedSubs.length > 0 && displayCache) {
2704
+ for (const key of currentOrder) {
2705
+ if (previousChildSet.has(key)) continue;
2509
2706
  if (affectedChildren.has(key)) continue;
2510
- if (currentChildSet.has(key)) continue;
2511
- const prevValue = previousDisplayCache ? previousDisplayCache[key] : null;
2512
- const snapshot = this.createSnapshot?.(joinPath(view.path, key), prevValue, isVolatile, serverTimestamp);
2707
+ const childValue = displayCache[key];
2708
+ const snapshot = this.createSnapshot?.(
2709
+ joinPath(view.path, key),
2710
+ childValue,
2711
+ isVolatile,
2712
+ serverTimestamp
2713
+ );
2513
2714
  if (snapshot) {
2514
- for (const entry of childRemovedSubs) {
2715
+ const prevKey = view.getPreviousChildKey(key);
2716
+ for (const entry of childAddedSubs) {
2515
2717
  try {
2516
- entry.callback(snapshot, void 0);
2718
+ entry.callback(snapshot, prevKey);
2517
2719
  } catch (err) {
2518
- console.error("Error in child_removed callback:", err);
2720
+ console.error("Error in child_added callback:", err);
2519
2721
  }
2520
2722
  }
2521
2723
  }
@@ -2538,7 +2740,9 @@ var SubscriptionManager = class {
2538
2740
  childMovedSubs,
2539
2741
  isVolatile,
2540
2742
  serverTimestamp,
2541
- isFullSnapshot ? void 0 : affectedChildren
2743
+ isFullSnapshot ? void 0 : affectedChildren,
2744
+ previousDisplayCache,
2745
+ displayCache
2542
2746
  );
2543
2747
  }
2544
2748
  }
@@ -2761,26 +2965,26 @@ var DatabaseReference = class _DatabaseReference {
2761
2965
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX] = this._query.orderByChildPath;
2762
2966
  }
2763
2967
  if (this._query.startAt !== void 0) {
2764
- queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_VALUE] = this._query.startAt.value;
2968
+ queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_VALUE] = this._query.startAt.value ?? null;
2765
2969
  if (this._query.startAt.key !== void 0) {
2766
2970
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_NAME] = this._query.startAt.key;
2767
2971
  }
2768
2972
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_IS_INCLUSIVE] = true;
2769
2973
  } else if (this._query.startAfter !== void 0) {
2770
- queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_VALUE] = this._query.startAfter.value;
2974
+ queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_VALUE] = this._query.startAfter.value ?? null;
2771
2975
  if (this._query.startAfter.key !== void 0) {
2772
2976
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_NAME] = this._query.startAfter.key;
2773
2977
  }
2774
2978
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_START_IS_INCLUSIVE] = false;
2775
2979
  }
2776
2980
  if (this._query.endAt !== void 0) {
2777
- queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_VALUE] = this._query.endAt.value;
2981
+ queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_VALUE] = this._query.endAt.value ?? null;
2778
2982
  if (this._query.endAt.key !== void 0) {
2779
2983
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_NAME] = this._query.endAt.key;
2780
2984
  }
2781
2985
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_IS_INCLUSIVE] = true;
2782
2986
  } else if (this._query.endBefore !== void 0) {
2783
- queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_VALUE] = this._query.endBefore.value;
2987
+ queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_VALUE] = this._query.endBefore.value ?? null;
2784
2988
  if (this._query.endBefore.key !== void 0) {
2785
2989
  queryObj[WIRE_PROTOCOL_CONSTANTS.INDEX_END_NAME] = this._query.endBefore.key;
2786
2990
  }
@@ -2939,17 +3143,39 @@ var DatabaseReference = class _DatabaseReference {
2939
3143
  }
2940
3144
  /**
2941
3145
  * Set the priority of the data at this location.
2942
- * Fetches current value and sets it with the new priority.
3146
+ * Uses cached value for optimistic behavior (local effects are immediate).
3147
+ * The optimistic update happens synchronously, Promise resolves after server ack.
2943
3148
  */
2944
- async setPriority(priority) {
3149
+ setPriority(priority) {
2945
3150
  validateNotInfoPath(this._path, "setPriority");
2946
- const snapshot = await this.once();
2947
- const currentVal = snapshot.val();
2948
- if (currentVal === null || currentVal === void 0) {
2949
- await this._db._sendSet(this._path, { ".priority": priority });
2950
- return;
3151
+ const { value: cachedValue, found } = this._db._getCachedValue(this._path);
3152
+ if (!found) {
3153
+ return this.once().then((snapshot) => {
3154
+ const actualValue2 = snapshot.val();
3155
+ if (actualValue2 === null || actualValue2 === void 0) {
3156
+ return this._db._sendSet(this._path, { ".priority": priority });
3157
+ }
3158
+ return this.setWithPriority(actualValue2, priority);
3159
+ });
2951
3160
  }
2952
- await this.setWithPriority(currentVal, priority);
3161
+ let actualValue;
3162
+ if (cachedValue === null || cachedValue === void 0) {
3163
+ actualValue = null;
3164
+ } else if (typeof cachedValue === "object" && !Array.isArray(cachedValue)) {
3165
+ const obj = cachedValue;
3166
+ if (".value" in obj && Object.keys(obj).every((k) => k === ".value" || k === ".priority")) {
3167
+ actualValue = obj[".value"];
3168
+ } else {
3169
+ const { ".priority": _oldPriority, ...rest } = obj;
3170
+ actualValue = Object.keys(rest).length > 0 ? rest : null;
3171
+ }
3172
+ } else {
3173
+ actualValue = cachedValue;
3174
+ }
3175
+ if (actualValue === null || actualValue === void 0) {
3176
+ return this._db._sendSet(this._path, { ".priority": priority });
3177
+ }
3178
+ return this.setWithPriority(actualValue, priority);
2953
3179
  }
2954
3180
  /**
2955
3181
  * Atomically modify the data at this location using optimistic concurrency.
@@ -3020,14 +3246,23 @@ var DatabaseReference = class _DatabaseReference {
3020
3246
  /**
3021
3247
  * Read the data at this location once.
3022
3248
  *
3023
- * @param eventType - The event type (only 'value' is supported)
3249
+ * For 'value' events, this fetches data directly from the server.
3250
+ * For child events ('child_added', 'child_changed', 'child_removed', 'child_moved'),
3251
+ * this subscribes, waits for the first event, then unsubscribes.
3252
+ *
3253
+ * @param eventType - The event type
3024
3254
  * @returns Promise that resolves to the DataSnapshot
3025
3255
  */
3026
3256
  once(eventType = "value") {
3027
- if (eventType !== "value") {
3028
- throw new Error('once() only supports "value" event type');
3257
+ if (eventType === "value") {
3258
+ return this._db._sendOnce(this._path, this._buildQueryParams());
3029
3259
  }
3030
- return this._db._sendOnce(this._path, this._buildQueryParams());
3260
+ return new Promise((resolve) => {
3261
+ const unsubscribe = this.on(eventType, (snapshot) => {
3262
+ unsubscribe();
3263
+ resolve(snapshot);
3264
+ });
3265
+ });
3031
3266
  }
3032
3267
  // ============================================
3033
3268
  // Subscriptions
@@ -3096,6 +3331,12 @@ var DatabaseReference = class _DatabaseReference {
3096
3331
  */
3097
3332
  orderByChild(path) {
3098
3333
  this._validateNoOrderBy("orderByChild");
3334
+ if (path.startsWith("$") || path.includes("/$")) {
3335
+ throw new LarkError(
3336
+ ErrorCode.INVALID_PATH,
3337
+ `orderByChild: Invalid path '${path}'. Paths cannot contain '$' prefix (reserved for internal use)`
3338
+ );
3339
+ }
3099
3340
  return new _DatabaseReference(this._db, this._path, {
3100
3341
  ...this._query,
3101
3342
  orderBy: "child",
@@ -3469,35 +3710,35 @@ var DatabaseReference = class _DatabaseReference {
3469
3710
  hasParams = true;
3470
3711
  }
3471
3712
  if (this._query.startAt !== void 0) {
3472
- params.startAt = this._query.startAt.value;
3713
+ params.startAt = this._query.startAt.value ?? null;
3473
3714
  if (this._query.startAt.key !== void 0) {
3474
3715
  params.startAtKey = this._query.startAt.key;
3475
3716
  }
3476
3717
  hasParams = true;
3477
3718
  }
3478
3719
  if (this._query.startAfter !== void 0) {
3479
- params.startAfter = this._query.startAfter.value;
3720
+ params.startAfter = this._query.startAfter.value ?? null;
3480
3721
  if (this._query.startAfter.key !== void 0) {
3481
3722
  params.startAfterKey = this._query.startAfter.key;
3482
3723
  }
3483
3724
  hasParams = true;
3484
3725
  }
3485
3726
  if (this._query.endAt !== void 0) {
3486
- params.endAt = this._query.endAt.value;
3727
+ params.endAt = this._query.endAt.value ?? null;
3487
3728
  if (this._query.endAt.key !== void 0) {
3488
3729
  params.endAtKey = this._query.endAt.key;
3489
3730
  }
3490
3731
  hasParams = true;
3491
3732
  }
3492
3733
  if (this._query.endBefore !== void 0) {
3493
- params.endBefore = this._query.endBefore.value;
3734
+ params.endBefore = this._query.endBefore.value ?? null;
3494
3735
  if (this._query.endBefore.key !== void 0) {
3495
3736
  params.endBeforeKey = this._query.endBefore.key;
3496
3737
  }
3497
3738
  hasParams = true;
3498
3739
  }
3499
3740
  if (this._query.equalTo !== void 0) {
3500
- params.equalTo = this._query.equalTo.value;
3741
+ params.equalTo = this._query.equalTo.value ?? null;
3501
3742
  if (this._query.equalTo.key !== void 0) {
3502
3743
  params.equalToKey = this._query.equalTo.key;
3503
3744
  }
@@ -3516,6 +3757,13 @@ var DatabaseReference = class _DatabaseReference {
3516
3757
  }
3517
3758
  return `${baseUrl}${this._path}`;
3518
3759
  }
3760
+ /**
3761
+ * Returns the URL for JSON serialization.
3762
+ * This allows refs to be serialized with JSON.stringify().
3763
+ */
3764
+ toJSON() {
3765
+ return this.toString();
3766
+ }
3519
3767
  };
3520
3768
  var ThenableReference = class extends DatabaseReference {
3521
3769
  constructor(db, path, promise) {
@@ -3536,11 +3784,17 @@ function isWrappedPrimitive(data) {
3536
3784
  return false;
3537
3785
  }
3538
3786
  const keys = Object.keys(data);
3539
- return keys.length === 2 && ".value" in data && ".priority" in data;
3787
+ if (keys.length === 2 && ".value" in data && ".priority" in data) {
3788
+ return true;
3789
+ }
3790
+ if (keys.length === 1 && ".value" in data) {
3791
+ return true;
3792
+ }
3793
+ return false;
3540
3794
  }
3541
3795
  function stripPriorityMetadata(data) {
3542
3796
  if (data === null || data === void 0) {
3543
- return data;
3797
+ return null;
3544
3798
  }
3545
3799
  if (typeof data !== "object") {
3546
3800
  return data;
@@ -3603,9 +3857,19 @@ var DataSnapshot = class _DataSnapshot {
3603
3857
  }
3604
3858
  /**
3605
3859
  * Check if data exists at this location (is not null/undefined).
3860
+ * Returns false for priority-only nodes (only .priority, no actual value).
3606
3861
  */
3607
3862
  exists() {
3608
- return this._data !== null && this._data !== void 0;
3863
+ if (this._data === null || this._data === void 0) {
3864
+ return false;
3865
+ }
3866
+ if (typeof this._data === "object" && !Array.isArray(this._data)) {
3867
+ const keys = Object.keys(this._data);
3868
+ if (keys.length === 1 && keys[0] === ".priority") {
3869
+ return false;
3870
+ }
3871
+ }
3872
+ return true;
3609
3873
  }
3610
3874
  /**
3611
3875
  * Get a child snapshot at the specified path.
@@ -3752,24 +4016,6 @@ var DataSnapshot = class _DataSnapshot {
3752
4016
  }
3753
4017
  };
3754
4018
 
3755
- // src/utils/jwt.ts
3756
- function decodeJwtPayload(token) {
3757
- const parts = token.split(".");
3758
- if (parts.length !== 3) {
3759
- throw new Error("Invalid JWT format");
3760
- }
3761
- const payload = parts[1];
3762
- const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3763
- const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
3764
- let decoded;
3765
- if (typeof atob === "function") {
3766
- decoded = atob(padded);
3767
- } else {
3768
- decoded = Buffer.from(padded, "base64").toString("utf-8");
3769
- }
3770
- return JSON.parse(decoded);
3771
- }
3772
-
3773
4019
  // src/utils/volatile.ts
3774
4020
  function isVolatilePath(path, patterns) {
3775
4021
  if (!patterns || patterns.length === 0) {
@@ -3892,6 +4138,11 @@ var LarkDatabase = class {
3892
4138
  this._coordinatorUrl = null;
3893
4139
  this._volatilePaths = [];
3894
4140
  this._transportType = null;
4141
+ // Auth state
4142
+ this._currentToken = null;
4143
+ // Token for auth (empty string = anonymous)
4144
+ this._isAnonymous = false;
4145
+ // True if connected anonymously
3895
4146
  // Reconnection state
3896
4147
  this._connectionId = null;
3897
4148
  this._connectOptions = null;
@@ -3904,8 +4155,12 @@ var LarkDatabase = class {
3904
4155
  this.disconnectCallbacks = /* @__PURE__ */ new Set();
3905
4156
  this.errorCallbacks = /* @__PURE__ */ new Set();
3906
4157
  this.reconnectingCallbacks = /* @__PURE__ */ new Set();
4158
+ this.authStateChangedCallbacks = /* @__PURE__ */ new Set();
3907
4159
  // .info path subscriptions (handled locally, not sent to server)
3908
4160
  this.infoSubscriptions = [];
4161
+ // Authentication promise - resolves when auth completes, allows operations to queue
4162
+ this.authenticationPromise = null;
4163
+ this.authenticationResolve = null;
3909
4164
  this._serverTimeOffset = 0;
3910
4165
  this.messageQueue = new MessageQueue();
3911
4166
  this.subscriptionManager = new SubscriptionManager();
@@ -3915,10 +4170,11 @@ var LarkDatabase = class {
3915
4170
  // Connection State
3916
4171
  // ============================================
3917
4172
  /**
3918
- * Whether the database is currently connected.
4173
+ * Whether the database is fully connected and authenticated.
4174
+ * Returns true when ready to perform database operations.
3919
4175
  */
3920
4176
  get connected() {
3921
- return this._state === "connected";
4177
+ return this._state === "authenticated";
3922
4178
  }
3923
4179
  /**
3924
4180
  * Whether the database is currently attempting to reconnect.
@@ -4003,16 +4259,27 @@ var LarkDatabase = class {
4003
4259
  }
4004
4260
  this._connectOptions = options;
4005
4261
  this._intentionalDisconnect = false;
4262
+ this.authenticationPromise = new Promise((resolve) => {
4263
+ this.authenticationResolve = resolve;
4264
+ });
4006
4265
  await this.performConnect(databaseId, options);
4007
4266
  }
4008
4267
  /**
4009
4268
  * Internal connect implementation used by both initial connect and reconnect.
4269
+ * Implements the Join → Auth flow:
4270
+ * 1. Connect WebSocket
4271
+ * 2. Send join (identifies database)
4272
+ * 3. Send auth (authenticates user - required even for anonymous)
4010
4273
  */
4011
4274
  async performConnect(databaseId, options, isReconnect = false) {
4012
4275
  const previousState = this._state;
4013
4276
  this._state = isReconnect ? "reconnecting" : "connecting";
4014
4277
  this._databaseId = databaseId;
4015
4278
  this._coordinatorUrl = options.coordinator || DEFAULT_COORDINATOR_URL;
4279
+ if (!isReconnect) {
4280
+ this._currentToken = options.token || "";
4281
+ this._isAnonymous = !options.token && options.anonymous !== false;
4282
+ }
4016
4283
  try {
4017
4284
  const coordinatorUrl = this._coordinatorUrl;
4018
4285
  const coordinator = new Coordinator(coordinatorUrl);
@@ -4038,30 +4305,44 @@ var LarkDatabase = class {
4038
4305
  );
4039
4306
  this.transport = transportResult.transport;
4040
4307
  this._transportType = transportResult.type;
4041
- const requestId = this.messageQueue.nextRequestId();
4308
+ this._state = "connected";
4309
+ const joinRequestId = this.messageQueue.nextRequestId();
4042
4310
  const joinMessage = {
4043
4311
  o: "j",
4044
- t: connectResponse.token,
4045
- r: requestId
4312
+ d: databaseId,
4313
+ r: joinRequestId
4046
4314
  };
4047
4315
  if (this._connectionId) {
4048
4316
  joinMessage.pcid = this._connectionId;
4049
4317
  }
4050
4318
  this.send(joinMessage);
4051
- const joinResponse = await this.messageQueue.registerRequest(requestId);
4319
+ const joinResponse = await this.messageQueue.registerRequest(joinRequestId);
4052
4320
  this._volatilePaths = joinResponse.volatilePaths;
4053
4321
  this._connectionId = joinResponse.connectionId;
4054
4322
  if (joinResponse.serverTime != null) {
4055
4323
  this._serverTimeOffset = joinResponse.serverTime - Date.now();
4056
4324
  }
4057
- const jwtPayload = decodeJwtPayload(connectResponse.token);
4325
+ this._state = "joined";
4326
+ const authRequestId = this.messageQueue.nextRequestId();
4327
+ const authMessage = {
4328
+ o: "au",
4329
+ t: this._currentToken ?? "",
4330
+ r: authRequestId
4331
+ };
4332
+ this.send(authMessage);
4333
+ const authResponse = await this.messageQueue.registerRequest(authRequestId);
4058
4334
  this._auth = {
4059
- uid: jwtPayload.sub,
4060
- provider: jwtPayload.provider,
4061
- token: jwtPayload.claims || {}
4335
+ uid: authResponse.uid || "",
4336
+ provider: this._isAnonymous ? "anonymous" : "custom",
4337
+ token: {}
4338
+ // Token claims would need to be decoded from the token if needed
4062
4339
  };
4063
- this._state = "connected";
4340
+ this._state = "authenticated";
4064
4341
  this._reconnectAttempt = 0;
4342
+ if (this.authenticationResolve) {
4343
+ this.authenticationResolve();
4344
+ this.authenticationResolve = null;
4345
+ }
4065
4346
  this.fireConnectionStateChange();
4066
4347
  if (!isReconnect) {
4067
4348
  this.subscriptionManager.initialize({
@@ -4074,6 +4355,7 @@ var LarkDatabase = class {
4074
4355
  await this.restoreAfterReconnect();
4075
4356
  }
4076
4357
  this.connectCallbacks.forEach((cb) => cb());
4358
+ this.authStateChangedCallbacks.forEach((cb) => cb(this._auth));
4077
4359
  } catch (error) {
4078
4360
  if (isReconnect) {
4079
4361
  this._state = "reconnecting";
@@ -4086,6 +4368,8 @@ var LarkDatabase = class {
4086
4368
  this._connectOptions = null;
4087
4369
  this._connectionId = null;
4088
4370
  this._transportType = null;
4371
+ this._currentToken = null;
4372
+ this._isAnonymous = false;
4089
4373
  this.transport?.close();
4090
4374
  this.transport = null;
4091
4375
  throw error;
@@ -4099,13 +4383,14 @@ var LarkDatabase = class {
4099
4383
  if (this._state === "disconnected") {
4100
4384
  return;
4101
4385
  }
4102
- const wasConnected = this._state === "connected";
4386
+ const wasAuthenticated = this._state === "authenticated";
4387
+ const wasPartiallyConnected = this._state === "connected" || this._state === "joined";
4103
4388
  this._intentionalDisconnect = true;
4104
4389
  if (this._reconnectTimer) {
4105
4390
  clearTimeout(this._reconnectTimer);
4106
4391
  this._reconnectTimer = null;
4107
4392
  }
4108
- if (wasConnected && this.transport) {
4393
+ if ((wasAuthenticated || wasPartiallyConnected) && this.transport) {
4109
4394
  try {
4110
4395
  const requestId = this.messageQueue.nextRequestId();
4111
4396
  this.send({ o: "l", r: requestId });
@@ -4117,7 +4402,7 @@ var LarkDatabase = class {
4117
4402
  }
4118
4403
  }
4119
4404
  this.cleanupFull();
4120
- if (wasConnected) {
4405
+ if (wasAuthenticated || wasPartiallyConnected) {
4121
4406
  this.disconnectCallbacks.forEach((cb) => cb());
4122
4407
  }
4123
4408
  }
@@ -4126,7 +4411,7 @@ var LarkDatabase = class {
4126
4411
  * Disconnects from the server but preserves subscriptions for later reconnection via goOnline().
4127
4412
  */
4128
4413
  goOffline() {
4129
- if (this._state === "connected" || this._state === "reconnecting") {
4414
+ if (this._state === "authenticated" || this._state === "joined" || this._state === "connected" || this._state === "reconnecting") {
4130
4415
  this._intentionalDisconnect = true;
4131
4416
  if (this._reconnectTimer) {
4132
4417
  clearTimeout(this._reconnectTimer);
@@ -4157,7 +4442,7 @@ var LarkDatabase = class {
4157
4442
  * Used for intentional disconnect.
4158
4443
  */
4159
4444
  cleanupFull() {
4160
- const wasConnected = this._state === "connected";
4445
+ const wasAuthenticated = this._state === "authenticated";
4161
4446
  this.transport?.close();
4162
4447
  this.transport = null;
4163
4448
  this._state = "disconnected";
@@ -4168,11 +4453,15 @@ var LarkDatabase = class {
4168
4453
  this._connectionId = null;
4169
4454
  this._connectOptions = null;
4170
4455
  this._transportType = null;
4456
+ this._currentToken = null;
4457
+ this._isAnonymous = false;
4171
4458
  this._reconnectAttempt = 0;
4459
+ this.authenticationPromise = null;
4460
+ this.authenticationResolve = null;
4172
4461
  this.subscriptionManager.clear();
4173
4462
  this.messageQueue.rejectAll(new Error("Connection closed"));
4174
4463
  this.pendingWrites.clear();
4175
- if (wasConnected) {
4464
+ if (wasAuthenticated) {
4176
4465
  this.fireConnectionStateChange();
4177
4466
  }
4178
4467
  this.infoSubscriptions = [];
@@ -4205,7 +4494,7 @@ var LarkDatabase = class {
4205
4494
  getInfoValue(path) {
4206
4495
  const normalizedPath = normalizePath(path) || "/";
4207
4496
  if (normalizedPath === "/.info/connected") {
4208
- return this._state === "connected";
4497
+ return this._state === "authenticated";
4209
4498
  }
4210
4499
  if (normalizedPath === "/.info/serverTimeOffset") {
4211
4500
  return this._serverTimeOffset;
@@ -4272,6 +4561,9 @@ var LarkDatabase = class {
4272
4561
  if (this._intentionalDisconnect || !this._databaseId || !this._connectOptions) {
4273
4562
  return;
4274
4563
  }
4564
+ this.authenticationPromise = new Promise((resolve) => {
4565
+ this.authenticationResolve = resolve;
4566
+ });
4275
4567
  try {
4276
4568
  await this.performConnect(this._databaseId, this._connectOptions, true);
4277
4569
  } catch {
@@ -4430,6 +4722,9 @@ var LarkDatabase = class {
4430
4722
  * @internal Send a transaction to the server.
4431
4723
  */
4432
4724
  async _sendTransaction(ops) {
4725
+ if (!this.isAuthenticatedOrThrow()) {
4726
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4727
+ }
4433
4728
  const requestId = this.messageQueue.nextRequestId();
4434
4729
  this.pendingWrites.trackWrite(requestId, "transaction", "/", ops);
4435
4730
  const message = {
@@ -4476,6 +4771,76 @@ var LarkDatabase = class {
4476
4771
  this.reconnectingCallbacks.add(callback);
4477
4772
  return () => this.reconnectingCallbacks.delete(callback);
4478
4773
  }
4774
+ /**
4775
+ * Register a callback for auth state changes.
4776
+ * Fires when user signs in, signs out, or auth changes.
4777
+ * Returns an unsubscribe function.
4778
+ */
4779
+ onAuthStateChanged(callback) {
4780
+ this.authStateChangedCallbacks.add(callback);
4781
+ return () => this.authStateChangedCallbacks.delete(callback);
4782
+ }
4783
+ // ============================================
4784
+ // Authentication Management
4785
+ // ============================================
4786
+ /**
4787
+ * Sign in with a new auth token while connected.
4788
+ * Changes the authenticated user without disconnecting.
4789
+ *
4790
+ * Note: Some subscriptions may be revoked if the new user doesn't have
4791
+ * permission. Listen for 'permission_denied' errors on your subscriptions.
4792
+ *
4793
+ * @param token - The auth token for the new user
4794
+ * @throws Error if not connected (must call connect() first)
4795
+ */
4796
+ async signIn(token) {
4797
+ if (this._state !== "authenticated" && this._state !== "joined") {
4798
+ throw new LarkError("not_connected", "Must be connected first - call connect()");
4799
+ }
4800
+ const authRequestId = this.messageQueue.nextRequestId();
4801
+ const authMessage = {
4802
+ o: "au",
4803
+ t: token,
4804
+ r: authRequestId
4805
+ };
4806
+ this.send(authMessage);
4807
+ const authResponse = await this.messageQueue.registerRequest(authRequestId);
4808
+ this._currentToken = token;
4809
+ this._isAnonymous = false;
4810
+ this._auth = {
4811
+ uid: authResponse.uid || "",
4812
+ provider: "custom",
4813
+ token: {}
4814
+ };
4815
+ this.authStateChangedCallbacks.forEach((cb) => cb(this._auth));
4816
+ }
4817
+ /**
4818
+ * Sign out the current user.
4819
+ * Reverts to anonymous authentication.
4820
+ *
4821
+ * Note: Some subscriptions may be revoked if anonymous users don't have
4822
+ * permission. Listen for 'permission_denied' errors on your subscriptions.
4823
+ */
4824
+ async signOut() {
4825
+ if (this._state !== "authenticated") {
4826
+ return;
4827
+ }
4828
+ const unauthRequestId = this.messageQueue.nextRequestId();
4829
+ const unauthMessage = {
4830
+ o: "ua",
4831
+ r: unauthRequestId
4832
+ };
4833
+ this.send(unauthMessage);
4834
+ const authResponse = await this.messageQueue.registerRequest(unauthRequestId);
4835
+ this._currentToken = "";
4836
+ this._isAnonymous = true;
4837
+ this._auth = {
4838
+ uid: authResponse.uid || "",
4839
+ provider: "anonymous",
4840
+ token: {}
4841
+ };
4842
+ this.authStateChangedCallbacks.forEach((cb) => cb(this._auth));
4843
+ }
4479
4844
  // ============================================
4480
4845
  // Internal: Message Handling
4481
4846
  // ============================================
@@ -4487,6 +4852,9 @@ var LarkDatabase = class {
4487
4852
  console.error("Failed to parse message:", data);
4488
4853
  return;
4489
4854
  }
4855
+ if (process.env.LARK_DEBUG) {
4856
+ console.log("[LARK] <<< SERVER:", JSON.stringify(message, null, 2));
4857
+ }
4490
4858
  if (isPingMessage(message)) {
4491
4859
  this.transport?.send(JSON.stringify({ o: "po" }));
4492
4860
  return;
@@ -4496,6 +4864,12 @@ var LarkDatabase = class {
4496
4864
  this.subscriptionManager.clearPendingWrite(message.a);
4497
4865
  } else if (isNackMessage(message)) {
4498
4866
  this.pendingWrites.onNack(message.n);
4867
+ if (message.e === "permission_denied" && message.sp) {
4868
+ const path = message.sp;
4869
+ console.warn(`Subscription revoked at ${path}: permission_denied`);
4870
+ this.subscriptionManager.handleSubscriptionRevoked(path);
4871
+ return;
4872
+ }
4499
4873
  if (message.e !== "condition_failed") {
4500
4874
  console.error(`Write failed (${message.e}): ${message.m || message.e}`);
4501
4875
  }
@@ -4512,27 +4886,28 @@ var LarkDatabase = class {
4512
4886
  if (this._state === "disconnected") {
4513
4887
  return;
4514
4888
  }
4515
- const wasConnected = this._state === "connected";
4889
+ const wasAuthenticated = this._state === "authenticated";
4516
4890
  const wasReconnecting = this._state === "reconnecting";
4891
+ const wasPartiallyConnected = this._state === "connected" || this._state === "joined";
4517
4892
  if (this._intentionalDisconnect) {
4518
4893
  this.cleanupFull();
4519
- if (wasConnected) {
4894
+ if (wasAuthenticated || wasPartiallyConnected) {
4520
4895
  this.disconnectCallbacks.forEach((cb) => cb());
4521
4896
  }
4522
4897
  return;
4523
4898
  }
4524
4899
  const canReconnect = this._databaseId && this._connectOptions;
4525
- if ((wasConnected || wasReconnecting) && canReconnect) {
4900
+ if ((wasAuthenticated || wasPartiallyConnected || wasReconnecting) && canReconnect) {
4526
4901
  this._state = "reconnecting";
4527
4902
  this.cleanupForReconnect();
4528
4903
  this.reconnectingCallbacks.forEach((cb) => cb());
4529
- if (wasConnected) {
4904
+ if (wasAuthenticated || wasPartiallyConnected) {
4530
4905
  this.disconnectCallbacks.forEach((cb) => cb());
4531
4906
  }
4532
4907
  this.scheduleReconnect();
4533
4908
  } else {
4534
4909
  this.cleanupFull();
4535
- if (wasConnected) {
4910
+ if (wasAuthenticated || wasPartiallyConnected) {
4536
4911
  this.disconnectCallbacks.forEach((cb) => cb());
4537
4912
  }
4538
4913
  }
@@ -4543,10 +4918,48 @@ var LarkDatabase = class {
4543
4918
  // ============================================
4544
4919
  // Internal: Sending Messages
4545
4920
  // ============================================
4921
+ /**
4922
+ * Check if authenticated synchronously.
4923
+ * Returns true if authenticated, false if connecting (should wait), throws if disconnected.
4924
+ */
4925
+ isAuthenticatedOrThrow() {
4926
+ if (this._state === "authenticated") {
4927
+ return true;
4928
+ }
4929
+ if (this._state === "connecting" || this._state === "connected" || this._state === "joined" || this._state === "reconnecting") {
4930
+ return false;
4931
+ }
4932
+ throw new LarkError("not_connected", "Not connected - call connect() first");
4933
+ }
4934
+ /**
4935
+ * Wait for authentication to complete before performing an operation.
4936
+ * If already authenticated, returns immediately (synchronously).
4937
+ * If connecting/reconnecting, waits for auth to complete.
4938
+ * If disconnected and no connect in progress, throws.
4939
+ *
4940
+ * IMPORTANT: This returns a Promise only if waiting is needed.
4941
+ * Callers should use: `if (!this.isAuthenticatedOrThrow()) if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();`
4942
+ * to preserve synchronous execution when already authenticated.
4943
+ */
4944
+ async waitForAuthenticated() {
4945
+ if (this._state === "authenticated") {
4946
+ return;
4947
+ }
4948
+ if (this._state === "connecting" || this._state === "connected" || this._state === "joined" || this._state === "reconnecting") {
4949
+ if (this.authenticationPromise) {
4950
+ await this.authenticationPromise;
4951
+ return;
4952
+ }
4953
+ }
4954
+ throw new LarkError("not_connected", "Not connected - call connect() first");
4955
+ }
4546
4956
  send(message) {
4547
4957
  if (!this.transport || !this.transport.connected) {
4548
4958
  throw new LarkError("not_connected", "Not connected to database");
4549
4959
  }
4960
+ if (process.env.LARK_DEBUG) {
4961
+ console.log("[LARK] >>> CLIENT:", JSON.stringify(message, null, 2));
4962
+ }
4550
4963
  this.transport.send(JSON.stringify(message));
4551
4964
  }
4552
4965
  /**
@@ -4554,6 +4967,7 @@ var LarkDatabase = class {
4554
4967
  * Note: Priority is now part of the value (as .priority), not a separate field.
4555
4968
  */
4556
4969
  async _sendSet(path, value) {
4970
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4557
4971
  const normalizedPath = normalizePath(path) || "/";
4558
4972
  validateWriteData(value, normalizedPath);
4559
4973
  const requestId = this.messageQueue.nextRequestId();
@@ -4578,6 +4992,7 @@ var LarkDatabase = class {
4578
4992
  * @internal Send an update operation.
4579
4993
  */
4580
4994
  async _sendUpdate(path, values) {
4995
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4581
4996
  const normalizedPath = normalizePath(path) || "/";
4582
4997
  for (const [key, value] of Object.entries(values)) {
4583
4998
  const fullPath = key.startsWith("/") ? key : `${normalizedPath}/${key}`;
@@ -4609,6 +5024,7 @@ var LarkDatabase = class {
4609
5024
  * @internal Send a delete operation.
4610
5025
  */
4611
5026
  async _sendDelete(path) {
5027
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4612
5028
  const normalizedPath = normalizePath(path) || "/";
4613
5029
  const requestId = this.messageQueue.nextRequestId();
4614
5030
  const pendingWriteIds = this.subscriptionManager.getPendingWriteIdsForPath(normalizedPath);
@@ -4644,7 +5060,7 @@ var LarkDatabase = class {
4644
5060
  _sendVolatileSet(path, value) {
4645
5061
  const normalizedPath = normalizePath(path) || "/";
4646
5062
  this.subscriptionManager.applyOptimisticWrite(normalizedPath, value, "", "set");
4647
- if (!this.transport || !this.transport.connected) {
5063
+ if (this._state !== "authenticated" || !this.transport || !this.transport.connected) {
4648
5064
  return;
4649
5065
  }
4650
5066
  const message = {
@@ -4660,7 +5076,7 @@ var LarkDatabase = class {
4660
5076
  _sendVolatileUpdate(path, values) {
4661
5077
  const normalizedPath = normalizePath(path) || "/";
4662
5078
  this.subscriptionManager.applyOptimisticWrite(normalizedPath, values, "", "update");
4663
- if (!this.transport || !this.transport.connected) {
5079
+ if (this._state !== "authenticated" || !this.transport || !this.transport.connected) {
4664
5080
  return;
4665
5081
  }
4666
5082
  const message = {
@@ -4676,7 +5092,7 @@ var LarkDatabase = class {
4676
5092
  _sendVolatileDelete(path) {
4677
5093
  const normalizedPath = normalizePath(path) || "/";
4678
5094
  this.subscriptionManager.applyOptimisticWrite(normalizedPath, null, "", "delete");
4679
- if (!this.transport || !this.transport.connected) {
5095
+ if (this._state !== "authenticated" || !this.transport || !this.transport.connected) {
4680
5096
  return;
4681
5097
  }
4682
5098
  const message = {
@@ -4715,6 +5131,7 @@ var LarkDatabase = class {
4715
5131
  return new DataSnapshot(cached.value, path, this);
4716
5132
  }
4717
5133
  }
5134
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4718
5135
  const requestId = this.messageQueue.nextRequestId();
4719
5136
  const message = {
4720
5137
  o: "o",
@@ -4731,6 +5148,7 @@ var LarkDatabase = class {
4731
5148
  * @internal Send an onDisconnect operation.
4732
5149
  */
4733
5150
  async _sendOnDisconnect(path, action, value) {
5151
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4734
5152
  const requestId = this.messageQueue.nextRequestId();
4735
5153
  const message = {
4736
5154
  o: "od",
@@ -4744,11 +5162,20 @@ var LarkDatabase = class {
4744
5162
  this.send(message);
4745
5163
  await this.messageQueue.registerRequest(requestId);
4746
5164
  }
5165
+ /**
5166
+ * @internal Get a cached value from the subscription manager.
5167
+ * Used for optimistic writes where we need the current value without a network fetch.
5168
+ */
5169
+ _getCachedValue(path) {
5170
+ const normalizedPath = normalizePath(path) || "/";
5171
+ return this.subscriptionManager.getCachedValue(normalizedPath);
5172
+ }
4747
5173
  /**
4748
5174
  * @internal Send a subscribe message to server.
4749
5175
  * Includes tag for non-default queries to enable proper event routing.
4750
5176
  */
4751
5177
  async sendSubscribeMessage(path, eventTypes, queryParams, tag) {
5178
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4752
5179
  const requestId = this.messageQueue.nextRequestId();
4753
5180
  const message = {
4754
5181
  o: "sb",
@@ -4766,6 +5193,7 @@ var LarkDatabase = class {
4766
5193
  * Includes query params and tag so server can identify which specific subscription to remove.
4767
5194
  */
4768
5195
  async sendUnsubscribeMessage(path, queryParams, tag) {
5196
+ if (!this.isAuthenticatedOrThrow()) await this.waitForAuthenticated();
4769
5197
  const requestId = this.messageQueue.nextRequestId();
4770
5198
  const message = {
4771
5199
  o: "us",