@object-ui/data-objectstack 11.0.0 → 11.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 11.3.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [d23d6eb]
8
+ - @object-ui/core@11.3.0
9
+ - @object-ui/types@11.3.0
10
+
11
+ ## 11.2.0
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [9e7a986]
16
+ - Updated dependencies [1311749]
17
+ - @object-ui/core@11.2.0
18
+ - @object-ui/types@11.2.0
19
+
20
+ ## 11.1.0
21
+
22
+ ### Patch Changes
23
+
24
+ - @object-ui/types@11.1.0
25
+ - @object-ui/core@11.1.0
26
+
3
27
  ## 7.3.0
4
28
 
5
29
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -585,6 +585,7 @@ function createObjectStackUserStateAdapter(options) {
585
585
  }
586
586
  } = options;
587
587
  let cachedRowId = null;
588
+ let saveChain = Promise.resolve();
588
589
  const findExisting = async () => {
589
590
  const params = {
590
591
  $filter: {
@@ -615,6 +616,42 @@ function createObjectStackUserStateAdapter(options) {
615
616
  }
616
617
  return [];
617
618
  };
619
+ const upsert = async (items) => {
620
+ const now = (/* @__PURE__ */ new Date()).toISOString();
621
+ if (cachedRowId !== null) {
622
+ try {
623
+ await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
624
+ return;
625
+ } catch (updateError) {
626
+ cachedRowId = null;
627
+ onError("save", updateError);
628
+ }
629
+ }
630
+ const existing = await findExisting();
631
+ if (existing && existing.id !== void 0 && existing.id !== null) {
632
+ cachedRowId = existing.id;
633
+ await dataSource.update(resource, existing.id, { value: items, updated_at: now });
634
+ return;
635
+ }
636
+ try {
637
+ const created = await dataSource.create(resource, {
638
+ user_id: userId,
639
+ key,
640
+ value: items,
641
+ updated_at: now
642
+ });
643
+ const newId = created?.id;
644
+ if (newId !== void 0 && newId !== null) cachedRowId = newId;
645
+ } catch (createError) {
646
+ const recovered = await findExisting();
647
+ if (recovered && recovered.id !== void 0 && recovered.id !== null) {
648
+ cachedRowId = recovered.id;
649
+ await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
650
+ return;
651
+ }
652
+ throw createError;
653
+ }
654
+ };
618
655
  return {
619
656
  async load() {
620
657
  try {
@@ -631,40 +668,11 @@ function createObjectStackUserStateAdapter(options) {
631
668
  }
632
669
  },
633
670
  async save(items) {
634
- try {
635
- const now = (/* @__PURE__ */ new Date()).toISOString();
636
- if (cachedRowId !== null) {
637
- try {
638
- await dataSource.update(resource, cachedRowId, {
639
- value: items,
640
- updated_at: now
641
- });
642
- return;
643
- } catch (updateError) {
644
- cachedRowId = null;
645
- onError("save", updateError);
646
- }
647
- }
648
- const existing = await findExisting();
649
- if (existing && existing.id !== void 0 && existing.id !== null) {
650
- cachedRowId = existing.id;
651
- await dataSource.update(resource, existing.id, {
652
- value: items,
653
- updated_at: now
654
- });
655
- return;
656
- }
657
- const created = await dataSource.create(resource, {
658
- user_id: userId,
659
- key,
660
- value: items,
661
- updated_at: now
662
- });
663
- const newId = created?.id;
664
- if (newId !== void 0 && newId !== null) cachedRowId = newId;
665
- } catch (error) {
671
+ const run = saveChain.then(() => upsert(items)).catch((error) => {
666
672
  onError("save", error);
667
- }
673
+ });
674
+ saveChain = run;
675
+ return run;
668
676
  }
669
677
  };
670
678
  }
@@ -2105,6 +2113,53 @@ var ObjectStackAdapter = class {
2105
2113
  }
2106
2114
  return body;
2107
2115
  }
2116
+ /**
2117
+ * Synchronously download a server-streamed export (csv / json / xlsx).
2118
+ *
2119
+ * Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
2120
+ * requested format, formats values for readability (lookup → name, select →
2121
+ * label, boolean → 是/否, dates formatted) and enforces permissions. The
2122
+ * filter / sort are translated the same way as `rawFindWithPopulate` so the
2123
+ * exported file mirrors the active list view. Returns the file as a Blob;
2124
+ * the caller triggers the browser download.
2125
+ */
2126
+ async exportDownload(resource, request = {}) {
2127
+ const queryParams = new URLSearchParams();
2128
+ const format = request.format === "xlsx" ? "xlsx" : request.format === "json" ? "json" : "csv";
2129
+ queryParams.set("format", format);
2130
+ if (request.fields && request.fields.length > 0) {
2131
+ queryParams.set("fields", request.fields.join(","));
2132
+ }
2133
+ if (request.limit && request.limit > 0) {
2134
+ queryParams.set("limit", String(request.limit));
2135
+ }
2136
+ if (request.includeHeaders === false) {
2137
+ queryParams.set("header", "false");
2138
+ }
2139
+ if (request.sort && request.sort.length > 0) {
2140
+ const orderby = request.sort.filter((s) => s && s.field).map((s) => `${s.field}:${s.direction === "desc" ? "desc" : "asc"}`).join(",");
2141
+ if (orderby) queryParams.set("orderby", orderby);
2142
+ }
2143
+ if (request.filter !== void 0 && request.filter !== null) {
2144
+ const translated = translateFilterToAST(request.filter);
2145
+ if (translated !== void 0) {
2146
+ queryParams.set("filter", JSON.stringify(translated));
2147
+ }
2148
+ }
2149
+ const baseUrl = this.baseUrl.replace(/\/$/, "");
2150
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
2151
+ const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
2152
+ const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
2153
+ const headers = { ...this.getAuthHeaders() };
2154
+ const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
2155
+ if (!res.ok) {
2156
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2157
+ const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
2158
+ err.status = res.status;
2159
+ throw err;
2160
+ }
2161
+ return await res.blob();
2162
+ }
2108
2163
  /**
2109
2164
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
2110
2165
  * Maps OData-style conventions to ObjectStack conventions.
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ObjectStackClient } from '@objectstack/client';
2
- import { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
2
+ import { DataSource, QueryParams, QueryResult, ExportDownloadRequest, FileUploadResult } from '@object-ui/types';
3
3
  export { FileUploadResult } from '@object-ui/types';
4
4
 
5
5
  /**
@@ -1443,6 +1443,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1443
1443
  * to findData({ object, query: req.query }) which processes `populate`.
1444
1444
  */
1445
1445
  private rawFindWithPopulate;
1446
+ /**
1447
+ * Synchronously download a server-streamed export (csv / json / xlsx).
1448
+ *
1449
+ * Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
1450
+ * requested format, formats values for readability (lookup → name, select →
1451
+ * label, boolean → 是/否, dates formatted) and enforces permissions. The
1452
+ * filter / sort are translated the same way as `rawFindWithPopulate` so the
1453
+ * exported file mirrors the active list view. Returns the file as a Blob;
1454
+ * the caller triggers the browser download.
1455
+ */
1456
+ exportDownload(resource: string, request?: ExportDownloadRequest): Promise<Blob>;
1446
1457
  /**
1447
1458
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
1448
1459
  * Maps OData-style conventions to ObjectStack conventions.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ObjectStackClient } from '@objectstack/client';
2
- import { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
2
+ import { DataSource, QueryParams, QueryResult, ExportDownloadRequest, FileUploadResult } from '@object-ui/types';
3
3
  export { FileUploadResult } from '@object-ui/types';
4
4
 
5
5
  /**
@@ -1443,6 +1443,17 @@ declare class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1443
1443
  * to findData({ object, query: req.query }) which processes `populate`.
1444
1444
  */
1445
1445
  private rawFindWithPopulate;
1446
+ /**
1447
+ * Synchronously download a server-streamed export (csv / json / xlsx).
1448
+ *
1449
+ * Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
1450
+ * requested format, formats values for readability (lookup → name, select →
1451
+ * label, boolean → 是/否, dates formatted) and enforces permissions. The
1452
+ * filter / sort are translated the same way as `rawFindWithPopulate` so the
1453
+ * exported file mirrors the active list view. Returns the file as a Blob;
1454
+ * the caller triggers the browser download.
1455
+ */
1456
+ exportDownload(resource: string, request?: ExportDownloadRequest): Promise<Blob>;
1446
1457
  /**
1447
1458
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
1448
1459
  * Maps OData-style conventions to ObjectStack conventions.
package/dist/index.js CHANGED
@@ -537,6 +537,7 @@ function createObjectStackUserStateAdapter(options) {
537
537
  }
538
538
  } = options;
539
539
  let cachedRowId = null;
540
+ let saveChain = Promise.resolve();
540
541
  const findExisting = async () => {
541
542
  const params = {
542
543
  $filter: {
@@ -567,6 +568,42 @@ function createObjectStackUserStateAdapter(options) {
567
568
  }
568
569
  return [];
569
570
  };
571
+ const upsert = async (items) => {
572
+ const now = (/* @__PURE__ */ new Date()).toISOString();
573
+ if (cachedRowId !== null) {
574
+ try {
575
+ await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
576
+ return;
577
+ } catch (updateError) {
578
+ cachedRowId = null;
579
+ onError("save", updateError);
580
+ }
581
+ }
582
+ const existing = await findExisting();
583
+ if (existing && existing.id !== void 0 && existing.id !== null) {
584
+ cachedRowId = existing.id;
585
+ await dataSource.update(resource, existing.id, { value: items, updated_at: now });
586
+ return;
587
+ }
588
+ try {
589
+ const created = await dataSource.create(resource, {
590
+ user_id: userId,
591
+ key,
592
+ value: items,
593
+ updated_at: now
594
+ });
595
+ const newId = created?.id;
596
+ if (newId !== void 0 && newId !== null) cachedRowId = newId;
597
+ } catch (createError) {
598
+ const recovered = await findExisting();
599
+ if (recovered && recovered.id !== void 0 && recovered.id !== null) {
600
+ cachedRowId = recovered.id;
601
+ await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
602
+ return;
603
+ }
604
+ throw createError;
605
+ }
606
+ };
570
607
  return {
571
608
  async load() {
572
609
  try {
@@ -583,40 +620,11 @@ function createObjectStackUserStateAdapter(options) {
583
620
  }
584
621
  },
585
622
  async save(items) {
586
- try {
587
- const now = (/* @__PURE__ */ new Date()).toISOString();
588
- if (cachedRowId !== null) {
589
- try {
590
- await dataSource.update(resource, cachedRowId, {
591
- value: items,
592
- updated_at: now
593
- });
594
- return;
595
- } catch (updateError) {
596
- cachedRowId = null;
597
- onError("save", updateError);
598
- }
599
- }
600
- const existing = await findExisting();
601
- if (existing && existing.id !== void 0 && existing.id !== null) {
602
- cachedRowId = existing.id;
603
- await dataSource.update(resource, existing.id, {
604
- value: items,
605
- updated_at: now
606
- });
607
- return;
608
- }
609
- const created = await dataSource.create(resource, {
610
- user_id: userId,
611
- key,
612
- value: items,
613
- updated_at: now
614
- });
615
- const newId = created?.id;
616
- if (newId !== void 0 && newId !== null) cachedRowId = newId;
617
- } catch (error) {
623
+ const run = saveChain.then(() => upsert(items)).catch((error) => {
618
624
  onError("save", error);
619
- }
625
+ });
626
+ saveChain = run;
627
+ return run;
620
628
  }
621
629
  };
622
630
  }
