@rebasepro/client 0.19.1 → 0.19.2-canary.g09316f6

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.es.js CHANGED
@@ -1,5 +1,5 @@
1
- import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, SCHEMA_VERSION_HEADER, Vector, isPublicStoragePath, isUnsupported, parseRelationAggregateSort, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
2
- import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, normalizeOrderBy, or, paginateFind, resolveFindWindow, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
1
+ import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, RebaseClientError as RebaseClientError$1, SCHEMA_VERSION_HEADER, Vector, hasFieldOperation, isPublicStoragePath, isUnsupported, parseRelationAggregateSort, sortKeyToString, toCanonicalOp, unsupportedMethod } from "@rebasepro/types";
2
+ import { COMPOSITE_ID_SEPARATOR, QueryBuilder, RebasePaginationError, and, buildCompositeId, collectAllPages, cond, mergeIncludeSpecs, normalizeOrderBy, not, or, paginateFind, resolveFindWindow, serializeFilter, serializeInclude, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
5
5
  function rebaseReviver(_key, value) {
@@ -93,6 +93,7 @@ function buildQueryString(params) {
93
93
  if (params.limit != null) parts.push(`limit=${params.limit}`);
94
94
  if (params.offset != null) parts.push(`offset=${params.offset}`);
95
95
  if (params.page != null) parts.push(`page=${params.page}`);
96
+ if (params.after) parts.push(`after=${encodeURIComponent(params.after)}`);
96
97
  if (params.orderBy) {
97
98
  const wire = serializeOrderBy(params.orderBy);
98
99
  if (wire) parts.push(`orderBy=${encodeURIComponent(wire)}`);
@@ -108,7 +109,10 @@ function buildQueryString(params) {
108
109
  if (vs.distance) parts.push(`vector_distance=${encodeURIComponent(vs.distance)}`);
109
110
  if (vs.threshold !== void 0) parts.push(`vector_threshold=${encodeURIComponent(String(vs.threshold))}`);
110
111
  }
111
- if (params.include && params.include.length > 0) parts.push(`include=${encodeURIComponent(params.include.join(","))}`);
112
+ const include = serializeInclude(params.include);
113
+ if (include) parts.push(`include=${encodeURIComponent(include)}`);
114
+ if (params.fields && params.fields.length > 0) parts.push(`fields=${encodeURIComponent(params.fields.join(","))}`);
115
+ if (params.distinct) parts.push("distinct=true");
112
116
  if (params.logical) {
113
117
  const root = params.logical;
114
118
  const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
@@ -123,6 +127,39 @@ function buildQueryString(params) {
123
127
  return parts.length > 0 ? "?" + parts.join("&") : "";
124
128
  }
125
129
  /**
130
+ * The query string for `GET /<collection>/aggregate`.
131
+ *
132
+ * `?select=sum(total),count()` is SQL's spelling, because whoever writes an
133
+ * aggregate is thinking in SQL and any other spelling has to be learned first —
134
+ * and because it is what the route already parses. The filters are serialised
135
+ * by exactly the same code a `find()` uses, so "revenue by status, this month"
136
+ * narrows the same rows whichever call is asking.
137
+ *
138
+ * `orderBy`, `include` and the page are deliberately not here: an aggregate has
139
+ * no rows to sort, no relations to load and no page to continue. `limit` is,
140
+ * and bounds the number of *groups*.
141
+ */
142
+ function buildAggregateQueryString(params) {
143
+ const parts = [];
144
+ const select = params.select.map((entry) => `${entry.fn}(${entry.field ?? ""})`).join(",");
145
+ parts.push(`select=${encodeURIComponent(select)}`);
146
+ if (params.groupBy && params.groupBy.length > 0) parts.push(`groupBy=${encodeURIComponent(params.groupBy.join(","))}`);
147
+ if (params.limit != null) parts.push(`limit=${params.limit}`);
148
+ if (params.searchString) parts.push(`searchString=${encodeURIComponent(params.searchString)}`);
149
+ if (params.logical) {
150
+ const root = params.logical;
151
+ const serialized = (root.conditions ?? []).map(serializeLogicalCondition).join(",");
152
+ parts.push(`${root.type}=${encodeURIComponent(`(${serialized})`)}`);
153
+ }
154
+ if (params.where) {
155
+ assertNoUndefinedFilterValues(params.where);
156
+ const serialized = serializeFilter(params.where);
157
+ for (const [field, value] of Object.entries(serialized)) if (Array.isArray(value)) for (const v of value) parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(v)}`);
158
+ else parts.push(`${encodeURIComponent(field)}=${encodeURIComponent(value)}`);
159
+ }
160
+ return "?" + parts.join("&");
161
+ }
162
+ /**
126
163
  * The base every request and every caller-built URL resolves against.
127
164
  *
128
165
  * `baseUrl` is optional because the common production shape is a Rebase
@@ -284,7 +321,7 @@ function createTransport(config, environment) {
284
321
  })()
285
322
  });
286
323
  }
287
- async function request(path, init) {
324
+ async function request(path, init, meta) {
288
325
  const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
289
326
  let activeToken = token;
290
327
  if (tokenGetter) try {
@@ -298,6 +335,10 @@ function createTransport(config, environment) {
298
335
  ...init,
299
336
  headers
300
337
  });
338
+ if (meta) {
339
+ meta.status = res.status;
340
+ meta.etag = res.headers?.get?.("ETag") ?? void 0;
341
+ }
301
342
  if (res.status === 204) return void 0;
302
343
  const text = await res.text().catch(() => "");
303
344
  let body = {};
@@ -334,6 +375,10 @@ function createTransport(config, environment) {
334
375
  ...init,
335
376
  headers: retryHeaders
336
377
  });
378
+ if (meta) {
379
+ meta.status = retryRes.status;
380
+ meta.etag = retryRes.headers?.get?.("ETag") ?? void 0;
381
+ }
337
382
  if (retryRes.status === 204) return void 0;
338
383
  const retryText = await retryRes.text().catch(() => "");
339
384
  let retryBody = {};
@@ -1543,9 +1588,14 @@ var SDKQueryBuilder = class {
1543
1588
  * `.orderBy("roles").orderBy("created_at", "desc")` sorts by role and
1544
1589
  * shows the newest first within each one.
1545
1590
  */
1546
- orderBy(column, direction = "asc") {
1591
+ orderBy(column, direction = "asc", nulls) {
1547
1592
  const existing = normalizeOrderBy(this.params.orderBy) ?? [];
1548
- this.params.orderBy = [...existing, [sortKeyToString(column), direction]];
1593
+ const key = sortKeyToString(column);
1594
+ this.params.orderBy = [...existing, nulls ? [
1595
+ key,
1596
+ direction,
1597
+ nulls
1598
+ ] : [key, direction]];
1549
1599
  return this;
1550
1600
  }
1551
1601
  /**
@@ -1616,15 +1666,74 @@ var SDKQueryBuilder = class {
1616
1666
  return this;
1617
1667
  }
1618
1668
  /**
1619
- * Include related entities in the response.
1620
- * Relations will be populated with full data instead of just IDs.
1669
+ * Load related rows into the response, in place of their foreign keys.
1621
1670
  *
1622
- * @param relations - Relation names to include, or "*" for all.
1623
- * @example
1624
- * client.data.posts.include("tags", "author").find()
1671
+ * Three spellings, all the same request:
1672
+ *
1673
+ * ```ts
1674
+ * client.data.posts.include("tags", "author") // names
1675
+ * client.data.posts.include("comments.author") // a dotted path
1676
+ * client.data.posts.include({ // parametrised
1677
+ * comments: {
1678
+ * limit: 5,
1679
+ * where: { published: ["==", true] },
1680
+ * orderBy: ["created_at", "desc"],
1681
+ * include: { author: true }
1682
+ * }
1683
+ * })
1684
+ * ```
1685
+ *
1686
+ * Up to three hops deep, and `"*"` still loads every relation one hop
1687
+ * deep. A name that is not a relation of the collection is a 400
1688
+ * `UNKNOWN_RELATION` — it used to be ignored, which answers 200 with the
1689
+ * field missing and reads exactly like a row that has no related row.
1690
+ *
1691
+ * Repeated calls **merge**: `.include("author").include("tags")` asks for
1692
+ * both. Assigning here — which is what it used to do — meant the second
1693
+ * call silently discarded the first.
1625
1694
  */
1626
1695
  include(...relations) {
1627
- this.params.include = relations;
1696
+ this.params.include = mergeIncludeSpecs(this.params.include, relations);
1697
+ return this;
1698
+ }
1699
+ /**
1700
+ * Return only these columns.
1701
+ *
1702
+ * A projection at the database, not a trim of the response: a query that
1703
+ * needs two fields of a wide row reads two columns. The primary key always
1704
+ * comes back regardless — a row that cannot be addressed cannot be
1705
+ * updated, deleted or paged past — and `excludeFromApi` columns stay
1706
+ * hidden whether or not they are named.
1707
+ */
1708
+ fields(...columns) {
1709
+ this.params.fields = [...this.params.fields ?? [], ...columns];
1710
+ return this;
1711
+ }
1712
+ /**
1713
+ * Collapse rows that are identical over the columns being returned.
1714
+ *
1715
+ * Only meaningful alongside {@link fields}: the primary key is always in
1716
+ * the projection, so without narrowing it every row is already distinct.
1717
+ *
1718
+ * ```ts
1719
+ * client.data.posts.fields("status").distinct().find() // the statuses in use
1720
+ * ```
1721
+ */
1722
+ distinct(enabled = true) {
1723
+ this.params.distinct = enabled;
1724
+ return this;
1725
+ }
1726
+ /**
1727
+ * Continue after a previous page's `meta.nextCursor` — keyset paging.
1728
+ *
1729
+ * Unlike `offset`, a row inserted or deleted before the cursor cannot
1730
+ * shift the window, so a walk neither repeats nor skips rows. Keep
1731
+ * `orderBy` identical across pages: a cursor only continues the listing it
1732
+ * came from, and one used against a different sort is refused rather than
1733
+ * seeked in an order nobody asked for.
1734
+ */
1735
+ after(cursor) {
1736
+ this.params.after = cursor;
1628
1737
  return this;
1629
1738
  }
1630
1739
  /**
@@ -1634,6 +1743,29 @@ var SDKQueryBuilder = class {
1634
1743
  return this.collection.find(this.params);
1635
1744
  }
1636
1745
  /**
1746
+ * Aggregate the rows this query matches instead of returning them.
1747
+ *
1748
+ * The builder's `where`/`logical`/`search` narrow which rows are
1749
+ * aggregated; its `orderBy`, `include` and window do not apply — an
1750
+ * aggregate has no rows to sort, no relations to load and no page to
1751
+ * continue.
1752
+ *
1753
+ * ```ts
1754
+ * await client.data.orders
1755
+ * .where("created_at", ">=", startOfMonth)
1756
+ * .aggregate({ select: [{ fn: "sum", field: "total" }], groupBy: ["status"] });
1757
+ * // [{ status: "paid", sum_total: 41822.5 }, …]
1758
+ * ```
1759
+ */
1760
+ async aggregate(params) {
1761
+ return this.collection.aggregate({
1762
+ ...params,
1763
+ where: this.params.where,
1764
+ logical: this.params.logical,
1765
+ searchString: this.params.searchString
1766
+ });
1767
+ }
1768
+ /**
1637
1769
  * Page through everything this query matches, one row at a time.
1638
1770
  *
1639
1771
  * `.limit()` on the builder becomes the page size, so the ceiling on a
@@ -1709,6 +1841,67 @@ function inflightCountsFor(transport) {
1709
1841
  }
1710
1842
  return map;
1711
1843
  }
1844
+ /**
1845
+ * Where a fetched row's version is kept.
1846
+ *
1847
+ * Non-enumerable, so it does not appear in `Object.keys`, in `JSON.stringify`,
1848
+ * in a spread into an update body, or in the generated `Row` type — a row's
1849
+ * version is a fact about the *response*, not a column, and putting it in the
1850
+ * row proper would mean the server having to strip it back off every write.
1851
+ *
1852
+ * A string key rather than a symbol so it survives the structured clone an
1853
+ * offline store or a worker boundary does… which it does not, and that is the
1854
+ * honest position: an etag is per-read, and a row that has been through a cache
1855
+ * has not been read from the server. `etagOf` answers `undefined` there, and a
1856
+ * conditional write without a tag is an unconditional one.
1857
+ */
1858
+ var ETAG_KEY = "__etag";
1859
+ /**
1860
+ * The version of a row, when it came straight from a read that reported one.
1861
+ *
1862
+ * ```ts
1863
+ * const post = await client.data.posts.get(1);
1864
+ * await client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(post) });
1865
+ * ```
1866
+ *
1867
+ * `undefined` for a row from `find()` (a list response carries one ETag at
1868
+ * most, and it would not be this row's), from the offline cache, or from a
1869
+ * server that does not send them. Passing `undefined` as `ifMatch` sends no
1870
+ * precondition, so the call above degrades to an ordinary update rather than
1871
+ * failing — which is the right default, and the reason to read this at the call
1872
+ * site where the choice is visible.
1873
+ */
1874
+ function etagOf(row) {
1875
+ if (!row || typeof row !== "object") return void 0;
1876
+ const value = row[ETAG_KEY];
1877
+ return typeof value === "string" ? value : void 0;
1878
+ }
1879
+ /**
1880
+ * The headers a write's options ask for.
1881
+ *
1882
+ * One function rather than a spread at each call site: the three headers are
1883
+ * easy to add to one method and forget on the next, which is exactly how single
1884
+ * `PATCH` and `DELETE` came to send an `Idempotency-Key` the server never read
1885
+ * and `delete()` came to have no options at all.
1886
+ */
1887
+ function writeHeaders(options) {
1888
+ const headers = {};
1889
+ if (options?.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
1890
+ if (options?.ifMatch) headers["If-Match"] = options.ifMatch;
1891
+ if (options?.returning === false) headers["Prefer"] = "return=minimal";
1892
+ return Object.keys(headers).length > 0 ? { headers } : void 0;
1893
+ }
1894
+ /** Attach a version to a row without making it part of the row. */
1895
+ function withETag(row, etag) {
1896
+ if (!etag || !row || typeof row !== "object") return row;
1897
+ Object.defineProperty(row, ETAG_KEY, {
1898
+ value: etag,
1899
+ enumerable: false,
1900
+ configurable: true,
1901
+ writable: true
1902
+ });
1903
+ return row;
1904
+ }
1712
1905
  function createCollectionClient(transport, slug, ws) {
1713
1906
  const basePath = `/data/${slug}`;
1714
1907
  const client = {
@@ -1728,9 +1921,10 @@ function createCollectionClient(transport, slug, ws) {
1728
1921
  },
1729
1922
  async findById(id) {
1730
1923
  try {
1731
- const raw = await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
1924
+ const meta = {};
1925
+ const raw = await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" }, meta);
1732
1926
  if (!raw) return void 0;
1733
- return raw;
1927
+ return withETag(raw, meta.etag);
1734
1928
  } catch (err) {
1735
1929
  if (err instanceof RebaseApiError && err.status === 404) return;
1736
1930
  throw err;
@@ -1750,20 +1944,38 @@ function createCollectionClient(transport, slug, ws) {
1750
1944
  return await transport.request(basePath, {
1751
1945
  method: "POST",
1752
1946
  body: JSON.stringify(body),
1753
- ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1947
+ ...writeHeaders(options)
1948
+ });
1949
+ },
1950
+ /**
1951
+ * Insert, or replace the row already occupying the conflict target.
1952
+ *
1953
+ * One statement server-side (`INSERT ... ON CONFLICT DO UPDATE`), so
1954
+ * unlike a `findById` followed by `create`-or-`update` it cannot lose
1955
+ * the race between the two.
1956
+ */
1957
+ async upsert(data, options) {
1958
+ const query = options?.onConflict?.length ? `?on_conflict=${encodeURIComponent(options.onConflict.join(","))}` : "";
1959
+ return await transport.request(`${basePath}${query}`, {
1960
+ method: "POST",
1961
+ body: JSON.stringify(data),
1962
+ ...writeHeaders(options)
1754
1963
  });
1755
1964
  },
1756
1965
  async createMany(data, options) {
1757
1966
  if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
1758
1967
  if (data.length === 0) return [];
1759
- return (await transport.request(`${basePath}/bulk`, {
1968
+ const raw = await transport.request(`${basePath}/bulk`, {
1760
1969
  method: "POST",
1761
1970
  body: JSON.stringify({
1762
1971
  rows: data,
1763
- ...options?.upsert ? { upsert: true } : {}
1972
+ ...options?.upsert ? { upsert: true } : {},
1973
+ ...options?.onConflict?.length ? { onConflict: options.onConflict } : {}
1764
1974
  }),
1765
- ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1766
- })).data || [];
1975
+ ...writeHeaders(options)
1976
+ });
1977
+ if (options?.returning === false) return [];
1978
+ return raw.data || [];
1767
1979
  },
1768
1980
  /**
1769
1981
  * `PATCH`, the verb for a merge — `update(id, data: Partial<M>)` sends
@@ -1778,20 +1990,25 @@ function createCollectionClient(transport, slug, ws) {
1778
1990
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
1779
1991
  method: "PATCH",
1780
1992
  body: JSON.stringify(data),
1781
- ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1993
+ ...writeHeaders(options)
1782
1994
  });
1783
1995
  },
1784
1996
  async updateMany(updates, options) {
1785
1997
  if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
1786
1998
  if (updates.length === 0) return [];
1787
- return (await transport.request(`${basePath}/bulk`, {
1999
+ const raw = await transport.request(`${basePath}/bulk`, {
1788
2000
  method: "PATCH",
1789
2001
  body: JSON.stringify({ updates }),
1790
- ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
1791
- })).data || [];
2002
+ ...writeHeaders(options)
2003
+ });
2004
+ if (options?.returning === false) return [];
2005
+ return raw.data || [];
1792
2006
  },
1793
- async delete(id) {
1794
- await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "DELETE" });
2007
+ async delete(id, options) {
2008
+ await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
2009
+ method: "DELETE",
2010
+ ...writeHeaders(options)
2011
+ });
1795
2012
  },
1796
2013
  /**
1797
2014
  * `POST .../bulk/delete`, not `DELETE .../bulk`.
@@ -1810,7 +2027,7 @@ function createCollectionClient(transport, slug, ws) {
1810
2027
  await transport.request(`${basePath}/bulk/delete`, {
1811
2028
  method: "POST",
1812
2029
  body: JSON.stringify({ ids }),
1813
- ...options?.idempotencyKey ? { headers: { "Idempotency-Key": options.idempotencyKey } } : {}
2030
+ ...writeHeaders(options)
1814
2031
  });
1815
2032
  },
1816
2033
  async count(params) {
@@ -1889,8 +2106,8 @@ function createCollectionClient(transport, slug, ws) {
1889
2106
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
1890
2107
  return builder.where(columnOrCondition, operator, value);
1891
2108
  },
1892
- orderBy(column, direction) {
1893
- return new SDKQueryBuilder(client).orderBy(column, direction);
2109
+ orderBy(column, direction, nulls) {
2110
+ return new SDKQueryBuilder(client).orderBy(column, direction, nulls);
1894
2111
  },
1895
2112
  limit(count) {
1896
2113
  return new SDKQueryBuilder(client).limit(count);
@@ -1907,6 +2124,28 @@ function createCollectionClient(transport, slug, ws) {
1907
2124
  include(...relations) {
1908
2125
  return new SDKQueryBuilder(client).include(...relations);
1909
2126
  },
2127
+ fields(...columns) {
2128
+ return new SDKQueryBuilder(client).fields(...columns);
2129
+ },
2130
+ distinct(enabled) {
2131
+ return new SDKQueryBuilder(client).distinct(enabled);
2132
+ },
2133
+ after(cursor) {
2134
+ return new SDKQueryBuilder(client).after(cursor);
2135
+ },
2136
+ /**
2137
+ * `count`/`sum`/`avg`/`min`/`max` over the matching rows, optionally
2138
+ * grouped — `GET /<collection>/aggregate`.
2139
+ *
2140
+ * The REST route has served this since aggregates landed and the SDK
2141
+ * had no method for it, so the only way to reach it from a typed client
2142
+ * was to hand-build the URL. `findAll()` and a reduce is the thing this
2143
+ * exists to replace: wrong under a `limit`, unaffordable without one.
2144
+ */
2145
+ async aggregate(params) {
2146
+ const qs = buildAggregateQueryString(params);
2147
+ return (await transport.request(`${basePath}/aggregate${qs}`, { method: "GET" })).data || [];
2148
+ },
1910
2149
  listen: unsupportedMethod(NO_SOCKET),
1911
2150
  listenById: unsupportedMethod(NO_SOCKET)
1912
2151
  };
@@ -1922,36 +2161,46 @@ function createCollectionClient(transport, slug, ws) {
1922
2161
  logical: params?.logical,
1923
2162
  limit: params?.limit,
1924
2163
  offset: window.driverOffset,
2164
+ page: params?.page,
2165
+ include: params?.include,
2166
+ fields: params?.fields,
2167
+ distinct: params?.distinct,
1925
2168
  orderBy: normalizeOrderBy(params?.orderBy),
1926
2169
  searchString: params?.searchString,
1927
2170
  searchExplain: params?.searchExplain,
1928
2171
  vectorSearch: params?.vectorSearch
1929
- }, (incomingRows) => {
2172
+ }, (incomingRows, frameMeta) => {
1930
2173
  const currentUpdateId = ++lastUpdateId;
1931
2174
  const requestedLimit = window.limit;
1932
2175
  const offset = window.offset;
1933
2176
  const rows = incomingRows;
1934
- const emit = (total, hasMore) => {
2177
+ const emit = (total, hasMore, nextCursor) => {
1935
2178
  if (!active || currentUpdateId !== lastUpdateId) return;
1936
2179
  onUpdate({
1937
2180
  data: rows,
1938
2181
  meta: {
1939
2182
  total,
1940
- limit: requestedLimit,
1941
- offset,
1942
- hasMore
2183
+ limit: frameMeta?.limit ?? requestedLimit,
2184
+ offset: frameMeta?.offset ?? offset,
2185
+ hasMore,
2186
+ ...nextCursor && { nextCursor }
1943
2187
  }
1944
2188
  });
1945
2189
  };
2190
+ if (frameMeta && frameMeta.total !== void 0) {
2191
+ lastKnownTotal = frameMeta.total;
2192
+ emit(frameMeta.total, frameMeta.hasMore, frameMeta.nextCursor);
2193
+ return;
2194
+ }
2195
+ if (lastKnownTotal !== void 0) {
2196
+ emit(lastKnownTotal, offset + rows.length < lastKnownTotal, frameMeta?.nextCursor);
2197
+ return;
2198
+ }
1946
2199
  client.count(params).then((total) => {
1947
2200
  lastKnownTotal = total;
1948
- emit(total, offset + rows.length < total);
2201
+ emit(total, offset + rows.length < total, frameMeta?.nextCursor);
1949
2202
  }).catch(() => {
1950
- if (lastKnownTotal !== void 0) {
1951
- emit(lastKnownTotal, offset + rows.length < lastKnownTotal);
1952
- return;
1953
- }
1954
- emit(offset + rows.length, rows.length >= requestedLimit);
2203
+ emit(offset + rows.length, rows.length >= requestedLimit, frameMeta?.nextCursor);
1955
2204
  });
1956
2205
  }, onError);
1957
2206
  return () => {
@@ -2044,9 +2293,13 @@ function createStorage(transport, storageId) {
2044
2293
  if (!storageId) return path;
2045
2294
  return `${path}${path.includes("?") ? "&" : "?"}storageId=${encodeURIComponent(storageId)}`;
2046
2295
  };
2047
- async function putObject({ file, key, metadata, bucket, public: isPublic }) {
2296
+ async function putObject({ file, key, metadata, bucket, public: isPublic, context }) {
2048
2297
  const formData = new FormData();
2049
2298
  formData.append("file", file);
2299
+ if (context?.collection && context?.property) {
2300
+ formData.append("collection", context.collection);
2301
+ formData.append("property", context.property);
2302
+ }
2050
2303
  let effectiveKey = key;
2051
2304
  if (isPublic && effectiveKey && !isPublicStoragePath(effectiveKey)) effectiveKey = `${PUBLIC_STORAGE_PREFIX}${effectiveKey.replace(/^\/+/, "")}`;
2052
2305
  if (effectiveKey) formData.append("key", effectiveKey);
@@ -2628,6 +2881,8 @@ var RebaseWebSocketClient = class {
2628
2881
  const incomingRows = message.rows || [];
2629
2882
  const updatePks = message.pks;
2630
2883
  if (updatePks) collectionSub.pks = updatePks;
2884
+ const updateMeta = message.meta;
2885
+ if (updateMeta) collectionSub.latestMeta = updateMeta;
2631
2886
  const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
2632
2887
  collectionSub.latestData = rows;
2633
2888
  collectionSub.lastUpdated = Date.now();
@@ -2637,7 +2892,7 @@ var RebaseWebSocketClient = class {
2637
2892
  collectionSub.subscribeInFlight = false;
2638
2893
  collectionSub.callbacks.forEach((callback) => {
2639
2894
  try {
2640
- callback.onUpdate(rows);
2895
+ callback.onUpdate(rows, collectionSub.latestMeta);
2641
2896
  } catch (error) {
2642
2897
  console.error("Error in collection subscription callback:", error);
2643
2898
  if (callback.onError) callback.onError(error instanceof Error ? error : new Error(String(error)));
@@ -3043,7 +3298,7 @@ var RebaseWebSocketClient = class {
3043
3298
  onError
3044
3299
  });
3045
3300
  if (existingSubscription.latestData !== void 0 && existingSubscription.isInitialDataReceived) try {
3046
- onUpdate(existingSubscription.latestData);
3301
+ onUpdate(existingSubscription.latestData, existingSubscription.latestMeta);
3047
3302
  } catch (error) {
3048
3303
  console.error("Error in collection subscription callback:", error);
3049
3304
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
@@ -4565,9 +4820,22 @@ function logicalOrders(condition, depth = 0) {
4565
4820
  * separate answer, because a sort changes which rows come first and not which
4566
4821
  * rows match. See {@link isLocallySortable}.
4567
4822
  */
4823
+ /**
4824
+ * Whether an `include` asks for anything, in any of its spellings.
4825
+ *
4826
+ * `include` is a list of names, a list of dotted paths, or a tree — and only
4827
+ * the first two have a `length`. A `.length > 0` test read the tree form as
4828
+ * "no relations", which is the answer that makes the local cache claim it can
4829
+ * evaluate a relational query exactly.
4830
+ */
4831
+ function hasAnyInclude(include) {
4832
+ if (!include) return false;
4833
+ if (Array.isArray(include)) return include.length > 0;
4834
+ return Object.keys(include).length > 0;
4835
+ }
4568
4836
  function isExactlyEvaluable(params) {
4569
4837
  if (!params) return true;
4570
- if (params.include && params.include.length > 0) return false;
4838
+ if (hasAnyInclude(params.include)) return false;
4571
4839
  if (params.searchString) return false;
4572
4840
  if (params.vectorSearch) return false;
4573
4841
  if (whereOrders(params.where)) return false;
@@ -4980,6 +5248,47 @@ var OfflineManager = class {
4980
5248
  this.notifyCollection(slug);
4981
5249
  return rows;
4982
5250
  },
5251
+ /**
5252
+ * Upsert, with the one case that cannot be resolved without the
5253
+ * server refused rather than guessed.
5254
+ *
5255
+ * On the primary key an upsert is create-or-replace, which the
5256
+ * local store can do and the queue already carries (`createMany`
5257
+ * with `upsert`). On a natural key it is not: deciding whether a
5258
+ * row with that email exists means an index this store does not
5259
+ * keep, and inventing an id would insert a duplicate the moment the
5260
+ * queue drains — the exact outcome upsert exists to prevent.
5261
+ */
5262
+ upsert: async (data, options) => {
5263
+ await this.ensureCollection(slug);
5264
+ if (this.connectivity.shouldAttempt()) try {
5265
+ const row = await inner.upsert(data, options);
5266
+ this.connectivity.markSuccess();
5267
+ await this.ingest(slug, [row]);
5268
+ this.notifyCollection(slug);
5269
+ this.scheduleRefresh(slug);
5270
+ return row;
5271
+ } catch (error) {
5272
+ if (!isNetworkError(error)) throw error;
5273
+ this.connectivity.markFailure();
5274
+ }
5275
+ const rowId = data.id;
5276
+ if (options?.onConflict?.length || rowId === void 0) throw new RebaseClientError(`Cannot upsert into "${slug}" while offline: the row it would replace can only be found by the server. Upserting on the primary key, with the key in the row, is queued; upserting on a natural key is not.`, { code: "OFFLINE_UPSERT_UNSUPPORTED" });
5277
+ const row = {
5278
+ ...data,
5279
+ id: rowId
5280
+ };
5281
+ await this.enqueue({
5282
+ collection: slug,
5283
+ type: "createMany",
5284
+ data: [row],
5285
+ upsert: true,
5286
+ rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
5287
+ });
5288
+ this.setLocalRow(slug, rowId, row);
5289
+ this.notifyCollection(slug);
5290
+ return row;
5291
+ },
4983
5292
  updateMany: async (updates, options) => {
4984
5293
  await this.ensureCollection(slug);
4985
5294
  if (!Array.isArray(updates)) throw new TypeError("updateMany expects an array of { id, data } entries.");
@@ -5046,10 +5355,10 @@ var OfflineManager = class {
5046
5355
  for (const id of ids) this.removeLocalRow(slug, id, false);
5047
5356
  this.notifyCollection(slug);
5048
5357
  },
5049
- update: async (id, data) => {
5358
+ update: async (id, data, options) => {
5050
5359
  await this.ensureCollection(slug);
5051
5360
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
5052
- const row = await inner.update(id, data);
5361
+ const row = await inner.update(id, data, options);
5053
5362
  this.connectivity.markSuccess();
5054
5363
  await this.ingest(slug, [row]);
5055
5364
  this.notifyCollection(slug);
@@ -5058,6 +5367,7 @@ var OfflineManager = class {
5058
5367
  if (!isNetworkError(error)) throw error;
5059
5368
  this.connectivity.markFailure();
5060
5369
  }
5370
+ if (hasFieldOperation(data)) throw new RebaseClientError(`Cannot apply a field operation to "${slug}" while offline: it is evaluated against the stored value, which this device does not have a current copy of. Send the value itself, or retry when the connection is back.`, { code: "OFFLINE_FIELD_OP_UNSUPPORTED" });
5061
5371
  const base = this.rawLocalRow(slug, id);
5062
5372
  await this.enqueue({
5063
5373
  collection: slug,
@@ -5075,10 +5385,10 @@ var OfflineManager = class {
5075
5385
  this.notifyCollection(slug);
5076
5386
  return optimistic;
5077
5387
  },
5078
- delete: async (id) => {
5388
+ delete: async (id, options) => {
5079
5389
  await this.ensureCollection(slug);
5080
5390
  if (this.connectivity.shouldAttempt() && !this.hasPending(slug, id)) try {
5081
- await inner.delete(id);
5391
+ await inner.delete(id, options);
5082
5392
  this.connectivity.markSuccess();
5083
5393
  this.removeLocalRow(slug, id, true);
5084
5394
  this.notifyCollection(slug);
@@ -5121,12 +5431,16 @@ var OfflineManager = class {
5121
5431
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
5122
5432
  return builder.where(columnOrCondition, operator, value);
5123
5433
  },
5124
- orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
5434
+ orderBy: (column, direction, nulls) => new SDKQueryBuilder(wrapped).orderBy(column, direction, nulls),
5125
5435
  limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
5126
5436
  offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
5127
5437
  search: (searchString, options) => new SDKQueryBuilder(wrapped).search(searchString, options),
5128
5438
  vectorSearch: (property, vector, options) => new SDKQueryBuilder(wrapped).vectorSearch(property, vector, options),
5129
5439
  include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations),
5440
+ fields: (...columns) => new SDKQueryBuilder(wrapped).fields(...columns),
5441
+ distinct: (enabled) => new SDKQueryBuilder(wrapped).distinct(enabled),
5442
+ after: (cursor) => new SDKQueryBuilder(wrapped).after(cursor),
5443
+ aggregate: inner.aggregate,
5130
5444
  listen: inner.listen,
5131
5445
  listenById: inner.listenById
5132
5446
  };
@@ -6274,11 +6588,26 @@ channel: (name, options) => {
6274
6588
  body: payload ? JSON.stringify(payload) : void 0
6275
6589
  });
6276
6590
  },
6591
+ batch: async (operations, options) => {
6592
+ if (!Array.isArray(operations)) throw new TypeError("batch expects an array of operations.");
6593
+ if (operations.length === 0) return {
6594
+ data: [],
6595
+ meta: { operations: 0 }
6596
+ };
6597
+ const headers = {};
6598
+ if (options?.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
6599
+ if (options?.returning === false) headers.Prefer = "return=minimal";
6600
+ return transport.request("/data/_batch", {
6601
+ method: "POST",
6602
+ body: JSON.stringify({ operations }),
6603
+ ...Object.keys(headers).length > 0 ? { headers } : {}
6604
+ });
6605
+ },
6277
6606
  data: dataProxy,
6278
6607
  ...offlineManager ? { offline: offlineManager.api } : {}
6279
6608
  };
6280
6609
  }
6281
6610
  //#endregion
6282
- export { MemoryOfflineStore, QueryBuilder, RebaseApiError, RebaseClientError, RebasePaginationError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, isOfflineError, or };
6611
+ export { MemoryOfflineStore, QueryBuilder, RebaseApiError, RebaseClientError, RebasePaginationError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, etagOf, isOfflineError, not, or };
6283
6612
 
6284
6613
  //# sourceMappingURL=index.es.js.map