diff --git a/docs/spec.md b/docs/spec.md index 7fb4e4c..9d217bb 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -258,6 +258,8 @@ type StackType = { `POST /types` (see [Types](#types-1) under the wire format) applies the same check server-side, so the wire path can't silently replace a Type either. +**Type cache.** `Stack` caches every Type it fetches or defines in memory, keyed by versioned `id` — populated by `getType()`, `defineType()`, and `listTypes()`. Since a Type's schema is immutable once defined (a legal schema change always gets a new `id` via a version bump; see schema drift above), the cache is never invalidated, only added to — `create()`, `update()`, and `restoreVersion()` validate against the cached entry instead of re-fetching the Type on every write. Over the API adapter this removes a `GET /types/:id` round trip from every write after a type's first use in the process. `listTypes()` refreshes the cache wholesale, which is the explicit way to pick up a `name`-only rename made by another writer (the one field `defineType()` permits changing without a version bump) since the cache otherwise has no way to learn of it. + **Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, the candidate declares that same field as required, at a read-compatible kind. Array and object fields recurse: their `items`/`properties` must themselves be read-compatible. This licenses _consuming_ Records, not writing them — a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which compatibility checking never inspects). **Two distinct relations, easy to conflate:** schema drift detection (above) answers _"may this schema replace that one under the same `id`?"_ — evolution legality. Type compatibility (below) answers _"may a consumer expecting this shape read Records of that Type?"_ — read compatibility. They deliberately disagree on `text`/`string`: read-compatible (both are strings at the value level) but **not** evolution-legal (changing a field's declared `kind` is drift, even to a read-compatible one) — a stored `kind: 'string'` field silently becoming `kind: 'text'` is exactly the kind of change a version bump should surface, even though every existing reader could still consume the value. @@ -760,6 +762,7 @@ type AdapterCapabilities = { fullTextSearch: boolean; contentFieldQuery: boolean; sortableFields: string[]; + maxAttachmentBytes: number | null; // upload size ceiling, or null = unbounded }; ``` @@ -772,6 +775,8 @@ type AdapterCapabilities = { - **sql.js adapter** (`record-adapter-sqljs`, browser-only) — same query support as the native adapter, but full-text search via FTS4 (the sql.js WASM build's dialect) - **API adapter** — capabilities determined by the server; declared in a discovery endpoint +Local, embedded adapters (JSON, native SQLite, sql.js) declare `maxAttachmentBytes: null` — nothing at the storage layer imposes a ceiling. Only a server behind the API adapter enforces one, since it's the only adapter transporting attachment bytes over a connection with its own limits. + --- ## Deletion @@ -833,7 +838,8 @@ GET /.well-known/stack "capabilities": { "fullTextSearch": true, "contentFieldQuery": true, - "sortableFields": ["createdAt", "updatedAt", "version"] + "sortableFields": ["createdAt", "updatedAt", "version"], + "maxAttachmentBytes": 52428800 } } ``` @@ -917,6 +923,7 @@ POST /records/:id/migrate — commit a migration (change typeId + content tog ?hasAttachment= ?attachmentFileId= ?relatedTo= +?relatedToLabel= (only meaningful alongside ?relatedTo; narrows to that label) ?search= ?sort=createdAt|updatedAt|version ?direction=asc|desc @@ -927,6 +934,8 @@ POST /records/:id/migrate — commit a migration (change typeId + content tog `GET /records` covers all native field queries and is usable from a browser or simple HTTP client without a JSON body. `POST /records/query` is a superset — it accepts the full `Query` object as a JSON body and additionally supports `content` field filtering. A server that declares `contentFieldQuery: false` in discovery does not support the POST query endpoint. +**Filters gated by a capability fail loudly, not silently.** A `content` filter has no representation in `GET /records`' query params, and `search` behaves however the server does with an unsupported param — so `APIAdapter` checks `capabilities.contentFieldQuery`/`capabilities.fullTextSearch` before dispatching and throws `APIAdapterCapabilityError` locally, without sending a request, when the corresponding filter is used against a server that hasn't declared the capability. The alternative — degrading to an unfiltered (or partially filtered) result and returning it as if it were the requested query — is a superset silently presented as the filtered result, which is worse than an error for anything that trusts the filter (dedup checks, existence checks, selection-sensitive logic). + `PATCH /records/:id` accepts a partial content object. Omitted fields retain their current values. A field set to `null` is removed (RFC 7396 / JSON Merge Patch). Associations and permissions are managed via their own endpoints. **Optimistic concurrency:** `PATCH`, `DELETE`, `POST .../undelete`, `POST .../restore/:version`, and the association/permission endpoints below all accept an optional `If-Match` header: @@ -984,10 +993,12 @@ GET /records/:id/associations?kind=attachment GET /records/:id/associations?kind=relationship GET /records/:id/associations?label=avatar — filter by label across all kinds POST /records/:id/associations — add an association -DELETE /records/:id/associations — remove an association (by body) +POST /records/:id/associations/delete — remove an association (by body) ``` -`POST`/`DELETE` accept the same optional `If-Match` precondition described under [Records](#records). +Removing an association is a `POST` to a `/delete` sub-path, not a `DELETE` with a body — `DELETE` request bodies have no defined semantics (RFC 9110 §9.3.5: "has no generally defined semantics; ... might lead some implementations to reject the request"), and this protocol is meant to be implemented behind arbitrary proxies, gateways, and localhost setups that may drop or reject them. The discriminant (which association to remove) travels as a JSON body either way, so the endpoint is a `POST` like every other body-carrying mutation. + +Both endpoints accept the same optional `If-Match` precondition described under [Records](#records). Response shape is consistent regardless of kind: @@ -1034,7 +1045,7 @@ Authorization: Bearer `POST /attachments` requires the same authorization as creating an `_attachment@1` record: `401` for anonymous/missing tokens, `403` if the requester lacks a `create` grant on `_attachment@1`. A bytes upload is only meaningful as a precursor to metadata creation, so the two share one authorization requirement. -Returns `413 Request Entity Too Large` if the payload exceeds the server's configured limit (default 50 MB, controlled by `MAX_ATTACHMENT_BYTES`). +Returns `413 Request Entity Too Large` if the payload exceeds the server's configured limit (default 50 MB, controlled by `MAX_ATTACHMENT_BYTES`). The same limit is exposed ahead of time as `maxAttachmentBytes` in [discovery](#discovery), so clients can pre-check and surface it in UI rather than learning the ceiling only by uploading the whole payload and getting a 413 back. The SDK's `Stack.putAttachment()` and `ScopedStack.putAttachment()` perform both steps automatically. Direct HTTP callers must create the `_attachment@1` record separately if metadata is needed. diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 31ac7a6..441cd5e 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -72,6 +72,24 @@ export class APIAdapterConnectionError extends APIAdapterError { } } +/** + * Thrown locally — before any request is sent — when a query uses a filter + * the connected server has declared it doesn't support (`capabilities. + * contentFieldQuery` or `capabilities.fullTextSearch` is false). Servers + * without these capabilities have no endpoint that honors the filter at + * all, so silently sending it anyway would return an unfiltered superset + * presented as the filtered result (see #56) rather than erroring. + */ +export class APIAdapterCapabilityError extends APIAdapterError { + constructor( + public readonly capability: keyof AdapterCapabilities, + message: string, + ) { + super(message); + this.name = 'APIAdapterCapabilityError'; + } +} + // ------------------------------------------------------- // Discovery response shape // ------------------------------------------------------- @@ -160,7 +178,10 @@ const buildQueryParams = (query: StackQuery): URLSearchParams => { if (f.tags) for (const tag of f.tags) p.append('tag', tag); if (f.hasAttachment) p.set('hasAttachment', f.hasAttachment); if (f.attachmentFileId) p.set('attachmentFileId', f.attachmentFileId); - if (f.relatedTo) p.set('relatedTo', f.relatedTo.recordId); + if (f.relatedTo) { + p.set('relatedTo', f.relatedTo.recordId); + if (f.relatedTo.label) p.set('relatedToLabel', f.relatedTo.label); + } if (f.search) p.set('search', f.search); if (f.includeDeleted) p.set('includeDeleted', 'true'); if (query.sort?.field) p.set('sort', query.sort.field); @@ -396,6 +417,25 @@ export class APIAdapter implements StackAdapter { total: number | null; }; + // Fail loudly rather than silently widening the result set: a server + // that hasn't declared these capabilities has no endpoint that honors + // the corresponding filter, so sending it anyway would drop the filter + // without signal (see #56). + if (query.filter?.content && !this.capabilities.contentFieldQuery) { + throw new APIAdapterCapabilityError( + 'contentFieldQuery', + 'Query uses filter.content, but this server does not declare the contentFieldQuery ' + + 'capability — there is no endpoint that would honor it.', + ); + } + if (query.filter?.search && !this.capabilities.fullTextSearch) { + throw new APIAdapterCapabilityError( + 'fullTextSearch', + 'Query uses filter.search, but this server does not declare the fullTextSearch ' + + 'capability — there is no endpoint that would honor it.', + ); + } + let raw: Envelope; if (this.capabilities.contentFieldQuery) { // POST /records/query supports the full query shape including content field filters @@ -433,7 +473,9 @@ export class APIAdapter implements StackAdapter { association: Association, opts: { expectedVersion?: number } = {}, ): Promise { - await this.request('DELETE', `/records/${id}/associations`, association, { + // POST, not DELETE — a DELETE body has no defined semantics (RFC 9110 + // §9.3.5) and proxies/gateways are free to drop or reject it. See #56. + await this.request('POST', `/records/${id}/associations/delete`, association, { ifMatch: opts.expectedVersion, }); } diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 21c7b4b..08e0abc 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -4,6 +4,7 @@ import { APIAdapterAuthError, APIAdapterConnectionError, APIAdapterError, + APIAdapterCapabilityError, } from '../src/index.js'; import type { StackRecord, StackType, RecordVersion, Association } from '@haverstack/core'; import { @@ -31,6 +32,7 @@ const DISCOVERY = { fullTextSearch: true, contentFieldQuery: true, sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: 52428800, }, }; @@ -117,6 +119,7 @@ describe('open', () => { expect(adapter.capabilities.fullTextSearch).toBe(true); expect(adapter.capabilities.contentFieldQuery).toBe(true); expect(adapter.capabilities.sortableFields).toEqual(['createdAt', 'updatedAt', 'version']); + expect(adapter.capabilities.maxAttachmentBytes).toBe(52428800); }); test('populates ownerEntityId from discovery response', async () => { @@ -488,6 +491,64 @@ describe('queryRecords', () => { const [url] = mockFetch.mock.lastCall as [string]; expect(url).toContain('parentId=null'); }); + + test('GET params include relatedToLabel alongside relatedTo (#56)', async () => { + const limitedDiscovery = { + ...DISCOVERY, + capabilities: { ...DISCOVERY.capabilities, contentFieldQuery: false }, + }; + const adapter = await openAdapter(limitedDiscovery); + mockFetch.mockResolvedValueOnce(jsonResponse(queryEnvelope)); + await adapter.queryRecords({ filter: { relatedTo: { recordId: 'rec-1', label: 'author' } } }); + const [url] = mockFetch.mock.lastCall as [string]; + expect(url).toContain('relatedTo=rec-1'); + expect(url).toContain('relatedToLabel=author'); + }); + + test('GET params omit relatedToLabel when no label is given', async () => { + const limitedDiscovery = { + ...DISCOVERY, + capabilities: { ...DISCOVERY.capabilities, contentFieldQuery: false }, + }; + const adapter = await openAdapter(limitedDiscovery); + mockFetch.mockResolvedValueOnce(jsonResponse(queryEnvelope)); + await adapter.queryRecords({ filter: { relatedTo: { recordId: 'rec-1' } } }); + const [url] = mockFetch.mock.lastCall as [string]; + expect(url).toContain('relatedTo=rec-1'); + expect(url).not.toContain('relatedToLabel'); + }); + + test('throws APIAdapterCapabilityError for filter.content without contentFieldQuery (#56)', async () => { + const limitedDiscovery = { + ...DISCOVERY, + capabilities: { ...DISCOVERY.capabilities, contentFieldQuery: false }, + }; + const adapter = await openAdapter(limitedDiscovery); + await expect(adapter.queryRecords({ filter: { content: { slug: 'hello' } } })).rejects.toThrow( + APIAdapterCapabilityError, + ); + expect(mockFetch).toHaveBeenCalledTimes(1); // only the discovery call — no request sent + }); + + test('throws APIAdapterCapabilityError for filter.search without fullTextSearch (#56)', async () => { + const limitedDiscovery = { + ...DISCOVERY, + capabilities: { ...DISCOVERY.capabilities, fullTextSearch: false }, + }; + const adapter = await openAdapter(limitedDiscovery); + await expect(adapter.queryRecords({ filter: { search: 'hello' } })).rejects.toThrow( + APIAdapterCapabilityError, + ); + expect(mockFetch).toHaveBeenCalledTimes(1); // only the discovery call — no request sent + }); + + test('does not throw for filter.content when contentFieldQuery is true', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(queryEnvelope)); + await expect( + adapter.queryRecords({ filter: { content: { slug: 'hello' } } }), + ).resolves.toBeDefined(); + }); }); // ------------------------------------------------------- @@ -517,16 +578,26 @@ describe('associate', () => { }); describe('dissociate', () => { - test('sends DELETE /records/:id/associations', async () => { + // POST, not DELETE — a DELETE body has no defined wire semantics (#56). + test('sends POST /records/:id/associations/delete', async () => { const adapter = await openAdapter(); mockFetch.mockResolvedValueOnce(noContent()); const assoc: Association = { kind: 'tag', label: 'starred' }; await adapter.dissociate('rec-abc123', assoc); expect(mockFetch).toHaveBeenLastCalledWith( - `${BASE_URL}/records/rec-abc123/associations`, - expect.objectContaining({ method: 'DELETE' }), + `${BASE_URL}/records/rec-abc123/associations/delete`, + expect.objectContaining({ method: 'POST' }), ); }); + + test('sends the association as JSON body', async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(noContent()); + const assoc: Association = { kind: 'tag', label: 'starred' }; + await adapter.dissociate('rec-abc123', assoc); + const [, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual(assoc); + }); }); // ------------------------------------------------------- diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 4d4f441..a67d86e 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -39,6 +39,7 @@ const DISCOVERY = { fullTextSearch: true, contentFieldQuery: true, sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: 52428800, }, }; diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index ecf2258..3255231 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -203,9 +203,12 @@ export const associateFixtures: ConformanceFixture, unde export const dissociateFixtures: ConformanceFixture, undefined>[] = [ { name: 'dissociate-tag', - description: 'DELETE /records/:id/associations removes an association and bumps version.', - method: 'DELETE', - path: '/records/rec-1/associations', + description: + 'POST /records/:id/associations/delete removes an association and bumps version. POST, ' + + 'not DELETE — a DELETE request body has no defined semantics (RFC 9110 §9.3.5) and is a ' + + 'portability landmine for proxies/gateways that drop or reject it (#56).', + method: 'POST', + path: '/records/rec-1/associations/delete', requestBody: { kind: 'tag', label: 'starred' }, responseStatus: 204, }, diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 0ac4796..471d430 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -380,12 +380,31 @@ export class Stack implements StackClient { * Used to detect the stale-writer case in presentAtLatest(). */ private readonly maxDefinedVersion = new Map(); + /** + * In-memory cache of Types this instance has fetched or defined, keyed by + * versioned id. A Type's schema is immutable once defined — schemaHash + * only changes via a version bump, which is a different id and thus a + * different cache entry — so entries are never invalidated, only added + * (by getTypeCached() on first fetch, and by defineType() on write). + * listTypes() also refreshes it wholesale. This is what removes the + * GET /types/:id round trip that create()/update()/restoreVersion() would + * otherwise pay on every write for a value that cannot change. + */ + private readonly typeCache = new Map(); private constructor( private readonly adapter: StackAdapter, private readonly idTimestampSkewMsValue: number | null, ) {} + private async getTypeCached(id: TypeId): Promise { + const cached = this.typeCache.get(id); + if (cached) return cached; + const type = await this.adapter.getType(id); + if (type) this.typeCache.set(id, type); + return type; + } + /** * Create a Stack instance. Reads ownerEntityId and timezone from the adapter. */ @@ -480,7 +499,7 @@ export class Stack implements StackClient { if (parsed.version > priorMax) this.maxDefinedVersion.set(parsed.baseId, parsed.version); const schemaHash = await hashSchema(schema); - const existing = await this.adapter.getType(id); + const existing = await this.getTypeCached(id); if (existing) { if (existing.schemaHash === schemaHash) { @@ -507,15 +526,19 @@ export class Stack implements StackClient { }; await this.adapter.saveType(type); + this.typeCache.set(id, type); return type; } async getType(id: TypeId): Promise { - return this.adapter.getType(id); + return this.getTypeCached(id); } + /** Refreshes typeCache wholesale — the explicit way to see a rename made by another writer. */ async listTypes(): Promise { - return this.adapter.listTypes(); + const types = await this.adapter.listTypes(); + for (const type of types) this.typeCache.set(type.id, type); + return types; } /** @@ -523,7 +546,7 @@ export class Stack implements StackClient { * Useful for duck-typed consumption across types. */ async typeIsCompatible(typeId: TypeId, requiredSchema: TypeSchema): Promise { - const type = await this.adapter.getType(typeId); + const type = await this.getTypeCached(typeId); if (!type) return false; return isCompatible(type.schema, requiredSchema); } @@ -627,7 +650,7 @@ export class Stack implements StackClient { const migrateFn = this.resolveMigrationPath(typeId, latestId); if (!migrateFn) continue; - const latestType = await this.adapter.getType(latestId); + const latestType = await this.getTypeCached(latestId); if (!latestType) { throw new StackMigrationError(`migrateAll: target type "${latestId}" is not defined.`); } @@ -671,7 +694,7 @@ export class Stack implements StackClient { content: T, opts: CreateRecordOptions = {}, ): Promise { - const type = await this.adapter.getType(typeId); + const type = await this.getTypeCached(typeId); if (!type) { throw new Error(`Unknown type: "${typeId}". Call defineType() first.`); } @@ -795,7 +818,7 @@ export class Stack implements StackClient { } this.checkIfVersion(existing, opts.ifVersion); - const type = await this.adapter.getType(existing.typeId); + const type = await this.getTypeCached(existing.typeId); if (!type) { throw new Error(`Unknown type: "${existing.typeId}"`); } @@ -1051,7 +1074,7 @@ export class Stack implements StackClient { throw new StackNotFoundError(`Version ${version} not found for record "${id}"`); } - const type = await this.adapter.getType(target.typeId); + const type = await this.getTypeCached(target.typeId); if (!type) { throw new Error(`Unknown type: "${target.typeId}"`); } diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index beb96b3..ac361ba 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -27,6 +27,7 @@ export class MemoryAdapter implements StackAdapter { fullTextSearch: false, contentFieldQuery: false, sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: null, }; readonly ownerEntityId: string; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c721344..f743263 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -342,6 +342,13 @@ export type AdapterCapabilities = { fullTextSearch: boolean; contentFieldQuery: boolean; sortableFields: Array; + /** + * Maximum attachment upload size in bytes this adapter/server will + * accept, or `null` if unbounded. Lets apps pre-check and surface limits + * in UI before burning the upload, rather than learning the ceiling only + * from a 413 after sending the whole payload. + */ + maxAttachmentBytes: number | null; }; /** What a Stack can do, as seen by app and plugin code. */ diff --git a/packages/core/tests/combine.test.ts b/packages/core/tests/combine.test.ts index b54df0a..aed09f9 100644 --- a/packages/core/tests/combine.test.ts +++ b/packages/core/tests/combine.test.ts @@ -18,6 +18,7 @@ const capabilities: AdapterCapabilities = { fullTextSearch: false, contentFieldQuery: false, sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: null, }; /** Bare-minimum StackRecordAdapter — only what combineAdapters() touches. */ diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 1e72447..a8febe6 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach } from 'vitest'; +import { describe, test, expect, beforeEach, vi } from 'vitest'; import { Stack, StackValidationError, @@ -317,6 +317,59 @@ describe('create — client-supplied id', () => { }); }); +// ------------------------------------------------------- +// Type cache (#56) — create()/update()/etc. shouldn't pay a getType() +// round trip on every write for a value that can't change. +// ------------------------------------------------------- + +describe('type cache', () => { + // A type saved straight through the adapter, bypassing stack.defineType() + // (and its cache write) so the first getTypeCached() call is a genuine + // cache miss — the scenario a real app hits on first use of a type an + // earlier process already defined. + const COLD_TYPE_ID = 'com.example.test/cold@1'; + const seedColdType = async (): Promise => { + await adapter.saveType({ + id: COLD_TYPE_ID, + baseId: 'com.example.test/cold', + version: 1, + name: 'Cold', + schema: { text: { kind: 'text', required: true } }, + schemaHash: 'irrelevant-for-this-test', + createdAt: new Date(), + }); + }; + + test('create() x N against a not-yet-cached type calls adapter.getType() exactly once', async () => { + await seedColdType(); + const getTypeSpy = vi.spyOn(adapter, 'getType'); + + await stack.create(COLD_TYPE_ID, { text: 'one' }); + await stack.create(COLD_TYPE_ID, { text: 'two' }); + await stack.create(COLD_TYPE_ID, { text: 'three' }); + + expect(getTypeSpy).toHaveBeenCalledTimes(1); + }); + + test('defineType() populates the cache — a later create() never calls adapter.getType()', async () => { + await stack.defineType(NOTE_V2, 'Note', { text: { kind: 'text', required: true } }); + const getTypeSpy = vi.spyOn(adapter, 'getType'); + + await stack.create(NOTE_V2, { text: 'hello' }); + + expect(getTypeSpy).not.toHaveBeenCalled(); + }); + + test('update() reuses the type cached by an earlier create() — no getType() round trip', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + const getTypeSpy = vi.spyOn(adapter, 'getType'); + + await stack.update(record.id, { text: 'updated' }); + + expect(getTypeSpy).not.toHaveBeenCalled(); + }); +}); + // ------------------------------------------------------- // update — merge patch // ------------------------------------------------------- diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index e1c2bf1..55d690b 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -126,6 +126,7 @@ export class NativeSQLiteRecordAdapter implements StackRecordAdapter { fullTextSearch: true, contentFieldQuery: true, sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: null, }; ownerEntityId!: string; diff --git a/packages/record-adapter-sqljs/src/index.ts b/packages/record-adapter-sqljs/src/index.ts index d39a22a..f55562a 100644 --- a/packages/record-adapter-sqljs/src/index.ts +++ b/packages/record-adapter-sqljs/src/index.ts @@ -133,6 +133,7 @@ export class SQLiteRecordAdapter implements StackRecordAdapter { fullTextSearch: true, contentFieldQuery: true, sortableFields: ['createdAt', 'updatedAt', 'version'], + maxAttachmentBytes: null, }; ownerEntityId!: string;