@@ -2057,6 +2065,53 @@ var ObjectStackAdapter = class {
2057
2065
  }
2058
2066
  return body;
2059
2067
  }
2068
+ /**
2069
+ * Synchronously download a server-streamed export (csv / json / xlsx).
2070
+ *
2071
+ * Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
2072
+ * requested format, formats values for readability (lookup → name, select →
2073
+ * label, boolean → 是/否, dates formatted) and enforces permissions. The
2074
+ * filter / sort are translated the same way as `rawFindWithPopulate` so the
2075
+ * exported file mirrors the active list view. Returns the file as a Blob;
2076
+ * the caller triggers the browser download.
2077
+ */
2078
+ async exportDownload(resource, request = {}) {
2079
+ const queryParams = new URLSearchParams();
2080
+ const format = request.format === "xlsx" ? "xlsx" : request.format === "json" ? "json" : "csv";
2081
+ queryParams.set("format", format);
2082
+ if (request.fields && request.fields.length > 0) {
2083
+ queryParams.set("fields", request.fields.join(","));
2084
+ }
2085
+ if (request.limit && request.limit > 0) {
2086
+ queryParams.set("limit", String(request.limit));
2087
+ }
2088
+ if (request.includeHeaders === false) {
2089
+ queryParams.set("header", "false");
2090
+ }
2091
+ if (request.sort && request.sort.length > 0) {
2092
+ const orderby = request.sort.filter((s) => s && s.field).map((s) => `${s.field}:${s.direction === "desc" ? "desc" : "asc"}`).join(",");
2093
+ if (orderby) queryParams.set("orderby", orderby);
2094
+ }
2095
+ if (request.filter !== void 0 && request.filter !== null) {
2096
+ const translated = translateFilterToAST(request.filter);
2097
+ if (translated !== void 0) {
2098
+ queryParams.set("filter", JSON.stringify(translated));
2099
+ }
2100
+ }
2101
+ const baseUrl = this.baseUrl.replace(/\/$/, "");
2102
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
2103
+ const dataPath = hasApiVersionSuffix ? "/data" : "/api/v1/data";
2104
+ const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
2105
+ const headers = { ...this.getAuthHeaders() };
2106
+ const res = await this.fetchImpl(url, { method: "GET", headers, credentials: "include" });
2107
+ if (!res.ok) {
2108
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
2109
+ const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText);
2110
+ err.status = res.status;
2111
+ throw err;
2112
+ }
2113
+ return await res.blob();
2114
+ }
2060
2115
  /**
2061
2116
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
2062
2117
  * Maps OData-style conventions to ObjectStack conventions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "11.0.0",
3
+ "version": "11.3.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,9 +22,9 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@objectstack/client": "^11.0.0",
26
- "@object-ui/core": "11.0.0",
27
- "@object-ui/types": "11.0.0"
25
+ "@objectstack/client": "^11.2.0",
26
+ "@object-ui/core": "11.3.0",
27
+ "@object-ui/types": "11.3.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.1",
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { ObjectStackAdapter } from './index';
11
+
12
+ /** Build an adapter whose fetch is a spy returning the given Response. */
13
+ function makeDS(fetchImpl: any) {
14
+ const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
15
+ ds.connected = true;
16
+ ds.connectionState = 'connected';
17
+ return ds;
18
+ }
19
+
20
+ function csvResponse(body = 'ID,Name\n1,Acme') {
21
+ return new Response(body, { status: 200, headers: { 'Content-Type': 'text/csv' } });
22
+ }
23
+
24
+ describe('ObjectStackAdapter.exportDownload', () => {
25
+ it('GETs the /export route with format, fields, orderby, header and limit', async () => {
26
+ const fetchImpl = vi.fn(async () => csvResponse());
27
+ const ds = makeDS(fetchImpl);
28
+
29
+ const blob = await ds.exportDownload('task', {
30
+ format: 'xlsx',
31
+ fields: ['title', 'owner'],
32
+ sort: [{ field: 'title', direction: 'desc' }, { field: 'owner' }],
33
+ includeHeaders: false,
34
+ limit: 5000,
35
+ });
36
+
37
+ expect(blob).toBeInstanceOf(Blob);
38
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
39
+ const [url, init] = fetchImpl.mock.calls[0];
40
+ const parsed = new URL(url);
41
+ expect(parsed.pathname).toBe('/api/v1/data/task/export');
42
+ expect(parsed.searchParams.get('format')).toBe('xlsx');
43
+ expect(parsed.searchParams.get('fields')).toBe('title,owner');
44
+ // direction defaults to asc when omitted.
45
+ expect(parsed.searchParams.get('orderby')).toBe('title:desc,owner:asc');
46
+ expect(parsed.searchParams.get('header')).toBe('false');
47
+ expect(parsed.searchParams.get('limit')).toBe('5000');
48
+ expect(init.method).toBe('GET');
49
+ expect(init.credentials).toBe('include');
50
+ });
51
+
52
+ it('defaults to csv and omits optional params when not provided', async () => {
53
+ const fetchImpl = vi.fn(async () => csvResponse());
54
+ const ds = makeDS(fetchImpl);
55
+
56
+ await ds.exportDownload('task', {});
57
+
58
+ const [url] = fetchImpl.mock.calls[0];
59
+ const parsed = new URL(url);
60
+ expect(parsed.searchParams.get('format')).toBe('csv');
61
+ expect(parsed.searchParams.get('fields')).toBeNull();
62
+ expect(parsed.searchParams.get('orderby')).toBeNull();
63
+ expect(parsed.searchParams.get('header')).toBeNull();
64
+ expect(parsed.searchParams.get('limit')).toBeNull();
65
+ });
66
+
67
+ it('serializes the filter to a JSON AST query param', async () => {
68
+ const fetchImpl = vi.fn(async () => csvResponse());
69
+ const ds = makeDS(fetchImpl);
70
+
71
+ await ds.exportDownload('task', { filter: [['status', '=', 'open']] });
72
+
73
+ const [url] = fetchImpl.mock.calls[0];
74
+ const raw = new URL(url).searchParams.get('filter');
75
+ expect(raw).toBeTruthy();
76
+ expect(JSON.parse(raw!)).toEqual([['status', '=', 'open']]);
77
+ });
78
+
79
+ it('throws an error carrying the server message and status on failure', async () => {
80
+ const fetchImpl = vi.fn(async () =>
81
+ new Response(JSON.stringify({ error: { message: 'Permission denied' } }), {
82
+ status: 403,
83
+ headers: { 'Content-Type': 'application/json' },
84
+ }),
85
+ );
86
+ const ds = makeDS(fetchImpl);
87
+
88
+ await expect(ds.exportDownload('task', { format: 'csv' })).rejects.toMatchObject({
89
+ message: 'Permission denied',
90
+ status: 403,
91
+ });
92
+ });
93
+ });
@@ -74,4 +74,40 @@ describe('ObjectStackAdapter.getObjectSchema', () => {
74
74
  expect(schema.name).toBe('account');
75
75
  expect(Object.keys(schema.fields)).toEqual(['x']);
76
76
  });
77
+
78
+ it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
79
+ // Inert-metadata regression guard. The inline select editor filters its
80
+ // options by the object's `state_machine` validation (objectui#2110). That
81
+ // feature only works because `getObjectSchema` passes the served
82
+ // `validations` through untouched — the server enforces the same rule. If a
83
+ // future change here started stripping or reshaping top-level metadata, the
84
+ // filter would silently no-op (valid-but-inert) with no error. Pin the
85
+ // pass-through so the data path can't go dark unnoticed.
86
+ const { fetchImpl } = makeFetch({
87
+ name: 'showcase_task',
88
+ fields: { status: { type: 'select', options: [{ value: 'done', label: 'Done' }] } },
89
+ validations: [
90
+ {
91
+ type: 'state_machine',
92
+ field: 'status',
93
+ transitions: { in_review: ['done', 'in_progress'], done: ['in_progress'] },
94
+ },
95
+ ],
96
+ });
97
+ const adapter = new ObjectStackAdapter({
98
+ baseUrl: 'http://localhost:3000',
99
+ autoReconnect: false,
100
+ fetch: fetchImpl as any,
101
+ });
102
+
103
+ const schema: any = await adapter.getObjectSchema('showcase_task');
104
+
105
+ const sm = (schema.validations ?? []).find((v: any) => v?.type === 'state_machine');
106
+ expect(sm).toBeTruthy();
107
+ expect(sm.field).toBe('status');
108
+ // The exact map the consumer filters on — `done` only transitions to
109
+ // `in_progress` (so the editor must not offer `in_review`).
110
+ expect(sm.transitions.done).toEqual(['in_progress']);
111
+ expect(sm.transitions.in_review).toEqual(['done', 'in_progress']);
112
+ });
77
113
  });
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
10
- import type { DataSource, QueryParams, QueryResult, FileUploadResult } from '@object-ui/types';
10
+ import type { DataSource, QueryParams, QueryResult, FileUploadResult, ExportDownloadRequest } from '@object-ui/types';
11
11
  import { convertFiltersToAST } from '@object-ui/core';
12
12
  import { MetadataCache } from './cache/MetadataCache';
13
13
  import {
@@ -1182,6 +1182,66 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1182
1182
  return body;
1183
1183
  }
1184
1184
 
1185
+ /**
1186
+ * Synchronously download a server-streamed export (csv / json / xlsx).
1187
+ *
1188
+ * Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
1189
+ * requested format, formats values for readability (lookup → name, select →
1190
+ * label, boolean → 是/否, dates formatted) and enforces permissions. The
1191
+ * filter / sort are translated the same way as `rawFindWithPopulate` so the
1192
+ * exported file mirrors the active list view. Returns the file as a Blob;
1193
+ * the caller triggers the browser download.
1194
+ */
1195
+ async exportDownload(resource: string, request: ExportDownloadRequest = {}): Promise<Blob> {
1196
+ const queryParams = new URLSearchParams();
1197
+
1198
+ const format = request.format === 'xlsx' ? 'xlsx' : request.format === 'json' ? 'json' : 'csv';
1199
+ queryParams.set('format', format);
1200
+
1201
+ if (request.fields && request.fields.length > 0) {
1202
+ queryParams.set('fields', request.fields.join(','));
1203
+ }
1204
+ if (request.limit && request.limit > 0) {
1205
+ queryParams.set('limit', String(request.limit));
1206
+ }
1207
+ if (request.includeHeaders === false) {
1208
+ queryParams.set('header', 'false');
1209
+ }
1210
+ // Sort → server `orderby` shorthand: "field:dir,field2:dir".
1211
+ if (request.sort && request.sort.length > 0) {
1212
+ const orderby = request.sort
1213
+ .filter(s => s && s.field)
1214
+ .map(s => `${s.field}:${s.direction === 'desc' ? 'desc' : 'asc'}`)
1215
+ .join(',');
1216
+ if (orderby) queryParams.set('orderby', orderby);
1217
+ }
1218
+ // Filter → AST tuples, same translation the list GET path uses.
1219
+ if (request.filter !== undefined && request.filter !== null) {
1220
+ const translated = translateFilterToAST(request.filter);
1221
+ if (translated !== undefined) {
1222
+ queryParams.set('filter', JSON.stringify(translated));
1223
+ }
1224
+ }
1225
+
1226
+ const baseUrl = this.baseUrl.replace(/\/$/, '');
1227
+ // Avoid doubling /api/v1 if baseUrl already includes the version suffix.
1228
+ const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
1229
+ const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
1230
+ const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
1231
+
1232
+ const headers: Record<string, string> = { ...this.getAuthHeaders() };
1233
+ // `credentials: 'include'` carries the session cookie for the browser
1234
+ // console (which authenticates by cookie, not a bearer token).
1235
+ const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
1236
+ if (!res.ok) {
1237
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
1238
+ const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
1239
+ err.status = res.status;
1240
+ throw err;
1241
+ }
1242
+ return await res.blob();
1243
+ }
1244
+
1185
1245
  /**
1186
1246
  * Convert ObjectUI QueryParams to ObjectStack QueryOptions.
1187
1247
  * Maps OData-style conventions to ObjectStack conventions.
@@ -221,6 +221,59 @@ describe('createObjectStackUserStateAdapter', () => {
221
221
  }));
222
222
  });
223
223
 
224
+ it('recovers from a UNIQUE(user_id, key) insert failure by updating in place', async () => {
225
+ // First findExisting() (no cached id) misses; create() loses the race and
226
+ // throws; the recovery findExisting() now sees the row → update.
227
+ const ds = mockDataSource({
228
+ find: vi.fn()
229
+ .mockResolvedValueOnce({ data: [] })
230
+ .mockResolvedValueOnce({ data: [{ id: 'row-7', user_id: 'u', key: 'k', value: [] }] }) as any,
231
+ create: vi.fn().mockRejectedValue(new Error('UNIQUE constraint failed')) as any,
232
+ });
233
+
234
+ const adapter = createObjectStackUserStateAdapter({
235
+ dataSource: ds,
236
+ userId: 'u',
237
+ key: 'k',
238
+ });
239
+
240
+ await adapter.save([{ id: 'q' } as any]);
241
+
242
+ expect(ds.create).toHaveBeenCalledTimes(1);
243
+ expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
244
+ value: [{ id: 'q' }],
245
+ updated_at: expect.any(String),
246
+ });
247
+ });
248
+
249
+ it('serializes concurrent saves so only one insert happens', async () => {
250
+ // Both saves start before either resolves. Without serialization both
251
+ // would findExisting()→[]→create() and the second would trip the
252
+ // UNIQUE constraint. With the save chain, the second waits, sees the
253
+ // cached row id from the first, and updates.
254
+ const ds = mockDataSource({
255
+ find: vi.fn().mockResolvedValue({ data: [] }) as any,
256
+ create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
257
+ });
258
+
259
+ const adapter = createObjectStackUserStateAdapter({
260
+ dataSource: ds,
261
+ userId: 'u',
262
+ key: 'ui.recent',
263
+ });
264
+
265
+ await Promise.all([
266
+ adapter.save([{ id: 'a' } as any]),
267
+ adapter.save([{ id: 'a' }, { id: 'b' }] as any),
268
+ ]);
269
+
270
+ expect(ds.create).toHaveBeenCalledTimes(1);
271
+ expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
272
+ value: [{ id: 'a' }, { id: 'b' }],
273
+ updated_at: expect.any(String),
274
+ });
275
+ });
276
+
224
277
  it('swallows errors when save() throws', async () => {
225
278
  const onError = vi.fn();
226
279
  const ds = mockDataSource({
package/src/userState.ts CHANGED
@@ -104,6 +104,14 @@ export function createObjectStackUserStateAdapter<T = unknown>(
104
104
  // without re-querying. Reset on every successful load.
105
105
  let cachedRowId: string | number | null = null;
106
106
 
107
+ // Serializes overlapping save() calls. A fresh adapter is created whenever
108
+ // the data source / user changes (see UserStateBridge), so its cachedRowId
109
+ // starts null; rapid navigations then fire debounced flushes that can race —
110
+ // two saves each findExisting()→null→create() and the second trips the
111
+ // UNIQUE(user_id, key) constraint. Chaining writes means the second save
112
+ // sees the cachedRowId the first one set and updates instead of inserting.
113
+ let saveChain: Promise<void> = Promise.resolve();
114
+
107
115
  const findExisting = async (): Promise<UserPreferenceRecord | null> => {
108
116
  // NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare
109
117
  // names (`filter`, `limit`) silently drops the predicate at the
@@ -146,6 +154,55 @@ export function createObjectStackUserStateAdapter<T = unknown>(
146
154
  return [];
147
155
  };
148
156
 
157
+ // Upsert by (user_id, key): update in place when the row is known/found,
158
+ // otherwise insert — and if the insert loses a UNIQUE(user_id, key) race
159
+ // (a concurrent writer created the row, or the backend dropped our find
160
+ // predicate), recover by re-finding and updating rather than surfacing the
161
+ // failed insert.
162
+ const upsert = async (items: T[]): Promise<void> => {
163
+ const now = new Date().toISOString();
164
+
165
+ // Fast path: we already know the row id from a previous load/save.
166
+ if (cachedRowId !== null) {
167
+ try {
168
+ await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
169
+ return;
170
+ } catch (updateError) {
171
+ // Row may have been deleted server-side — fall through to find/insert.
172
+ cachedRowId = null;
173
+ onError('save', updateError);
174
+ }
175
+ }
176
+
177
+ const existing = await findExisting();
178
+ if (existing && existing.id !== undefined && existing.id !== null) {
179
+ cachedRowId = existing.id;
180
+ await dataSource.update(resource, existing.id, { value: items, updated_at: now });
181
+ return;
182
+ }
183
+
184
+ try {
185
+ const created = await dataSource.create(resource, {
186
+ user_id: userId,
187
+ key,
188
+ value: items,
189
+ updated_at: now,
190
+ });
191
+ const newId = (created as UserPreferenceRecord | undefined)?.id;
192
+ if (newId !== undefined && newId !== null) cachedRowId = newId;
193
+ } catch (createError) {
194
+ // The row already exists (UNIQUE(user_id, key) violation) — re-find and
195
+ // update in place. This turns the insert into a real upsert.
196
+ const recovered = await findExisting();
197
+ if (recovered && recovered.id !== undefined && recovered.id !== null) {
198
+ cachedRowId = recovered.id;
199
+ await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
200
+ return;
201
+ }
202
+ throw createError;
203
+ }
204
+ };
205
+
149
206
  return {
150
207
  async load(): Promise<T[]> {
151
208
  try {
@@ -163,45 +220,15 @@ export function createObjectStackUserStateAdapter<T = unknown>(
163
220
  },
164
221
 
165
222
  async save(items: T[]): Promise<void> {
166
- try {
167
- const now = new Date().toISOString();
168
- // Fast path: we already know the row id from a previous load/save.
169
- if (cachedRowId !== null) {
170
- try {
171
- await dataSource.update(resource, cachedRowId, {
172
- value: items,
173
- updated_at: now,
174
- });
175
- return;
176
- } catch (updateError) {
177
- // Row may have been deleted server-side — fall through to insert.
178
- cachedRowId = null;
179
- onError('save', updateError);
180
- }
181
- }
182
-
183
- const existing = await findExisting();
184
- if (existing && existing.id !== undefined && existing.id !== null) {
185
- cachedRowId = existing.id;
186
- await dataSource.update(resource, existing.id, {
187
- value: items,
188
- updated_at: now,
189
- });
190
- return;
191
- }
192
-
193
- const created = await dataSource.create(resource, {
194
- user_id: userId,
195
- key,
196
- value: items,
197
- updated_at: now,
198
- });
199
- const newId = (created as UserPreferenceRecord | undefined)?.id;
200
- if (newId !== undefined && newId !== null) cachedRowId = newId;
201
- } catch (error) {
223
+ // Chain onto any in-flight save so concurrent flushes upsert serially
224
+ // rather than racing into two inserts (see `saveChain` above). Each link
225
+ // catches its own errors so one failure never poisons the chain.
226
+ const run = saveChain.then(() => upsert(items)).catch(error => {
202
227
  onError('save', error);
203
228
  // Swallow — provider falls back to localStorage as source of truth.
204
- }
229
+ });
230
+ saveChain = run;
231
+ return run;
205
232
  },
206
233
  };
207
234
  }