From 5b3e81f08bc88bcd2b19b53ed5ad2dbc4903617d Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 8 Oct 2026 14:03:55 +0100 Subject: [PATCH 01/13] DEV-2201 Refactor data source security documentation around verified behavior --- .../fliplet-data-sources/SKILL.md | 5 +- docs/.well-known/agent-skills/index.json | 2 +- docs/.well-known/llms-full.txt | 1711 ++++++----------- docs/.well-known/llms.txt | 5 +- docs/API/datasources/security-examples.md | 234 +++ docs/API/datasources/security-rules.md | 183 ++ docs/API/datasources/testing-security.md | 84 + docs/API/fliplet-datasources.md | 9 +- docs/Data-source-security.md | 692 +------ .../datasource-security-docs.test.mjs | 74 + 10 files changed, 1278 insertions(+), 1721 deletions(-) create mode 100644 docs/API/datasources/security-examples.md create mode 100644 docs/API/datasources/security-rules.md create mode 100644 docs/API/datasources/testing-security.md create mode 100644 docs/bin/__tests__/datasource-security-docs.test.mjs diff --git a/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md b/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md index a0b833af..31c0d732 100644 --- a/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md +++ b/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md @@ -11,11 +11,14 @@ Data sources JavaScript API and security model: query, insert, update, delete re - [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. - [Data Sources query operators](https://developers.fliplet.com/API/datasources/query-operators.md): Filter Data Source queries with MongoDB/Sift operators ($eq, $gt, $in, $regex, $and, $or) inside connection.find() where clauses. +- [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, private, profile and role-based access rules with data shapes and allowed and denied SDK operations. +- [Data source security rule reference](https://developers.fliplet.com/API/datasources/security-rules.md): Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior. +- [Testing data source security in preview](https://developers.fliplet.com/API/datasources/testing-security.md): Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled. - [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. - [Fliplet.DataSources](https://developers.fliplet.com/API/fliplet-datasources.md): Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. - [Fliplet infrastructure data flow](https://developers.fliplet.com/Data-flow.md): Architecture diagram showing how data flows between Fliplet Studio, Fliplet servers, app clients, and connected data sources. - [Data Source Hooks](https://developers.fliplet.com/Data-Source-Hooks.md): Trigger emails, SMS, push notifications, web requests, or column operations on Data Source insert/update/beforeSave/beforeQuery using Sift.js match conditions. -- [Securing Fliplet data sources](https://developers.fliplet.com/Data-source-security.md): Secure your Data Sources with access rules, data requirements, and custom JavaScript security rules. +- [Securing Fliplet data sources](https://developers.fliplet.com/Data-source-security.md): Configure data source access rules and verify allowed and denied operations as authenticated app users. - [Securing Fliplet files and folders](https://developers.fliplet.com/File-security.md): Secure files and folders in your Fliplet apps with access rules and custom JavaScript security rules. ## How to load full content diff --git a/docs/.well-known/agent-skills/index.json b/docs/.well-known/agent-skills/index.json index 18558695..b2762963 100644 --- a/docs/.well-known/agent-skills/index.json +++ b/docs/.well-known/agent-skills/index.json @@ -11,7 +11,7 @@ "js-api", "security" ], - "sha256": "aee16d3438981d6d8ebf2403a42fc3603365474978d00677946795d45836ab6f" + "sha256": "7a7765e27d4c2ac92a7bc35897860f4c9c25d2128b426fbccbc5dc886ea07efb" }, { "name": "fliplet-app-actions", diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 8eb11043..bcfbc4b7 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -3928,15 +3928,27 @@ URL: https://developers.fliplet.com/API/core/ai.md Generate text and images, transcribe audio and create embeddings through Fliplet. `Fliplet.AI` is preloaded in apps and uses Fliplet access and AI credits without a customer-supplied OpenAI or Gemini key. +`Fliplet.AI` runs models through Fliplet’s server API and requires an internet connection. It does not provide an on-device model or offline inference. + ## Table of contents - [AI methods](#ai-methods) - [AI models](#ai-models) - [First request](#first-request) - [API reference](#api-reference) + - [Initialization](#initialization) + - [Instance methods](#instance-methods) + - [Completions](#flipletaicreatecompletion) + - [Streaming](#streaming-with-createcompletion) + - [Images](#flipletaigenerateimage) + - [Transcription](#flipletaitranscribeaudio) + - [Embeddings](#flipletaicreateembedding) - [Rate limiting](#rate-limiting) - [Error handling](#error-handling) +- [Usage examples](#usage-examples) - [Model catalog](#model-catalog) +- [Fallback defaults](#fallback-defaults) +- [Deprecated and retired models](#deprecated-and-retired-models) - [Related guides](#related-guides) @@ -4052,9 +4064,14 @@ For streaming, set `stream: true` in the constructor and attach `.stream(onChunk ### `Fliplet.AI.createCompletion()` -`Fliplet.AI.createCompletion(options: Object): Promise` +`Fliplet.AI.createCompletion(options: Object): Promise` -Send the payload required by the selected model. Non-streaming calls resolve to the provider's response object. With `stream: true`, the returned Promise also exposes `.stream()`, `.cancel()` and `.guid`; see [streaming](#streaming-with-createcompletion). +Send the payload required by the selected model. The return value depends on `stream`: + +| Mode | Returned request | Resolved value | +| --- | --- | --- | +| Buffered (`stream` omitted or `false`) | Promise | The provider response object for the selected format. | +| Streaming (`stream: true`) | Promise with `.stream(onChunk)`, `.cancel()` and `.guid` | `undefined` on normal completion, or `{ cancelled: true }` on cancellation. Accumulate reply text from callbacks; see the [streaming contract](#streaming-with-createcompletion). | | Field | Type | Required or behavior | | --- | --- | --- | @@ -4074,7 +4091,7 @@ Use `messages: [{ role, content }]` and read `choices[0].message.content` for a The [first request](#first-request) is a complete Chat Completions example. See [OpenAI's Chat Completions reference](https://platform.openai.com/docs/api-reference/chat/create) for message content, supported roles and model-specific fields. -### Using the Responses API +#### Using the Responses API Set `useResponses: true` and provide `input`. The returned JSON contains typed `output` items, not a `choices` array. Extract text from message content with `type: 'output_text'`; other output items can contain reasoning, tool calls or refusals. @@ -4107,7 +4124,7 @@ summarizeWithResponses(); For app-managed conversation context, `input` can also be an array such as `[{ role: 'user', content: 'Hello' }, { role: 'assistant', content: 'Hello! How can I help?' }, { role: 'user', content: 'Suggest a meeting agenda.' }]`. Preserve model-required tool items when using tools. Do not assume every Responses provider feature, including retrieval, background execution or provider-stored conversations, has a matching Fliplet integration. See the [Responses schema](https://platform.openai.com/docs/api-reference/responses/create) and [chatbot guide](/API/core/ai/chatbot). -### Using Gemini models +#### Using Gemini models Use `createCompletion({ model, contents })`. `contents` uses `{ role, parts }`; for a text request, each part is `{ text }`. Read `candidates[0].content.parts` and collect its text parts. @@ -4148,8 +4165,12 @@ Fliplet consumes `useResponses` for OpenAI routing and `stream` for Gemini routi ### Streaming with `createCompletion()` +#### Dependency and transport + Streaming requires the `fliplet-socket` dependency on the app or screen. If it is missing, `createCompletion()` throws synchronously. Fliplet sends the completion request over HTTP and delivers provider chunks through Fliplet sockets; the browser does not receive an SSE response from the app endpoint. +#### Chunk payloads + Register `.stream(onChunk)` immediately. The callback receives nonterminal payloads, including keepalive events. Ignore chunks without the expected content. Build the displayed reply in app state: | Request format | Text in a callback payload | @@ -4158,8 +4179,18 @@ Register `.stream(onChunk)` immediately. The callback receives nonterminal paylo | Responses | `chunk.delta` when `chunk.type === 'response.output_text.delta'` | | Gemini | Text parts of `chunk.candidates[0].content.parts` | +#### Completion and cancellation + Normal completion resolves the Promise with `undefined`. Cancellation resolves it with `{ cancelled: true }` after the socket cancellation event. The completion callback does not receive the assembled answer, including for Responses. Errors reject with the socket error payload, which need not be an `Error` instance. +To cancel, retain the request and call `await request.cancel()`, or use `Fliplet.AI.cancel(request.guid)`. Cancellation asks Fliplet to abort the active provider stream. A cancellation acknowledgement may report that the signal was broadcast; it is not proof that no generation or charge occurred. Keep partial text visibly interrupted and handle failure/retry in the app. The [chatbot guide](/API/core/ai/chatbot#build-a-conversation) covers progressive display, safe formatting and UI state. + +#### Console streaming example + +This plain JavaScript example uses Chat Completions and logs accumulated text as chunks arrive. It demonstrates the API lifecycle; it has no on-screen renderer. Load `fliplet-socket` and run after Fliplet is ready. + +For an on-screen reply, use the [chatbot rendering workflow](/API/core/ai/chatbot#build-a-conversation). Framework callbacks must update the state used by their renderer; Vue apps should follow [asynchronous reactive-state updates](/API/v3/frameworks/vue#updating-reactive-state-asynchronously). + ```javascript async function streamReply() { let text = ''; @@ -4174,7 +4205,7 @@ async function streamReply() { const delta = choice && choice.delta && choice.delta.content; if (typeof delta === 'string') { text += delta; - console.log(text); // textContent is a safe plain-text display option. + console.log(text); // Incremental console output, before the request settles. } }); const completion = await request; @@ -4188,7 +4219,7 @@ async function streamReply() { streamReply(); ``` -To cancel, retain the request and call `await request.cancel()`, or use `Fliplet.AI.cancel(request.guid)`. Cancellation asks Fliplet to abort the active provider stream. A cancellation acknowledgement may report that the signal was broadcast; it is not proof that no generation or charge occurred. Keep partial text visibly interrupted and handle failure/retry in the app. The [chatbot guide](/API/core/ai/chatbot#build-a-conversation) covers progressive display, safe formatting and UI state. +The console shows growing text while the request is pending, then logs the completed accumulated reply. `await request` supplies completion status, not that text. ### `Fliplet.AI.generateImage()` @@ -4357,103 +4388,6 @@ Catch asynchronous failures with `try...catch` or `.catch()`. Completion setup c Show a useful error and retain the user's input. Validate the expected response shape before treating a request as successful. Retry transient connection or rate-limit failures with a delay; correct invalid payloads, unavailable models, access or credit problems before retrying. A timed-out request is not proof that no provider work or charge occurred. -## Model catalog - -The tables list model IDs configured by Fliplet. Deprecated and retired entries appear in [Deprecated and retired models](#deprecated-and-retired-models). Configuration and provider routing do not guarantee that a model remains available from its provider, or that every model supports every request format. Check the linked provider reference for endpoint compatibility and parameters before using a model. - -OpenAI text models use [createCompletion()](/API/core/ai#flipletaicreatecompletion) or [POST /v1/apps/:app/ai/completions](/REST-API/fliplet-ai#completions). Chat Completions uses `messages`; Responses uses `input` and `useResponses: true`. Models marked Responses require that format. Other models must use a format supported by their individual [OpenAI model reference](https://developers.openai.com/api/docs/models). - -### OpenAI text models - -Numbered GPT models are ordered by version (highest first), then Astra, Sol, Terra and Luna within a version, and Pro, normal, Mini and Nano within a model family. This order helps you find models; it does not determine which is most suitable for your task. The 4o family is listed separately. - -| Model ID | Availability or request-format note | -| --- | --- | -| `gpt-6.1-sol` | Chat Completions or Responses. | -| `gpt-6-astra` | Chat Completions or Responses. | -| `gpt-6-sol` | Chat Completions or Responses. | -| `gpt-6-luna` | Chat Completions or Responses. | -| `gpt-5.6-sol` | Chat Completions or Responses. | -| `gpt-5.6-terra` | Chat Completions or Responses. | -| `gpt-5.6-luna` | Chat Completions or Responses. | -| `gpt-5.5-pro` | Responses format; provider does not support streaming. | -| `gpt-5.5` | Chat Completions or Responses. | -| `gpt-5.4-pro` | Responses format. | -| `gpt-5.4` | Chat Completions or Responses. | -| `gpt-5.4-mini` | Chat Completions or Responses. | -| `gpt-5.2-pro` | Responses format. | -| `gpt-5.2` | Chat Completions or Responses. | -| `gpt-4.1` | Chat Completions or Responses. | -| `gpt-4.1-mini` | Chat Completions or Responses. | - -#### GPT-4o family - -| Model ID | Availability or request-format note | -| --- | --- | -| `gpt-4o` | Chat Completions or Responses. | -| `gpt-4o-mini` | Chat Completions or Responses. | - - -Provider selection facts for text-only requests: - -- OpenAI positions [GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra) for complex reasoning and coding; Standard text input/output rates are $10/$50 per million tokens. -- [GPT-6.1 Sol](https://developers.openai.com/api/docs/models/gpt-6.1-sol) balances capability and cost; Standard text input/output rates are $2/$10 per million tokens. Chat Completions supports text generation without tool calling; use Responses for tools. -- [GPT-6 Luna](https://developers.openai.com/api/docs/models/gpt-6-luna) targets focused, high-volume workloads; Standard text input/output rates are $0.10/$0.50 per million tokens. For Chat Completions function calling, the provider requires `reasoning_effort: 'none'`; Responses uses its own reasoning settings. - -These three models accept text and image input and produce text output. Their provider context window is 1,050,000 tokens; prompts above 272,000 input tokens have different pricing. The 272,000-token warning in Fliplet Chat Completions does not truncate or reject input. The rates above describe provider costs, not Fliplet charges, and exclude caching, regional premiums and tools. They are facts for a selection decision, not comparative benchmark results. See [OpenAI pricing](https://developers.openai.com/api/docs/pricing) for conditions. Capability and cost comparisons for other configured models are not summarized here; preserve a supported user pin and verify the model-specific reference before changing it. - -### Gemini text models - -Gemini text models use [createCompletion({ model, contents })](/API/core/ai#using-gemini-models) or the [app REST completion endpoint](/REST-API/fliplet-ai#completions) with `contents`. Use the [Gemini model reference](https://ai.google.dev/gemini-api/docs/models) for payloads and capabilities. - -| Model ID | Availability note | -| --- | --- | -| `gemini-3.5-flash` | See provider model reference. | -| `gemini-3.1-flash-lite` | Earliest announced shutdown May 7, 2027; check provider notice before migration. | -| `gemini-2.5-pro` | Provider restricts access to accounts with prior active usage. | -| `gemini-2.5-flash` | Provider restricts access to accounts with prior active usage. | -| `gemini-2.5-flash-lite` | Provider restricts access to accounts with prior active usage. | - -For lightweight extraction and high-frequency text tasks, Google describes [Gemini 3.1 Flash-Lite](https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite) as a low-latency, cost-effective model with text, image, video, audio and PDF inputs and text output. [Gemini 3.5 Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash) supports multi-step reasoning and coding workflows. These are provider capabilities; a required upload, tool execution or media flow still needs a verified Fliplet integration. See [Gemini pricing](https://ai.google.dev/gemini-api/docs/pricing) for provider costs. - -See the [Gemini changelog](https://ai.google.dev/gemini-api/docs/changelog) and [Gemini deprecations](https://ai.google.dev/gemini-api/docs/deprecations) for retirement and access restrictions. - -### Image models - -OpenAI image models use [generateImage()](/API/core/ai#flipletaigenerateimage) or [POST /v1/apps/:app/ai/image](/REST-API/fliplet-ai#images). Gemini image IDs are also configured, but use `createCompletion()` with Gemini `contents` and `generationConfig`, as shown in the [Fliplet Gemini image example](/API/core/ai#gemini-image-generation) and [provider image reference](https://ai.google.dev/gemini-api/docs/image-generation). Read image data from `candidates[0].content.parts` entries with `inlineData`. OpenAI image response handling does not apply. - -| Model ID | Availability note | -| --- | --- | -| `gpt-image-2` | OpenAI image model. | -| `gemini-3.1-flash-image` | See provider image reference. | -| `gemini-3-pro-image` | See provider image reference. | - -`dall-e-2` and `dall-e-3` are legacy IDs. Fliplet replaces either ID with `gpt-image-2`, so specifying a DALL-E ID does not pin the requested model. - -### Transcription models - -[transcribeAudio()](/API/core/ai#flipletaitranscribeaudio) and [POST /v1/apps/:app/ai/audio](/REST-API/fliplet-ai#audio-transcription) accept only the three IDs below. Newer provider transcription IDs are not accepted by these Fliplet methods until Fliplet adds support. - -| Model ID | Availability note | -| --- | --- | -| `gpt-4o-mini-transcribe` | Deprecated; retirement scheduled for February 26, 2027. | -| `gpt-4o-transcribe` | Deprecated; retirement scheduled for February 26, 2027. | -| `whisper-1` | Deprecated; retirement scheduled for February 26, 2027. | - -All three accepted transcription models have announced retirement dates. Provider replacements are not yet accepted by Fliplet's transcription allowlist. See [OpenAI deprecations](https://developers.openai.com/api/docs/deprecations) and test the app's [upload/recording workflow](/API/core/ai/audio-transcription) before changing its model. - -### Embedding models - -Embedding models use [createEmbedding()](/API/core/ai#flipletaicreateembedding) or [POST /v1/apps/:app/ai/embeddings](/REST-API/fliplet-ai#embeddings). See the [OpenAI embedding reference](https://developers.openai.com/api/docs/guides/embeddings) for input limits and dimensions. - -- `text-embedding-3-small` -- `text-embedding-3-large` -- `text-embedding-ada-002` - -OpenAI Standard embedding rates are $0.02 per million input tokens for [text-embedding-3-small](https://developers.openai.com/api/docs/models/text-embedding-3-small) and $0.13 for [text-embedding-3-large](https://developers.openai.com/api/docs/models/text-embedding-3-large). These are provider rates, not Fliplet charges. Their default vector dimensions are 1,536 and 3,072 respectively; `dimensions` can reduce the output length. - -Keep the same embedding model and dimensions when comparing stored vectors with new queries. Changing models can require regenerating stored embeddings. - ## Usage examples The text examples below select a model explicitly. See the API reference for [image generation](#flipletaigenerateimage), [audio recording and transcription](#flipletaitranscribeaudio), and [embeddings](#flipletaicreateembedding). @@ -4512,368 +4446,59 @@ For single-turn tasks where conversation history is not needed between requests, ### Browser recording example -Add these controls to an app screen, then add the script below. The app owns the recording UI, device choice, duration, and where to insert the returned text; `Fliplet.AI` only uploads audio for transcription. - -```html - - -

-

-```
-
-```javascript
-const recordButton = document.getElementById('dictation-record');
-const cancelButton = document.getElementById('dictation-cancel');
-const statusElement = document.getElementById('dictation-status');
-const transcriptElement = document.getElementById('dictation-transcript');
-const allowedMimeTypes = ['audio/webm', 'audio/mp4', 'audio/mpeg', 'audio/wav', 'audio/ogg'];
-const recorderMimeTypes = [
-  'audio/webm;codecs=opus',
-  'audio/mp4;codecs=mp4a.40.2',
-  'audio/mp4'
-];
-const filenameByMimeType = {
-  'audio/webm': 'dictation.webm',
-  'audio/mp4': 'dictation.mp4',
-  'audio/mpeg': 'dictation.mp3',
-  'audio/wav': 'dictation.wav',
-  'audio/ogg': 'dictation.ogg'
-};
-const maxRecordingMs = 5 * 60 * 1000; // Example UI cap; choose a duration appropriate to the app.
-
-let stream;
-let recorder;
-let chunks = [];
-let phase = 'idle';
-let cancelled = false;
-let stopPromise;
-let recordingTimer;
-let abortController;
-
-function baseMimeType(mimeType) {
-  return (mimeType || '').split(';', 1)[0].trim().toLowerCase();
-}
-
-function setStatus(message) {
-  statusElement.textContent = message;
-}
-
-function clearRecordingTimer() {
-  window.clearTimeout(recordingTimer);
-  recordingTimer = undefined;
-}
-
-function releaseMicrophone() {
-  if (stream) {
-    stream.getTracks().forEach(function(track) {
-      track.stop();
-    });
-  }
+For microphone capture, final audio chunks, cleanup and transcription, use the complete [recording and upload example](/API/core/ai/audio-transcription#complete-recording-and-upload-example). The [method reference](#flipletaitranscribeaudio) shows the call for an existing Blob or File.
 
-  stream = undefined;
-}
-
-function updateControls() {
-  recordButton.disabled = phase !== 'idle' && phase !== 'recording';
-  recordButton.textContent = phase === 'recording' ? 'Stop and transcribe' : 'Start recording';
-  cancelButton.disabled = phase === 'idle';
-}
-
-function recorderOptions() {
-  if (!MediaRecorder.isTypeSupported) {
-    return undefined;
-  }
-
-  const mimeType = recorderMimeTypes.find(function(candidate) {
-    return MediaRecorder.isTypeSupported(candidate);
-  });
-
-  return mimeType ? { mimeType: mimeType } : undefined;
-}
-
-function recordedBlob() {
-  const mimeType = baseMimeType(recorder.mimeType || (chunks[0] && chunks[0].type));
-
-  return new Blob(chunks, { type: mimeType });
-}
-
-function stopRecorder() {
-  if (stopPromise) {
-    return stopPromise;
-  }
-
-  if (!recorder) {
-    return Promise.reject(new Error('There is no recorder to stop.'));
-  }
-
-  if (recorder.state === 'inactive') {
-    return Promise.resolve(recordedBlob());
-  }
-
-  stopPromise = new Promise(function(resolve, reject) {
-    recorder.addEventListener('stop', function() {
-      resolve(recordedBlob());
-    }, { once: true });
-    recorder.addEventListener('error', function(event) {
-      reject(event.error || new Error('The recorder failed.'));
-    }, { once: true });
-    recorder.stop(); // Flushes the final dataavailable chunk before stop.
-  });
-
-  return stopPromise;
-}
-
-function reset() {
-  clearRecordingTimer();
-  releaseMicrophone();
-  recorder = undefined;
-  chunks = [];
-  stopPromise = undefined;
-  abortController = undefined;
-  phase = 'idle';
-  updateControls();
-}
-
-function showError(error) {
-  if (error.name === 'NotAllowedError') {
-    setStatus('Microphone permission was denied.');
-  } else if (error.name === 'NotFoundError') {
-    setStatus('No microphone is available.');
-  } else if (error.name === 'NotReadableError') {
-    setStatus('The microphone is already in use or cannot be read.');
-  } else if (error.name === 'AbortError') {
-    setStatus('Transcription cancelled.');
-  } else if (error.name === 'TimeoutError') {
-    setStatus('Transcription timed out after two minutes.');
-  } else if (error.name === 'TypeError') {
-    setStatus('Invalid audio or transcription options.');
-  } else if (error.status === 400) {
-    setStatus('The audio upload was invalid.');
-  } else if (error.status === 401 || error.status === 403) {
-    setStatus('You are not allowed to transcribe audio in this app.');
-  } else if (error.status === 402) {
-    setStatus('This organization has insufficient AI credits.');
-  } else if (error.status === 413) {
-    setStatus('The audio file is larger than 25 MiB.');
-  } else if (error.status === 415) {
-    setStatus('This browser produced an unsupported audio format.');
-  } else if (error.status === 429) {
-    setStatus('Too many transcription requests. Wait and try again.');
-  } else if (error.message) {
-    setStatus(error.message);
-  } else {
-    setStatus('The transcription request failed. Check the connection and try again.');
-  }
-}
-
-async function startRecording() {
-  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) {
-    setStatus('This browser cannot record audio.');
-    return;
-  }
-
-  cancelled = false;
-  phase = 'preparing';
-  transcriptElement.textContent = '';
-  setStatus('Requesting microphone permission…');
-  updateControls();
-
-  try {
-    stream = await navigator.mediaDevices.getUserMedia({ audio: true });
-
-    if (cancelled) {
-      setStatus('Recording cancelled.');
-      reset();
-      return;
-    }
-
-    const options = recorderOptions();
-
-    // If no preferred MIME type is supported, let the browser choose one.
-    recorder = options ? new MediaRecorder(stream, options) : new MediaRecorder(stream);
-    chunks = [];
-    recorder.addEventListener('dataavailable', function(event) {
-      if (event.data && event.data.size > 0) {
-        chunks.push(event.data);
-      }
-    });
-    recorder.addEventListener('error', function(event) {
-      // stopRecorder() owns errors once stopping starts. During active
-      // recording there is no stop promise to clean up the microphone.
-      if (phase === 'recording') {
-        showError(event.error || new Error('The recorder failed.'));
-        reset();
-      }
-    });
-    recorder.start();
-    phase = 'recording';
-    recordingTimer = window.setTimeout(stopAndTranscribe, maxRecordingMs);
-    setStatus('Recording. It will stop after five minutes.');
-  } catch (error) {
-    showError(error);
-    reset();
-  }
-
-  updateControls();
-}
-
-async function stopAndTranscribe() {
-  if (phase !== 'recording') {
-    return;
-  }
-
-  phase = 'stopping';
-  clearRecordingTimer();
-  updateControls();
-
-  try {
-    const audio = await stopRecorder();
-
-    // The completed Blob no longer needs the microphone. Do not hold it open
-    // while a transcription request can take up to two minutes.
-    releaseMicrophone();
-
-    if (cancelled) {
-      setStatus('Recording cancelled.');
-      return;
-    }
-
-    const mimeType = baseMimeType(audio.type);
-    if (!allowedMimeTypes.includes(mimeType)) {
-      throw new Error('The recorder produced an unsupported MIME type: ' + (mimeType || 'none'));
-    }
+## Model catalog
 
-    phase = 'transcribing';
-    abortController = typeof AbortController === 'function' ? new AbortController() : undefined;
-    const options = {
-      filename: filenameByMimeType[mimeType],
-      model: 'gpt-4o-mini-transcribe', // Explicit example selection; see the transcription model catalog.
-      timeout: 120000
-    };
+[AI models available through Fliplet](/API/core/ai/models) owns the configured IDs, compatibility, provider facts and retirement notices. Select a model compatible with the method and request format.
 
-    if (abortController) {
-      options.signal = abortController.signal;
-    }
+### OpenAI text models
 
-    setStatus('Transcribing…');
-    updateControls();
-    const result = await Fliplet.AI.transcribeAudio(audio, options);
+See [OpenAI text models](/API/core/ai/models#openai-text-models) for IDs and request-format compatibility.
 
-    // A browser without AbortController cannot stop the upload. Ignore its late result.
-    if (!cancelled) {
-      transcriptElement.textContent = result.text;
-      setStatus('Transcription complete.');
-    }
-  } catch (error) {
-    if (cancelled && error.name === 'AbortError') {
-      setStatus('Transcription cancelled.');
-    } else {
-      showError(error);
-    }
-  } finally {
-    reset();
-  }
-}
+#### GPT-4o family
 
-async function cancelDictation() {
-  cancelled = true;
-  clearRecordingTimer();
+The [OpenAI catalog](/API/core/ai/models#gpt-4o-family) includes the GPT-4o family.
 
-  if (phase === 'preparing') {
-    setStatus('Cancelling microphone request…');
-    return;
-  }
+### Gemini text models
 
-  if (phase === 'recording' || phase === 'stopping') {
-    setStatus('Cancelling recording…');
+See [Gemini text models](/API/core/ai/models#gemini-text-models). These use Gemini `contents` and response handling.
 
-    if (phase === 'recording') {
-      stopAndTranscribe();
-    }
+### Image models
 
-    return;
-  }
+See [image models and legacy ID remapping](/API/core/ai/models#image-models), including the different OpenAI and Gemini method paths.
 
-  if (phase === 'transcribing') {
-    if (abortController) {
-      abortController.abort();
-      setStatus('Cancelling transcription…');
-    } else {
-      setStatus('Upload cannot be aborted in this browser; its result will be ignored.');
-    }
-  }
-}
+### Transcription models
 
-recordButton.addEventListener('click', function() {
-  if (phase === 'recording') {
-    stopAndTranscribe();
-  } else if (phase === 'idle') {
-    startRecording();
-  }
-});
-cancelButton.addEventListener('click', cancelDictation);
-updateControls();
-```
+See [accepted transcription models](/API/core/ai/models#transcription-models) and their availability notices.
 
-Before provider handoff, cancellation stops processing, so no provider call or charge occurs. After handoff, cancellation stops the caller waiting for a result. Fliplet lets the provider request continue; if it completes successfully, usage is metered and the organization may be charged.
+### Embedding models
 
+See [embedding models and vector compatibility](/API/core/ai/models#embedding-models).
 
 ## Fallback defaults
 
-These defaults apply only when `model` is omitted. They are not recommendations for new features.
-
-The following defaults reflect the JavaScript wrapper and REST handlers. JavaScript inserts its own completion fallback before making the REST request, so that request does not inherit the REST completion default.
-
-| Operation | JavaScript fallback when `model` is omitted | Direct app REST fallback when `model` is omitted |
-|---|---|---|
-| Chat: `Fliplet.AI()` / `ask()` and `createCompletion({ messages })`; `POST /v1/apps/:app/ai/completions` | `gpt-3.5-turbo` | `gpt-4o-mini` |
-| Responses: `createCompletion({ input, useResponses: true })`; same REST endpoint | `gpt-3.5-turbo` (incompatible with Responses; specify a compatible model) | `gpt-4o-mini` |
-| Legacy non-streaming prompt: `createCompletion({ prompt })`; same REST endpoint | `gpt-3.5-turbo` (not a legacy prompt model) | `gpt-3.5-turbo-instruct` (retired) |
-| Images: `generateImage()`; `POST /v1/apps/:app/ai/image` | `gpt-image-2` through the REST handler | `gpt-image-2` |
-| Audio transcription: `transcribeAudio()`; `POST /v1/apps/:app/ai/audio` | `gpt-4o-mini-transcribe` through the REST handler | `gpt-4o-mini-transcribe` |
-| Embeddings: `createEmbedding()`; `POST /v1/apps/:app/ai/embeddings` | `text-embedding-ada-002` through the REST handler | `text-embedding-ada-002` |
-
-Gemini requests require an explicit Gemini model ID and a Gemini payload. OpenAI retired `gpt-3.5-turbo-instruct` on September 28, 2026; new text features use `messages` or Responses `input` instead of the legacy `prompt` format. `gpt-3.5-turbo` is scheduled for retirement on October 23, 2026. See [OpenAI deprecations](https://developers.openai.com/api/docs/deprecations).
+Omitting `model` uses a fallback, not a suitability recommendation. The [fallback table](/API/core/ai/models#fallback-defaults) gives the JavaScript and direct REST values; JavaScript completions insert their own fallback before calling REST.
 
 ## Deprecated and retired models
 
-These IDs remain listed in Fliplet configuration or historical JavaScript metadata; their presence does not establish provider availability. Do not select them for new features. Check the provider notices for affected IDs and retirement dates. Transcription IDs remain in their category because they are the only IDs accepted by Fliplet, with deprecation clearly marked.
+See [deprecated and retired models](/API/core/ai/models#deprecated-and-retired-models) before selecting or migrating a model. A historical configuration entry does not establish provider availability.
 
 ### Deprecated OpenAI text models
 
-| Model ID | Availability or request-format note |
-| --- | --- |
-| `gpt-5.4-nano` | Deprecated; retirement April 1, 2027. Chat Completions or Responses. |
-| `gpt-5.3-codex` | Deprecated; retirement April 1, 2027. Responses format. |
-| `gpt-5.1` | Deprecated; retirement April 1, 2027. Chat Completions or Responses. |
-| `gpt-5-pro` | Responses format. Deprecated; see the snapshot retirement notice below. |
-| `gpt-5` | Chat Completions or Responses. Deprecated; see the snapshot retirement notice below. |
-| `gpt-5-mini` | Chat Completions or Responses. Deprecated; see the snapshot retirement notice below. |
-| `gpt-5-nano` | Chat Completions or Responses. Deprecated; see the snapshot retirement notice below. |
-
-OpenAI announced deprecation of GPT-5.1, GPT-5.3-Codex and GPT-5.4-Nano on October 1, 2026. GPT-5, GPT-5 Mini, GPT-5 Nano and GPT-5 Pro are also deprecated. The affected dated snapshots of GPT-5, GPT-5 Mini, GPT-5 Nano and GPT-5 Pro are scheduled for retirement on December 11, 2026; check the [OpenAI deprecation list](https://developers.openai.com/api/docs/deprecations) for exact affected IDs.
+See [OpenAI retirement notices](/API/core/ai/models#deprecated-openai-text-models).
 
 ### Historical Gemini text models
 
-| Model ID | Availability note |
-| --- | --- |
-| `gemini-2.0-flash-thinking` | Historical configuration entry; no current provider model with this ID. Do not select. |
-| `gemini-2.0-flash` | Legacy 2.0 family retired June 1, 2026; do not select. |
-| `gemini-1.5-pro` | Retired September 29, 2025; do not select. |
-| `gemini-1.5-flash-8b` | Retired September 29, 2025; do not select. |
-| `gemini-1.5-flash` | Retired September 29, 2025; do not select. |
+See [historical Gemini IDs](/API/core/ai/models#historical-gemini-text-models).
 
 ### Retired image models
 
-| Model ID | Availability note |
-| --- | --- |
-| `gemini-2.5-flash-image` | Retired October 2, 2026; do not select. |
+See [retired image IDs](/API/core/ai/models#retired-image-models).
 
 ### Historical IDs and additional provider models
 
-The JavaScript `AVAILABLE_MODELS` metadata is not a complete availability catalog or a validation list. In addition to IDs above, it retains `gpt-3.5-turbo`, `text-davinci-003`, `gpt-4`, `gpt-4-turbo`, `o1`, `o1-mini`, `o3` and `o3-mini`. Do not infer availability from that metadata. `text-davinci-003` and `o1-mini` are retired. `gpt-3.5-turbo`, `gpt-4`, `gpt-4-turbo`, `o1` and `o3-mini` are scheduled for retirement on October 23, 2026. `o3` is deprecated; its `o3-2025-04-16` snapshot is scheduled for retirement on December 11, 2026.
-
-The completion proxy also routes additional model IDs and provider snapshots that are not listed in the catalog above. Successful routing alone does not establish current provider access or endpoint compatibility. New features should start from the configured catalog above and use current provider documentation to confirm compatibility.
+See [JavaScript metadata and additional provider IDs](/API/core/ai/models#historical-ids-and-additional-provider-models). Exported metadata and successful proxy routing do not establish provider availability.
 
 ## Related guides
 
@@ -4926,7 +4551,9 @@ The five-minute capture cap in the example is an app choice, not a Fliplet platf
 
 ## Complete recording and upload example
 
-Add these controls to your screen and run the script after they exist. This example combines the recording workflow with file upload and retry. It displays an editable transcript separately; **Insert transcript** appends it to the text currently in the Notes field. A failed or cancelled operation leaves existing typed text intact.
+Add these controls to your screen and run the script after they exist. This vanilla JavaScript browser example combines the recording workflow with file upload and retry, updating DOM controls directly. It displays an editable transcript separately; **Insert transcript** appends it to the text currently in the Notes field. A failed or cancelled operation leaves existing typed text intact.
+
+When adapting this example to a framework, use its state, bindings and screen-disposal hook. Vue apps should follow [asynchronous reactive-state updates](/API/v3/frameworks/vue#updating-reactive-state-asynchronously). Preserve microphone cleanup on disposal; browser page lifecycle events alone do not cover SPA navigation.
 
 ```html
 
@@ -5188,7 +4815,7 @@ window.addEventListener('pagehide', function() {
 updateControls();
 ```
 
-Use your framework's screen-disposal hook as well as appropriate page lifecycle events when adapting this example. Block the affected form's Save or Send while capture, finalization or transcription is pending. Keep typing and editing available wherever they do not conflict with the operation. This example does not submit Notes automatically.
+Block the affected form's Save or Send while capture, finalization or transcription is pending. Keep typing and editing available wherever they do not conflict with the operation. This example does not submit Notes automatically.
 
 ## Cancellation, timeout and retry
 
@@ -5268,7 +4895,11 @@ For other app setups, add `fliplet-socket` through [dependencies and assets](/De
 
 ## Build a conversation
 
-Add this HTML and CSS to your screen, then run the JavaScript after the controls exist and Fliplet is ready. This framework-agnostic example uses Chat Completions. It displays the user's turn immediately, updates one assistant message during streaming and stores both raw text turns only after success.
+Add this HTML and CSS to your screen, then run the JavaScript after the controls exist and Fliplet is ready. This vanilla JavaScript browser example uses Chat Completions and updates DOM elements directly. It displays the user's turn immediately, updates one assistant message during streaming and stores both raw text turns only after success.
+
+When adapting it to a framework, use that framework's state and rendering mechanisms. For Vue, follow [asynchronous reactive-state updates](/API/v3/frameworks/vue#updating-reactive-state-asynchronously) so callbacks update the reactive state read by the template.
+
+Call the returned `disposeAIChat()` from your framework's unmount or route cleanup hook. The example also handles `pagehide`, which alone does not cover SPA navigation.
 
 ```html
 
    @@ -5445,9 +5076,7 @@ const disposeAIChat = mountAIChat(); The conversation is readable without announcing every chunk to a screen reader: the separate status reports waiting, completion or failure. Updates follow the bottom while the reader is there and preserve their position when they scroll back. Adapt the controls and styles to your app's design and audience. -In Vue, keep callbacks connected to [reactive state](/API/v3/frameworks/vue#updating-reactive-state-asynchronously); mutating an original object after inserting it into component state does not trigger a display update. On SPA route changes or framework unmount, call `disposeAIChat()`; `pagehide` alone does not cover SPA navigation. - - +## Conversation history History contains raw `{ role, content }` messages, not the HTML used for display. Failed or stopped replies remain visibly interrupted and are excluded from successful history. Retrying an unchanged draft reuses the failed display turn and adds the successful user/assistant pair once. A changed draft starts a separate display turn. @@ -5455,7 +5084,7 @@ Send a message, then ask about the reply. The next request includes the successf ## Choose the request format -Replace `requestReply(model, messages, onText)` in the complete example with the appropriate adapter below and set `model` to your supported selection. These adapters convert this guide's raw text history and deliver text to the same progressive renderer. Images, tool calls and tool results require the provider-specific structures in the [reference](/API/core/ai). +Replace `requestReply(model, messages, onText)` in the [complete browser example](#build-a-conversation) with the appropriate adapter below and set `model` to your supported selection. These adapters convert this guide's raw text history and deliver text to the same progressive renderer. Images, tool calls and tool results require the provider-specific structures in the [reference](/API/core/ai). ### Gemini conversation @@ -5511,7 +5140,7 @@ Cancellation is asynchronous. Keep the send guard active until the request settl ## Non-streaming and plain-text replies -If the user requests a supported non-streaming model, preserve that choice and show a pending state while waiting. For Chat Completions, replace `requestReply` with this adapter. It renders the completed reply through the same safe renderer; the Stop button stays disabled because this buffered Promise has no cancellation method. +If the user requests a supported non-streaming model, preserve that choice and show a pending state while waiting. For Chat Completions, replace `requestReply` in the [complete browser example](#build-a-conversation) with this adapter. It renders the completed reply through the same safe renderer; the Stop button stays disabled because this buffered Promise has no cancellation method. ```js async function requestReply(model, messages, onText) { @@ -5525,7 +5154,7 @@ async function requestReply(model, messages, onText) { For a Responses-only non-streaming model, send `input` with `useResponses: true` and extract message content with `type: 'output_text'` from `result.output`; see [Responses](/API/core/ai#using-the-responses-api). For Gemini, use `contents` and extract non-thought text parts from `result.candidates[0].content.parts`; see [Gemini](/API/core/ai#using-gemini-models). Preserve the guard, pending/error state and raw history when adapting either format. -For explicitly plain-text replies, replace the parser/sanitizer lines in `paint()` with `run.assistant.body.textContent = run.reply`, set that body's `white-space: pre-wrap`, and remove only the Markdown resources and their loading. In this example, `ensureRenderer()` loads only Markdown libraries, so its call can be removed. If your adapted loader also loads streaming dependencies, retain that part: a plain-text stream still needs `fliplet-socket`. Keep streaming if appropriate. Never display model output through unsanitized `innerHTML`. +For explicitly plain-text replies in the [complete browser example](#build-a-conversation), replace the parser/sanitizer lines in `paint()` with `run.assistant.body.textContent = run.reply`, set that body's `white-space: pre-wrap`, and remove only the Markdown resources and their loading. In this example, `ensureRenderer()` loads only Markdown libraries, so its call can be removed. If your adapted loader also loads streaming dependencies, retain that part: a plain-text stream still needs `fliplet-socket`. Keep streaming if appropriate. Never display model output through unsanitized `innerHTML`. The example's eager `fliplet-socket` dependency loads at app boot and is ready after Fliplet initialization. If your app instead declares that registered package as lazy, await its full dependency chain inside `submit()`'s `try` block before calling `requestReply()`. Keep the existing error/retry handling if loading fails: @@ -5589,10 +5218,10 @@ Use a compatible model requested by the user. Otherwise select for the task, bud The tables list model IDs configured by Fliplet. Deprecated and retired entries appear in [Deprecated and retired models](#deprecated-and-retired-models). Configuration and provider routing do not guarantee that a model remains available from its provider, or that every model supports every request format. Check the linked provider reference for endpoint compatibility and parameters before using a model. -OpenAI text models use [createCompletion()](/API/core/ai#flipletaicreatecompletion) or [POST /v1/apps/:app/ai/completions](/REST-API/fliplet-ai#completions). Chat Completions uses `messages`; Responses uses `input` and `useResponses: true`. Models marked Responses require that format. Other models must use a format supported by their individual [OpenAI model reference](https://developers.openai.com/api/docs/models). - ### OpenAI text models +OpenAI text models use [createCompletion()](/API/core/ai#flipletaicreatecompletion) or [POST /v1/apps/:app/ai/completions](/REST-API/fliplet-ai#completions). Chat Completions uses `messages`; Responses uses `input` and `useResponses: true`. Models marked Responses require that format. Other models must use a format supported by their individual [OpenAI model reference](https://developers.openai.com/api/docs/models). + Numbered GPT models are ordered by version (highest first), then Astra, Sol, Terra and Luna within a version, and Pro, normal, Mini and Nano within a model family. This order helps you find models; it does not determine which is most suitable for your task. The 4o family is listed separately. | Model ID | Availability or request-format note | @@ -5621,6 +5250,7 @@ Numbered GPT models are ordered by version (highest first), then Astra, Sol, Ter | `gpt-4o` | Chat Completions or Responses. | | `gpt-4o-mini` | Chat Completions or Responses. | +#### OpenAI capability and cost facts Provider selection facts for text-only requests: @@ -13290,6 +12920,498 @@ Both `find` and `findOne` accept: --- +# Data source security examples +URL: https://developers.fliplet.com/API/datasources/security-examples.md + +# Data source security examples + +Use these policies with their stated data shapes and operation limits. Each example pairs the complete rule array with app SDK calls and outcomes to verify with security enforcement enabled. + +## Before you start + +Configure each data source's columns and sample rows in Studio before running the app code. For authenticated examples, configure a **Members** login source using the [authentication guide](../v3/auth.md). Replace illustrative ID `731` in every bound rule with its actual ID. Seed two test identities whose flat authenticated data includes: + +```json +[ + { "Email": "ada@example.org", "Enabled": true, "Roles": ["Author", "Approver"] }, + { "Email": "lin@example.org", "Enabled": true, "Roles": ["Author"] } +] +``` + +`Enabled` is a native boolean, and `Roles` is an array of strings. Protect those identity fields from self-service writes. Columns are names, not enforced types: verify actual stored values. Use disposable test sources and record allowed and denied results using the [preview procedure](testing-security.md). Outcomes below describe the policy contract, not a substitute for authenticated runtime tests. + +SDK query results have `{ id, data }` envelopes. Obtain current fixture IDs from Studio rather than guessing IDs. These plain JavaScript examples use `await` and run inside an app with the Data Sources library available. Authentication happens through the app's configured login flow before these calls. + +## Public lookup + +**Goal:** Anonymous readers can browse a public instrument catalog. App users cannot change it. + +Create **Instrument Catalog** with columns `Instrument` and `Family`, seeded with: + +```json +[ + { "Instrument": "Oboe", "Family": "Woodwind" }, + { "Instrument": "Cello", "Family": "Strings" } +] +``` + +Save this entire rule array: + +```json +[ + { "type": ["select"], "allow": "all" } +] +``` + +Allowed as an anonymous app user: + +```js +const catalog = await Fliplet.DataSources.connectByName('Instrument Catalog', { offline: false }); +const instruments = await catalog.find(); +console.log(instruments.map(entry => entry.data.Instrument)); +// Expected: ['Oboe', 'Cello'] (order is illustrative). +``` + +Denied as either an anonymous or signed-in app user: + +```js +const catalog = await Fliplet.DataSources.connectByName('Instrument Catalog', { offline: false }); +let denied = false; +try { + await catalog.insert({ Instrument: 'Flute', Family: 'Woodwind' }); +} catch (error) { + denied = true; + console.log(error); // Inspect the denial; then confirm no Flute row persisted. +} +if (!denied) throw new Error('Unexpected grant: verify the saved policy and preview mode.'); +``` + +Update and delete are also denied because no rule grants them. Studio administrators may still seed the catalog; that success is separate from app-user evidence. + +## Shared signed-in notices + +**Goal:** Members from the intended login source can read and add shared notices. They cannot update or delete existing notices. + +Create **Rehearsal Notices** with columns `Message` and `Venue`, seeded with `{ "Message": "Bring a music stand", "Venue": "Hall B" }`. Save: + +```json +[ + { + "type": ["select"], + "allow": { "loggedIn": true, "dataSourceId": 731 } + }, + { + "type": ["insert"], + "allow": { "loggedIn": true, "dataSourceId": 731 }, + "require": ["Message", "Venue"] + } +] +``` + +Allowed after Ada signs in through Members: + +```js +const notices = await Fliplet.DataSources.connectByName('Rehearsal Notices', { offline: false }); +await notices.insert({ Message: 'Doors open at six', Venue: 'Hall A' }); +const rows = await notices.find({ where: { Venue: 'Hall A' } }); +console.log(rows.map(entry => entry.data)); +``` + +Denied tests: sign out and call `find()` or `insert()`; sign in through another login source and repeat. As Ada, submit `insert({ Message: 'Missing venue' })`, or update/delete a seeded notice. Each must reject and leave no forbidden persisted effect. The string requirements check presence only: `Venue: null` is not a nonempty validation failure under this policy. + +## Private sketches + +**Goal:** An enabled member reads, creates and deletes sketches scoped to their email. This example deliberately grants no updates, keeping ownership immutable through the app operations it permits. + +Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: + +```json +[ + { "OwnerEmail": "ada@example.org", "Title": "Morning motif" }, + { "OwnerEmail": "lin@example.org", "Title": "Evening variation" } +] +``` + +Save: + +{% raw %} +```json +[ + { + "type": ["select", "insert", "delete"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] + } +] +``` +{% endraw %} + +Allowed as Ada: + +```js +const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); +const mine = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); +console.log(mine.map(entry => entry.data.Title)); // ['Morning motif'] +await sketches.insert({ OwnerEmail: 'ada@example.org', Title: 'Second motif' }); +``` + +Use the fixture ID from Studio to test `removeById(adaSketchId)` on Ada's disposable row; the stored owner grants deletion. Denied as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })`, inserting Lin's email or deleting Lin's fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied. No update is granted, even on Ada's own row. + +The query rule validates `where`; it does not add an owner filter for you. A record read with `findById(id)` instead evaluates the stored row. Repeat both paths when the app uses both. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. + +## Editable profiles + +**Goal:** An enabled member updates their stored profile through single-record writes while excluded fields remain unchanged. This example does not guarantee protection on bulk/commit writes. + +Create **Performer Profiles** with columns `OwnerEmail`, `DisplayName` and `Badge`. Seed: + +```json +[ + { "OwnerEmail": "ada@example.org", "DisplayName": "Ada", "Badge": "Soloist" }, + { "OwnerEmail": "lin@example.org", "DisplayName": "Lin", "Badge": "Member" } +] +``` + +For immutable ownership or protected badges across **all** endpoints, leave app-session updates disabled until an implemented trusted write path has been verified against source and runtime tests. This example does not provide that guarantee: bulk/commit updates can write fields that single-record `exclude` filters out. Omitting bulk methods from the UI is not server enforcement. A script's `query` can also come from caller-supplied `body.where`, so proposed-payload validation cannot be assumed even when `entry.data` exists. + +Save: + +{% raw %} +```json +[ + { + "type": ["select", "update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], + "exclude": ["OwnerEmail", "Badge"] + } +] +``` +{% endraw %} + +For the explicitly limited single-record example, copy Ada's and Lin's fixture IDs from Studio and run as Ada: + +```js +async function verifySingleProfileWrite(adaProfileId, linProfileId) { + const profiles = await Fliplet.DataSources.connectByName('Performer Profiles', { offline: false }); + await profiles.update(adaProfileId, { DisplayName: 'Ada L.' }); + const saved = await profiles.findById(adaProfileId); + console.log(saved.data.DisplayName); // 'Ada L.' + let denied = false; + try { + await profiles.update(linProfileId, { OwnerEmail: 'ada@example.org', DisplayName: 'Changed' }); + } catch (error) { + denied = true; + console.log(error); // Stored Lin ownership does not match Ada. + } + if (!denied) throw new Error('Unexpected grant on another member’s profile.'); +} +``` + +Also submit a single update of Ada's row with `Badge: 'Director'` and `OwnerEmail: 'lin@example.org'`. It can resolve successfully, but those excluded fields must remain unchanged in a trusted Studio readback; the read rule hides them from the SDK response. Anonymous or wrong-source updates, insert and delete must be denied. This distinguishes a rejected operation from a filtered field. + +## Role and state access + +**Goal:** An enabled Approver can update an existing Pending review. This demonstrates stored-state authorization only, not a complete approval workflow. + +Create **Festival Reviews** with columns `WorkTitle`, `Stage` and `Comment`. Seed `{ "WorkTitle": "River quartet", "Stage": "Pending", "Comment": "" }`. + +This policy permits changing Pending directly to any next stage. Do not use it as approval-transition enforcement. Leave strict workflow writes disabled until an implemented trusted write path and actor-operation tests verify caller role, stored stage, proposed stage and protected fields across all allowed endpoints. + +Save this illustrative stored-state policy: + +```json +[ + { + "type": ["select"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } } + }, + { + "type": ["update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true, "Roles": "Approver" } }, + "require": [{ "Stage": { "equals": "Pending" } }] + } +] +``` + +As Ada (`Roles: ['Author', 'Approver']`), a single update of the seeded Pending review's `Comment` is allowed: + +```js +async function addReviewComment(reviewId) { + const reviews = await Fliplet.DataSources.connectByName('Festival Reviews', { offline: false }); + await reviews.update(reviewId, { Comment: 'Ready for rehearsal' }); + const saved = await reviews.findById(reviewId); + console.log(saved.data.Comment); // 'Ready for rehearsal' +} +``` + +Denied tests: Lin (`Roles: ['Author']`), a user with only `['SuperApprover']`, an inactive user or an anonymous caller attempting that update. Ada updating a seeded `Stage: 'Closed'` row must also be denied. Insert and delete have no grant. + +**Transition limit:** Ada can also submit `Stage: 'Published'` while the stored row is Pending. The requirement does not validate the next stage. Do not deploy this example as an approval-transition policy. A strict workflow needs server checks of caller role, stored stage, proposed stage and protected fields on every allowed write path. A single-update script can inspect `entry.data`, but commit lacks `entry`, and even a single-update request can supply `body.where` instead of the proposed payload to the script. Keep strict workflow writes unavailable until an implemented trusted write path and its actor-operation tests establish the required contract. Frontend guards and rule-query mutation do not supply that guarantee. + +--- + +# Data source security rule reference +URL: https://developers.fliplet.com/API/datasources/security-rules.md + +# Data source security rule reference + +Data source access rules grant app operations using identity, request and stored-record conditions. This reference distinguishes the rule evaluator from endpoint-specific reads and writes. + +## Contents + +- [Evaluation and defaults](#evaluation-and-defaults) +- [Rule properties](#rule-properties) +- [Identity and login binding](#identity-and-login-binding) +- [Requirements by operation](#requirements-by-operation) +- [Column restrictions](#column-restrictions) +- [Custom scripts](#custom-scripts) +- [Errors](#errors) + +## Evaluation and defaults + +Active rules are evaluated in order. The first grant wins; disabled rules, nonmatching actors and failed requirements are skipped. This fallthrough applies to insert, update and delete as well as select. A denied script does not veto a later grant. + +Absent or null `accessRules` retains legacy unrestricted access. An explicit empty array `[]`, or an array with no applicable grant, denies access. Studio administrative access and preview with enforcement disabled can bypass rules; use [authenticated preview testing](testing-security.md) to prove app access. + +## Rule properties + +| Property | Shape | Behavior | +|---|---|---| +| `type` | Array of operation strings | `select`, `insert`, `update`, `delete`. A nonempty `script` runs independently of this filter. | +| `allow` | String or object | `all`, `loggedIn`, a user condition, login-source gate or token-ID gate. A script must make its own access decision. | +| `enabled` | Boolean | `false` skips the rule. Omission leaves it active. | +| `appId` | Array of numbers | Limits the rule to those apps, including the corresponding cloned/production app relationship. Applies to script rules too. | +| `require` | Array of strings or objects | All requirements must match their operation-specific target. | +| `include`, `exclude` | Arrays of column names | Endpoint-specific column restrictions, described below. | +| `name` | String | Descriptive Studio label. | +| `script` | String | JavaScript returning an object with `granted: true` to grant. | + +## Identity and login binding + +`allow.user` checks the authenticated user's data, not the target record. The data-source identity is the signed-in row's flat column values plus its entry metadata; SAML identity comes from the authenticated assertion. Client-written session fields must not be used as proof of identity. A bound data-source rule uses the same source identity for its user gate and templated requirements. + +Use the actual authentication data source ID in `dataSourceId` (one positive integer or an array of IDs). For example, after configuring authentication against your **Members** source with ID `731`, this gate grants reads to users signed in through that source: + +```json +[ + { "type": ["select"], "allow": { "loggedIn": true, "dataSourceId": 731 } } +] +``` + +`"loggedIn"` alone accepts a supported authenticated login without requiring this particular source. An API-token gate uses `{ "tokens": [tokenId] }`; these are token IDs, not token secrets. + +### Typed user conditions + +Literal boolean and numeric user conditions compare native values. Preserve the actual identity types: + +```json +{ + "type": ["select"], + "allow": { + "dataSourceId": 731, + "user": { "Enabled": true, "Clearance": 7, "Roles": "Approver" } + } +} +``` + +For textual comparisons, `equals` compiles a case-insensitive anchored, escaped regular expression. It matches text and string elements of arrays, not native boolean or number values. `{ "Enabled": { "equals": true } }` therefore does not match native `true`. `contains` is a substring check: it matches both `Approver` and `SuperApprover`, including string elements in role arrays. Use a literal `"Approver"` or `{ "equals": "Approver" }` for exact role values. Both match `["Author", "Approver"]`; `contains` is unsuitable for exact role membership. + +Multiple fields in `allow.user` are AND conditions. Use separate rules for alternative grants. Object conditions support `equals`, `notequals` and `contains`; do not invent query operators in this rule syntax. + +### Identity templates + +{% raw %} +`{{user.[Email]}}` resolves the signed-in identity field; brackets also support names with spaces. Use it in `require` to compare a record or query to the caller. Comparing a user's own field to that same template inside `allow.user` does not restrict the actor. Missing identity must deny owner-scoped access; test missing, null and blank fields rather than relying on template output. +{% endraw %} + +Authentication setup and client-side session retrieval belong to the [authentication guide](../v3/auth.md) and [Session API](../fliplet-session.md). + +## Requirements by operation + +String requirements check **key presence**, not a nonempty value or type. For example, `"require": ["Label"]` can accept a submitted `Label: null`. Object requirements check values using the operators below. These two forms have different targets on updates. + +| Operation/path | String requirement target | Object requirement target | +|---|---|---| +| `find()` / `findOne()` query | Submitted `where` keys or supported `$filters` column conditions | Submitted query filter, not every returned row | +| `findById()` record read | Stored record keys | Stored record values | +| Single `insert()` | Submitted keys | Submitted data | +| Single `update()` | Submitted keys | **Stored row**, not proposed data | +| Bulk/commit insert | Keys in every submitted data object | Every submitted data object | +| Bulk/commit update | Keys in every submitted data object | Every loaded stored row; no stored context means the condition cannot grant | +| Single `removeById()` | Stored record keys | Stored record values | +| Query writes | Depends on the request's query and operation checks | Query admission and affected-record checks differ; do not infer access from a single-record test | +| Commit delete | Existing data for the requested IDs | Existing data for the requested IDs | + +A query read requirement checks the client's filter; it does not inject a filter. Send the appropriate `where` clause. `findOne({ where: ... })` follows the query route, whereas `findById(id)` follows the record route and cannot be assumed equivalent. Additional source permissions can reject either route. + +### Requirement operators + +{% raw %} +```json +[ + { + "type": ["select", "update"], + "allow": { "loggedIn": true, "dataSourceId": 731 }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] + } +] +``` +{% endraw %} + +| Operator | Meaning | +|---|---| +| `equals` | Case-insensitive anchored text comparison; query validation also recognizes `$eq` and matching equality `$filters`. | +| `notequals` | Inequality comparison; it is not an existence or nonempty check. | +| `contains` | Substring comparison; query validation recognizes direct strings and relevant `$eq`, `$like`, `$iLike` or equality `$filters` values. | + +Only these three object operators are supported. They are separate from [query operators](query-operators.md). On updates the example authorizes the stored owner's row, but it does **not** prohibit changing `OwnerEmail`. The [examples guide](security-examples.md) shows that limitation beside the write. + +## Column restrictions + +Column restrictions do not have a uniform write contract: + +| Path | `exclude` | `include` | +|---|---|---| +| Select query/record response | Removes excluded data columns | Restricts returned data columns after exclusion; a field removed by `exclude` is not restored by `include` | +| Single insert/update | Removes submitted excluded fields before persistence; this is filtering, not necessarily a rejected request | Does not reliably filter the persisted single-write payload | +| Bulk append/replace/commit | Does not filter each persisted entry | Does not filter each persisted entry | +| Query update | Has separate affected-record filtering behavior | Do not assume single-write or bulk behavior | + +A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. + +## Custom scripts + +A nonempty script runs regardless of `type` and `allow`, subject to the enabled/app filters. It must check operation, authenticated identity and login source itself. Return `{ granted: true }` to grant; bare booleans, missing returns and `{ granted: false }` do not grant. A script grant returns immediately for that rule, so standard `require` is not an additional payload validator. + +```js +// A read-only script for a bound login source; replace 731 with its actual ID. +if (!user || !session || session.dataSourceId !== 731 || type !== 'select') { + return { granted: false }; +} +return { granted: true, exclude: ['PrivateNotes'] }; +``` + +The returned `include`/`exclude` use the endpoint limits above. Put a script into the custom-rule editor; it is server-side rule code, not browser JavaScript. + +### Script inputs and mutation + +| Variable | Shape | +|---|---| +| `type` | Operation string: `select`, `insert`, `update` or `delete` | +| `user` | Authenticated flat identity object, or `undefined` | +| `session` | Read-only verified login context with `loggedIn`, `passports`, `dataSourceId` and `entryId`; data-source IDs are absent for other login types | +| `query` | A **shallow copy** selected in this order: request `body.where`, supplied entry data, request body | +| `entry` | Stored `{ id, data, ... }` for a single update when supplied; absent for commit and initial query evaluation | +| `DataSources` | Server-side lookup library described below | + +An online query read normally receives the unwrapped `where`. A single insert/update normally receives flat submitted data. A commit receives arrays of flat data for each operation; deletes receive loaded existing data. Query writes can receive `body.where` instead of proposed data even during affected-record checks. Do not treat `query` as a universal proposed payload or assume `entry` exists on every update. + +Assigning `query.OwnerEmail` or `query.UpdatedAt` does not establish a persisted transformation. The rule's copied query is not a supported audit-stamping or filter-injection contract. Validate inputs or deny access; do not advertise mutation as server-enforced stamping. The script VM has a 3,000 ms synchronous execution timeout; this is not a guaranteed end-to-end deadline for awaited lookups. + +### Reading another data source + +`DataSources(idOrName).find(options)` and `.findOne(options)` perform server-side reads without applying the target source's access rules. A lookup may therefore expose fields ordinary app users cannot read. Use it only for the minimum policy decision, and do not return its contents to clients. + +```js +if (!user || !session || session.dataSourceId !== 731 || type !== 'select') { + return { granted: false }; +} +const membership = await DataSources('Reading Groups').findOne({ + where: { Email: user.Email, Group: 'Curators' } +}); +return { granted: !!membership }; +``` + +These methods return **flat data**: `find` returns an array and `findOne` returns a flat object or `undefined`. They do not return SDK `{ id, data }` envelopes. Options are `where`, `limit` (up to 100, default 100) and `offset` (default 0). See [query operators](query-operators.md) for supported filters. + +## Errors + +A denied operation rejects the SDK promise. Standard access denials may expose `type: "datasource.access"` with `payload.dataSourceId`, but error shape and HTTP status depend on the endpoint and caller. Query/record reads commonly return HTTP 400; single insert/update access checks can return HTTP 401. The insert denial message uses “write”, while update and delete use their respective operations. Scripts can surface a custom-rule error. Do not classify every rejected promise, HTTP 400 or HTTP 401 as a policy denial: inspect the response and follow [preview diagnosis](testing-security.md#diagnose-a-failure). + +--- + +# Testing data source security in preview +URL: https://developers.fliplet.com/API/datasources/testing-security.md + +# Testing data source security in preview + +Test data access as the intended app identity with Studio's preview security enforcement enabled. A rendered screen, a successful administrative query or a preview with enforcement disabled does not prove app-user authorization. + +## Prepare a disposable test + +Create dedicated test data sources and records. Record the saved rule array, login source, actual field types and test record IDs. Build an actor-operation table before testing: + +| Actor | Operation | Expected result | +|---|---|---| +| Anonymous | Read private data | Denied | +| Bound enabled member | Read their record | Allowed | +| Bound enabled member | Change another member's record | Denied | +| Wrong-source login | Read protected data | Denied | +| Inactive member | Protected write | Denied | +| Approver with multiple roles | Supported review operation | Allowed | +| Member without required role | Same operation | Denied | + +Adapt the matrix to the configured policy. Include missing/null identity fields, different native types and each write method your app uses. For protected ownership, privilege or state fields, attempt malicious payloads directly through the SDK as well as through the UI. A hidden button does not stop an SDK call. + +## Enable enforcement and sign in + +1. Record the initial preview **Enforce security** setting. +2. Open preview settings and enable **Enforce security**. Wait for the preview to reload before continuing. +3. Use the app's configured login flow to sign in as the test actor. Confirm the identity and login source; a Studio account alone is not the app-user identity. Sign out between actors and test the anonymous case too. +4. Confirm the reloaded preview environment is consistent with enforcement enabled: + +```js +console.log({ + appId: Fliplet.Env.get('appId'), + disableSecurity: Fliplet.Env.get('disableSecurity'), + interact: Fliplet.Env.get('interact'), + development: Fliplet.Env.get('development') +}); +// Require disableSecurity === false, interact === false and development !== true. +// Missing/unknown enforcement state is unverified. +``` + +Also confirm the UI setting is enabled and the preview is outside editor interaction mode. Interaction mode can bypass data source enforcement. A stale document or an unknown setting is unverified. Do not log tokens or copy token-bearing preview URLs into reports. + +## Run allowed and denied operations + +Use an online connection to avoid counting cached reads or queued writes as server proof: + +```js +const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); +const rows = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); +console.log(rows.map(entry => ({ id: entry.id, data: entry.data }))); +``` + +Run this only after configuring the matching [private sketches policy](security-examples.md#private-sketches) and signing in as Ada. Record the successful server response and returned rows. Repeat with another actor's filter and confirm the authorization denial. + +For every allowed write, perform a fresh read and check the exact saved values. For every forbidden write, inspect the rejection and verify that no forbidden insert, update or delete persisted. Use a trusted Studio readback where the app's read rules hide the affected fields; label it as persistence evidence, not an app-user access test. + +Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate single-record `exclude` behavior to bulk writes. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. + +## Diagnose a failure + +A policy denial must be supported by the error response and unchanged forbidden state. An HTTP 400 or 401 alone is insufficient because errors vary by method and caller. + +| Symptom | Check | +|---|---| +| Access denial / `datasource.access` | Saved rule order, operation, login binding, field casing/types and the correct requirement target | +| Empty results | Actual filter and fixtures; an allowed empty read is different from a denied request | +| HTTP 429 | Rate-limit response and retry interval; do not classify it as a rule failure | +| Login/loading screen | Complete authentication before testing data access | +| Network/transport error | Request completion and response body; authorization remains unverified | +| Successful update but field unchanged | Single-write `exclude` may filter the submitted field rather than reject | +| Success only with enforcement off | Rerun with enforcement on and the intended identity | + +Use the [rule reference](security-rules.md) for evaluator and endpoint differences, and the [authentication guide](../v3/auth.md) for sign-in setup. Hooks remain documented in [Data source hooks](../../Data-Source-Hooks.md). + +## Finish and restore preview + +Delete the disposable test records through an authorized test/admin path. Restore the original **Enforce security** setting and wait for a fresh reload. Confirm the effective setting before reporting restoration; a failed reload is unverified. Preserve a compact allowed/denied result table with outstanding gaps, without credentials or private data. + +--- + # Data Source views URL: https://developers.fliplet.com/API/datasources/views.md @@ -15167,6 +15289,12 @@ URL: https://developers.fliplet.com/API/fliplet-datasources.md Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +## Security and verification + +Configure [data source access rules](../Data-source-security.md) before exposing private data. Use the [rule reference](datasources/security-rules.md), [complete policy examples](datasources/security-examples.md) and [authenticated preview tests](datasources/testing-security.md) alongside this method reference. + +Query reads (`find`/`findOne`) and record reads (`findById`) evaluate different requirement targets. Object update requirements check stored rows, not proposed values. Single and bulk writes have different column restrictions; a successful administrative request or cached/offline result does not establish app-user authorization. + ## ⚠️ Important: All API Calls Are Asynchronous **All Fliplet Data Sources API methods return Promises and must be chained using `.then()` or used with `async/await`.** @@ -16108,7 +16236,8 @@ const newDataSource = await Fliplet.DataSources.create({ } ], - // Set permissions + // Public sample data only: grants every app user all four operations. + // Use a scoped policy from the security guide for private data. accessRules: [ { type: ['select', 'insert', 'update', 'delete'], allow: 'all' } ] @@ -35577,688 +35706,106 @@ URL: https://developers.fliplet.com/Data-source-security.md # Securing Fliplet data sources -How to protect data sources with access rules, required-data validation, column-level restrictions, and custom JavaScript security rules. - -## Contents - -- [Security rules](#security-rules) -- [Access rule structure](#access-rule-structure) - - [Defining who can access](#defining-who-can-access) - - [Restricting columns](#restricting-columns) - - [Example: role-based access with protected fields](#example-role-based-access-with-protected-fields) - - [Example: department-scoped access](#example-department-scoped-access) -- [Data requirements and query validation](#data-requirements-and-query-validation) - - [Data requirement types](#data-requirement-types) - - [Handlebars templating](#handlebars-templating) - - [Require syntax](#require-syntax) -- [Custom security rules](#custom-security-rules) - - [Granting access](#granting-access) - - [Modifying the input query](#modifying-the-input-query) - - [Checking data when committing changes](#checking-data-when-committing-changes) - - [Reading data from other Data Sources](#reading-data-from-other-data-sources) - -## Security rules - -Access to Data Sources is secured via the **Access Rules** tab of the **App Data** section in Fliplet Studio. Each rule controls who can access the data source, what operations they can perform, and what conditions must be met. - -Rules are evaluated from top to bottom. The first rule that grants access is used and evaluation stops — later rules are not checked. If no rules match, access is denied by default. If no rules are defined at all, all access is denied — data sources are locked down until you explicitly add rules. - -Note that for read operations, a rule with unmet `require` conditions is **skipped**, not denied — evaluation continues to the next rule. For writes, unmet `require` is a hard rejection. See [Data requirements](#data-requirements-and-query-validation) for details. - -## Access rule structure - -Each access rule is a JSON object with the following properties: - -| Property | Type | Required | Description | -|---|---|---|---| -| `type` | Array of strings | Yes | Operations this rule applies to: `"select"`, `"insert"`, `"update"`, `"delete"` | -| `allow` | String or Object | Yes | Who can access: `"all"`, `"loggedIn"`, `{ "user": {...} }`, or `{ "tokens": [...] }` | -| `enabled` | Boolean | No | Whether the rule is active (defaults to `true`). Set to `false` to disable the rule without deleting it — disabled rules are skipped during evaluation | -| `include` | Array of strings | No | Whitelist of accessible columns. If both `include` and `exclude` are present, `include` takes precedence | -| `exclude` | Array of strings | No | Blacklist of hidden columns. Ignored if `include` is also present | -| `require` | Array | No | Data requirements that must be met (see [Data requirements](#data-requirements-and-query-validation)) | -| `appId` | Array of numbers | No | Restrict this rule to specific app IDs (applies to all apps if omitted) | -| `name` | String | No | Descriptive label for identifying the rule in Studio | -| `script` | String | No | Custom JavaScript code for advanced security logic. When present, `allow` and `type` are ignored — see [Custom security rules](#custom-security-rules) | - -### Defining who can access - -The `allow` property supports four modes: - -**All users** (including anonymous): - -```json -{ "allow": "all" } -``` - -**Logged-in users only:** - -```json -{ "allow": "loggedIn" } -``` - -**Specific users** (filtered by session data). `allow.user` checks the logged-in user's **identity** — use it with literal values to control *who* can access. To control *which records* they can access, use `require` instead (see [Data requirements](#data-requirements-and-query-validation)): - -```json -{ - "allow": { - "user": { - "Role": { "equals": "Admin" } - } - } -} -``` - -User filters support three operators: `equals`, `notequals`, and `contains`. Multiple conditions in the same `user` object are combined with AND logic. For OR logic, create separate rules instead. +Protect app data with server-evaluated access rules, then test the allowed and denied operations as real app users. Rules control access; browser checks and successful Studio administrative requests do not prove authorization. -

    Important: allow.user conditions check the logged-in user's session data — not data source records. Always use literal values here (e.g., "Admin", "Manager"). Although Handlebars syntax is supported, using it in allow.user (e.g., {% raw %}{{ user.[Department] }}{% endraw %}) compares the user's field against itself — always true, granting access to any logged-in user. Handlebars templates are useful in require conditions, where they scope queries by the user's data. See Data requirements.

    +## Configure a policy -{% raw %} -```json -{ - "allow": { - "user": { - "Role": { "equals": "Manager" }, - "Status": { "notequals": "Inactive" } - } - } -} -``` -{% endraw %} +1. Open **App Data** in Fliplet Studio, select the data source and open **Access Rules**. +2. Define the actors, operations and fields that need protection. Start with an explicit empty rules array (`[]`) for a source that should deny app access until configured. Missing or null rules retain legacy unrestricted access. +3. Choose a [complete security example](API/datasources/security-examples.md) and adapt its data shape and login-source binding. Save the rules, then read back the configuration. +4. Test both allowed and denied operations with [Enforce security enabled in preview](API/datasources/testing-security.md). Confirm persisted results using a fresh read. -**Specific API token:** +A minimal public read-only policy is: -```json -{ - "allow": { - "tokens": [42857] - } -} -``` - -### Restricting columns - -Use `include` to whitelist specific columns, or `exclude` to hide specific columns. If both are specified in the same rule, `include` takes precedence — only the whitelisted columns are returned, and the `exclude` list has no effect. If neither is specified, all columns are accessible. - -```json -{ - "type": ["select"], - "allow": "loggedIn", - "exclude": ["Password", "SSN", "Salary"] -} -``` - -

    Security note: Always exclude privilege fields (e.g., Admin, Role, Permissions) from rules that allow logged-in users to insert or update records. Otherwise, users can escalate their own privileges.

    - -### Example: role-based access with protected fields - -This example shows an "Employees" data source where admins have full access, regular users can read records (without sensitive fields), and can only update their own record or insert records with a non-privileged role. - -**Data source columns:** - -| Email | First Name | Role | Department | Salary | Password | Admin | Permissions | -|---|---|---|---|---|---|---|---| -| alice@acme.com | Alice | Admin | Engineering | 150000 | ••••• | Yes | all | -| bob@acme.com | Bob | User | Marketing | 85000 | ••••• | No | read | -| carol@acme.com | Carol | User | Engineering | 95000 | ••••• | No | read | - -**Security rules:** - -{% raw %} ```json [ - { - "type": ["select", "insert", "update", "delete"], - "allow": { "user": { "Role": { "equals": "Admin" } } }, - "enabled": true - }, - { - "type": ["select"], - "allow": "loggedIn", - "exclude": ["Password", "Salary"], - "enabled": true - }, - { - "type": ["update"], - "allow": "loggedIn", - "require": [ - { "Email": { "equals": "{{user.[Email]}}" } } - ], - "exclude": ["Role", "Admin", "Permissions"], - "enabled": true - }, - { - "type": ["insert"], - "allow": "loggedIn", - "require": [ - "Email", - "First Name", - { "Email": { "equals": "{{user.[Email]}}" } }, - { "Role": { "equals": "User" } } - ], - "exclude": ["Admin", "Permissions"], - "enabled": true - } -] -``` -{% endraw %} - -**Queries that succeed:** - -When Alice (Admin) is logged in, the first rule matches — she has full access to all records and columns: - -```js -// JS API — Alice reads all records including sensitive fields -const connection = await Fliplet.DataSources.connectByName('Employees'); -const allRecords = await connection.find(); -// Returns all 3 records with all columns including Salary and Password -``` - -```plaintext -// REST API equivalent -POST /v1/data-sources/123/data/query -Auth-token: -Content-Type: application/json - -// "type" here is the REST API operation type, not a security rule field -{ "type": "select" } - -// Response (200 OK): all entries with all columns -``` - -When Bob (User) is logged in, the second rule matches for reads — he can see records but `Password` and `Salary` are excluded: - -```js -// JS API — Bob reads records (sensitive columns automatically excluded) -const connection = await Fliplet.DataSources.connectByName('Employees'); -const records = await connection.find(); -// Returns all 3 records, but each record is missing Password and Salary -// e.g. { Email: "alice@acme.com", "First Name": "Alice", Role: "Admin", Department: "Engineering", Admin: "Yes", Permissions: "all" } -``` - -Bob can update his own record (the third rule requires his email in the data): - -```js -// JS API — Bob updates his own name -const connection = await Fliplet.DataSources.connectByName('Employees'); -await connection.update(456, { - Email: 'bob@acme.com', - 'First Name': 'Robert' -}); -// Succeeds — Email matches Bob's session, and Role/Admin/Permissions are excluded from writes -``` - -```plaintext -// REST API equivalent -PUT /v1/data-sources/123/data/456 -Auth-token: -Content-Type: application/json - -{ "Email": "bob@acme.com", "First Name": "Robert" } - -// Response (200 OK): updated entry -``` - -**Queries that fail:** - -When a query is denied, the API responds with HTTP status **400** and a JSON error body. The `message` field changes based on the denied operation — for example, `"do not allow this app to read data"` for selects, or `"do not allow this app to insert data"` for inserts: - -```json -{ - "message": "The security rules for the Data Source \"Employees\" do not allow this app to read data.", - "type": "datasource.access", - "payload": { "dataSourceId": 123 } -} -``` - -In the JS API, the promise is rejected with this error object. The `message` adapts to include the data source name and the specific operation that was denied (read, insert, update, or delete). The `type` is always `"datasource.access"` and `payload.dataSourceId` identifies the data source. - -```js -// Bob tries to update another user's record — fails -// (Email does not match Bob's session, so the require condition is not met) -await connection.update(789, { - Email: 'carol@acme.com', - 'First Name': 'Carolina' -}); - -// Bob tries to escalate his own role — fails -// (Role is excluded from the update rule, so it cannot be written) -await connection.update(456, { - Email: 'bob@acme.com', - Role: 'Admin' -}); - -// Bob tries to insert a record with an admin role — fails -// (the insert rule requires Role: "User") -await connection.insert({ - Email: 'bob@acme.com', - 'First Name': 'Bob Clone', - Role: 'Admin' -}); - -// Bob tries to delete a record — fails -// (no delete rule matches for non-admin users) -await connection.removeById(789); -``` - -### Example: department-scoped access - -In this example, managers see all records for their department, regular users only see their own record, and inserts are restricted to the user's department. Department scoping is enforced through `require` conditions on the query, not through `allow.user` (which checks the user's identity, not the data being accessed). - -**Data source columns:** - -| Email | Name | Role | Department | Salary | ManagerNotes | -|---|---|---|---|---|---| -| alice@acme.com | Alice | Manager | Engineering | 150000 | Top performer | -| bob@acme.com | Bob | User | Engineering | 85000 | | -| carol@acme.com | Carol | User | Marketing | 95000 | | -| dave@acme.com | Dave | Manager | Marketing | 140000 | Needs training | - -**Security rules:** - -{% raw %} -```json -[ - { - "type": ["select"], - "allow": { - "user": { "Role": { "equals": "Manager" } } - }, - "require": [ - { "Department": { "equals": "{{user.[Department]}}" } } - ], - "exclude": ["Salary"], - "enabled": true - }, - { - "type": ["select", "update"], - "allow": "loggedIn", - "require": [ - { "Email": { "equals": "{{user.[Email]}}" } } - ], - "exclude": ["Salary", "ManagerNotes"], - "enabled": true - }, - { - "type": ["insert"], - "allow": "loggedIn", - "require": [ - "Name", - { "Department": { "equals": "{{user.[Department]}}" } }, - { "CreatedBy": { "equals": "{{user.[Email]}}" } } - ], - "exclude": ["Role", "Admin"], - "enabled": true - } + { "type": ["select"], "allow": "all" } ] ``` -{% endraw %} - -The first rule uses `allow.user` to check the user's role (a literal match — "is this user a Manager?") and `require` to enforce department scoping on the query (the `where` clause must include the user's own department). This combination ensures only managers can use the rule *and* they can only query their own department's data. - -**Queries that succeed:** - -When Alice (Manager, Engineering) is logged in, the first rule matches — she is a Manager, and her query includes `Department: 'Engineering'` which satisfies the `require` condition: - -```js -// JS API — Alice reads all Engineering records -const connection = await Fliplet.DataSources.connectByName('Staff'); -const records = await connection.find({ - where: { Department: 'Engineering' } -}); -// Returns Alice and Bob's records, excluding Salary -// e.g. { Email: "bob@acme.com", Name: "Bob", Role: "User", Department: "Engineering", ManagerNotes: "" } -``` - -```plaintext -// REST API equivalent -POST /v1/data-sources/123/data/query -Auth-token: -Content-Type: application/json - -{ "type": "select", "where": { "Department": "Engineering" } } - -// Response (200 OK): Alice and Bob's entries, Salary excluded -``` - -When Bob (User, Engineering) is logged in, the first rule does not match (wrong role). The second rule matches — he must filter by his own email: - -```js -// JS API — Bob reads his own record -const connection = await Fliplet.DataSources.connectByName('Staff'); -const records = await connection.find({ - where: { Email: 'bob@acme.com' } -}); -// Returns Bob's record only, excluding Salary and ManagerNotes -``` - -Bob can insert a record in his own department: - -```js -// JS API — Bob creates a record in Engineering -await connection.insert({ - Name: 'New Hire', - Department: 'Engineering', - CreatedBy: 'bob@acme.com' -}); -// Succeeds — all require conditions are met, Role and Admin are excluded from writes -``` - -```plaintext -// REST API equivalent (Fliplet uses PUT for both inserts and updates on this endpoint) -PUT /v1/data-sources/123/data -Auth-token: -Content-Type: application/json - -{ "Name": "New Hire", "Department": "Engineering", "CreatedBy": "bob@acme.com" } - -// Response (200 OK): new entry -``` - -**Queries that fail:** -```js -// Alice (Manager) tries to read Marketing records — fails -// (the require condition demands Department matching Alice's own department "Engineering"; -// "Marketing" does not match, so the first rule is skipped; no other rule grants broad access) -await connection.find({ - where: { Department: 'Marketing' } -}); +Use this only for data intended for anonymous readers. It grants no insert, update or delete operation. The [public lookup example](API/datasources/security-examples.md#public-lookup) pairs this rule with sample rows and SDK calls. -// Bob tries to read all records without filtering by email — fails -// (the second rule requires Email matching Bob's session; without a where clause, -// the require condition is not met, the rule is skipped, and no subsequent rule grants access) -await connection.find(); +## Choose the right guide -// Bob tries to read Alice's record — fails -// (Email does not match Bob's session) -await connection.find({ - where: { Email: 'alice@acme.com' } -}); - -// Bob tries to insert a record in Marketing — fails -// (Department does not match Bob's department "Engineering") -await connection.insert({ - Name: 'Spy', - Department: 'Marketing', - CreatedBy: 'bob@acme.com' -}); - -// Bob tries to insert with a different CreatedBy — fails -// (CreatedBy must equal Bob's email) -await connection.insert({ - Name: 'Fake', - Department: 'Engineering', - CreatedBy: 'alice@acme.com' -}); -``` - -## Data requirements and query validation - -The `require` property defines conditions that incoming queries must satisfy. This is not the same as querying data — it controls how queries are **assessed against the rule's data requirements**. - -

    If you add data requirements to your rules, Fliplet core components that query the data source (e.g., List from Data Source, Chart, Form) may stop working because they issue broad queries without the where clauses that require demands. You will need to either add a permissive rule (without require) scoped to those components' app IDs, or replace the component queries with custom code that satisfies the requirements.

    - - -For example, to allow a specific app's components to read without `require` constraints while keeping other apps locked down: - -```json -{ - "type": ["select"], - "allow": "loggedIn", - "appId": [456, 789], - "enabled": true -} -``` - -This rule grants read access to logged-in users only when the request originates from app 456 or 789. Place it before stricter rules so that the matching apps get broad access while other apps fall through to rules with `require` conditions. - -The behavior of `require` varies by operation type: - -- **For `select` and `delete` operations:** The client's query must include a `where` clause that satisfies all requirements, or the rule does not match. For example, if a rule requires `{ "Email": { "equals": "{{user.[Email]}}" } }`, every `find()` call must include `where: { Email: user.Email }` (or the equivalent `$eq` operator) for that rule to grant access. To obtain the user's session data client-side, use the [Session API](API/fliplet-session.html). If the requirements are not met, the rule is **skipped** (not rejected) — evaluation falls through to the next rule. This means a more permissive rule below could still grant access. Design your rule order carefully: place restrictive rules first and ensure no later rule inadvertently grants broader access than intended. -- **For `insert` and `update` operations:** Validates that the submitted data contains the required fields and values. If the data does not match, the write is **rejected immediately** — unlike reads, unmet requirements on a write do not fall through to the next rule. - -

    Key difference: Unmet require on a read = rule skipped, next rule evaluated. Unmet require on a write = request denied outright. This means a permissive read rule further down the list can still grant access, but a failed write requirement stops evaluation.

    - -### Data requirement types - -`require` accepts an array of strings (required field names) and objects (field conditions). Four requirement types are available: - -| Requirement type | Description | Query patterns that satisfy this requirement | -|---|---|---| -| Field is required | The query must include the specified column | Any query that provides the column as a key | -| Field equals | The query value for the column must match exactly | `{ Field: value }` or `{ Field: { $eq: value } }` | -| Field not equals | The query value for the column must not match the specified value | `{ Field: { $ne: value } }` or any query where the field's value differs from the specified value | -| Field contains | The query value for the column must contain the specified substring | `{ Field: value }` or `{ Field: { $iLike: value } }` or `{ Field: { $like: value } }` | - -

    These requirement types (equals, notequals, contains) are not query operators — they define how the security rule validates incoming queries. For the full list of query operators you can use when reading and writing data, see the query operators reference.

    - -### Handlebars templating - -Condition values can reference the logged-in user's session data using Handlebars syntax. This enables dynamic, per-user access scoping. Use bracket notation (e.g., `[Email]`) for field names — it is required for names with spaces or special characters (e.g., {% raw %}`{{user.[First Name]}}`{% endraw %}), and also works for simple names. Plain dot notation ({% raw %}`{{user.Email}}`{% endraw %}) works only for field names without spaces. - -- {% raw %}`{{user.[Email]}}`{% endraw %} — the user's email address -- {% raw %}`{{user.[Department]}}`{% endraw %} — the user's department -- {% raw %}`{{user.[Role]}}`{% endraw %} — the user's role -- {% raw %}`{{user.[ID]}}`{% endraw %} — the user's data source entry ID - -### Require syntax - -**Required fields** (string) — the query must include these columns: - -```json -"require": ["Email", "FirstName", "Department"] -``` +| Task | Guide | +|---|---| +| Understand rule order, identity, requirements and endpoint differences | [Security rule reference](API/datasources/security-rules.md) | +| Configure public, shared, private, profile or role/state data | [Security examples](API/datasources/security-examples.md) | +| Prove allowed and denied app behavior | [Authenticated preview testing](API/datasources/testing-security.md) | +| Connect, query or write through JavaScript | [Fliplet.DataSources reference](API/fliplet-datasources.md) | +| Configure authentication or hooks | [App authentication](API/v3/auth.md), [Session API](API/fliplet-session.md) and [Data source hooks](Data-Source-Hooks.md) | -**Field conditions** (object with operator) — the query must match these conditions: +## Check the policy's limits -{% raw %} -```json -"require": [ - { "Email": { "equals": "{{user.[Email]}}" } }, - { "Status": { "notequals": "Archived" } }, - { "Department": { "contains": "{{user.[Department]}}" } } -] -``` -{% endraw %} +Rules grant access in order. A failed rule falls through for writes as well as reads, so a later broad grant can defeat an earlier restriction. -You can mix both formats in the same array: +Object conditions on updates check the **stored row**, while string requirements check submitted key presence. A condition that checks stored `Owner` or `Status` does not prevent a permitted write from changing those fields. Single-record writes honor `exclude`, but bulk/commit writes do not provide the same field protection. Custom scripts receive a copied input; assigning audit fields to that copy does not reliably persist them. -{% raw %} -```json -"require": [ - "Title", - "Description", - { "CreatedBy": { "equals": "{{user.[Email]}}" } }, - { "Status": { "equals": "Draft" } } -] -``` -{% endraw %} +For immutable ownership or approval transitions, keep app-user writes unavailable until an implemented trusted write path has been verified to check both stored and proposed values on every supported endpoint. See [role and state limits](API/datasources/security-examples.md#role-and-state-access) before choosing a workflow policy. -For full examples of `require` in practice — including sample data, succeed queries, and fail queries — see [role-based access with protected fields](#example-role-based-access-with-protected-fields) and [department-scoped access](#example-department-scoped-access) above. +## Existing section links -## Custom security rules +These destinations retain links to the previous sections of this guide. -For advanced logic beyond what the standard rule properties support, you can write custom JavaScript security rules. + -![Custom security](assets/img/datasource-custom-security.png) +[Security rules](API/datasources/security-rules.md#evaluation-and-defaults) -The custom security rule editor in Fliplet Studio displays a code editor where you write JavaScript that controls access. The rule name and enabled toggle appear above the code area. + -

    Important: When a rule has a script, the script is the sole determinant of access. Standard rule fields like allow and type are ignored — the script runs regardless of login status or operation type. Your script must perform its own identity and operation checks (e.g., if (!user) return { granted: false };). If the script does not return a value (e.g., an unhandled operation type falls through without a return), access is denied by default.

    +[Access rule structure](API/datasources/security-rules.md#rule-properties) -When writing a custom rule, these variables are available in the script context: + -- `type` (String) — the operation the user is attempting: `select`, `insert`, `update`, or `delete` -- `user` (Object) — the authenticated user's session data, or `undefined` if not logged in. For data-source passport sessions (e.g., Email Verification, Fliplet Login), this contains the flat column values from the user's row in the authentication data source (e.g., `user.Email`, `user.Role`). For SAML2 sessions, it contains the assertion attributes. -- `query` (Object or Array) — **important: the shape of this variable changes depending on the operation type:** - - **`select`**: the unwrapped `where` object from the request (e.g., if the client sends `find({ where: { Email: "a@b.com" } })`, the rule receives `{ Email: "a@b.com" }`) - - **`insert`** / **`update`**: the data being written to the entry. When called via the `commit` endpoint, `query` is an array of entries — see [Checking data when committing changes](#checking-data-when-committing-changes) - - **`delete`**: the current data of the entry being deleted (populated by the server from the existing record, not sent by the client) -- `entry` (Object) — the existing entry being updated, with `id` (Number) and `data` (Object containing the entry's current column values) properties. `undefined` for other operation types -- `DataSources` (Function) — server-side library for reading data from other data sources. See [Reading data from other Data Sources](#reading-data-from-other-data-sources) +[Defining who can access](API/datasources/security-rules.md#identity-and-login-binding) -Your code should handle all relevant operation types. Here is an example: + -```js -// Always check for unauthenticated access first -if (!user) { - return { granted: false }; -} +[Restricting columns](API/datasources/security-rules.md#column-restrictions) -switch (type) { - case 'select': - // "query" is the unwrapped where object from the API request. - // If the client sends find() with no where clause, query may be empty. - return { granted: query && query.Department === user.Department }; + - case 'insert': - // "query" is the data being inserted. - // It can also be an array when committing multiple records at once. - if (Array.isArray(query)) { - return { granted: query.every(data => data.Department === user.Department) }; - } +[Example role based access with protected fields](API/datasources/security-examples.md#editable-profiles) - return { granted: query.Department === user.Department }; + - case 'update': - // "query" is the data being updated. - // It can also be an array when committing multiple records at once. - // "entry" is the existing record (with id and data properties) when applicable. - if (Array.isArray(query)) { - return { granted: query.every(data => data.Department === user.Department) }; - } +[Example department scoped access](API/datasources/security-examples.md#private-sketches) - return { granted: query.Department === user.Department && entry && entry.data.Active === true }; + - case 'delete': - // "query" is the data of the entry being deleted. - // It can also be an array when deleting multiple records at once. - if (Array.isArray(query)) { - return { granted: query.every(data => data.CreatedBy === user.Email) }; - } +[Data requirements and query validation](API/datasources/security-rules.md#requirements-by-operation) - return { granted: query.CreatedBy === user.Email }; -} -``` + -### Granting access +[Data requirement types](API/datasources/security-rules.md#requirement-operators) -Return an object with `granted: true` to grant access. You can also return an `exclude` or `include` array to restrict which columns the user can access: + -```js -if (type === 'select') { - if (!user) { - return { granted: false }; - } +[Handlebars templating](API/datasources/security-rules.md#identity-and-login-binding) - // Grant full access to admin users - if (user.Admin === 'Yes') { - return { granted: true }; - } + - // Grant access to any other logged-in user, but hide sensitive columns - return { granted: true, exclude: ['Phone', 'NextOfKin'] }; -} +[Require syntax](API/datasources/security-rules.md#requirement-operators) -// No further access is granted by this rule to other operation types -``` + -

    Important: You must return an object — bare boolean values (e.g., return true) are not supported and will be treated as a denial. Always use return { granted: true } or return { granted: false }.

    +[Custom security rules](API/datasources/security-rules.md#custom-scripts) -### Modifying the input query + -Rules can modify the `query` object to enforce constraints: +[Granting access](API/datasources/security-rules.md#custom-scripts) -```js -if (!user) { - return { granted: false }; -} + -if (type === 'select') { - // Only allow reading records for the same office as the user's - query.Office = user.Office; +[Modifying the input query](API/datasources/security-rules.md#script-inputs-and-mutation) - return { granted: true }; -} + -if (type === 'insert') { - // Forces writes to have the office field same as the user's - query.Office = user.Office; +[Checking data when committing changes](API/datasources/security-rules.md#script-inputs-and-mutation) - // Also generate a "CreatedAt" datetime field for all records added - query.CreatedAt = Date.now(); + - return { granted: true }; -} -``` - -### Checking data when committing changes - -When a data source is updated via the `commit` endpoint (or JS API), `query` is always an **array** of flat data objects — not a single object. The security rule runs separately for each operation type in the commit (inserts, updates, and deletes). - -

    Note: For all three operation types during a commit, query is an array of flat data objects (the entry's column values). For deletes, the server loads the existing entries and provides their data — you receive the same flat shape as inserts and updates, not an array of IDs.

    - -```js -if (type === 'insert') { - // "query" is the array of entries to insert - // e.g. [{ Name: "Alice", Department: "Engineering" }, { Name: "Bob", Department: "Marketing" }] -} else if (type === 'update') { - // "query" is the array of entry data being written - // e.g. [{ Name: "Alice Updated", Department: "Engineering" }, { Name: "Bob Updated", Department: "Marketing" }] -} else if (type === 'delete') { - // "query" is the array of existing entry data for the entries being deleted - // e.g. [{ Name: "Alice", Department: "Engineering" }, { Name: "Dave", Department: "Marketing" }] -} -``` - -### Reading data from other Data Sources - -Custom rules can read data from other Data Sources using the `find` (multiple records) and `findOne` (single record) methods of the `DataSources` server-side library. These reads run at **server level** and bypass all security rules on the target data source. - -

    Cross-data-source lookups add a database round-trip per request and are not cached. Keep queries efficient and use findOne when you only need a single record. Script execution has a 3-second timeout — if the script does not return within 3 seconds, access is denied. Async operations (like DataSources queries) are supported via await.

    - -Connect using the data source ID (number) or name (string): - -```js -if (!user) { - return { granted: false }; -} - -if (type === 'select') { - const entry = await DataSources(123).findOne({ - where: { - Office: user.Office, - Managers: { $in: user.Manager }, - Country: { $like: '%United Kingdom%' } - } - }); - - // Allow reading data if the user has a manager in the same office - if (entry) { - return { granted: true }; - } -} - -if (type === 'insert') { - const entries = await DataSources('Users').find(); - - // Only allow writes as long as there are fewer than 10 entries in the target Data Source - if (entries && entries.length < 10) { - return { granted: true }; - } -} -``` - -`find` returns an array of matching entries (empty array if none match). `findOne` returns a single entry object, or `null` if no match is found. - -Both methods accept the following properties: - -- `where` (Object) — query filter supporting [standard query operators](API/datasources/query-operators.html) such as `$eq`, `$ne`, `$like`, `$iLike`, `$gt`, `$gte`, `$lt`, `$lte`, `$in` -- `limit` (Number, defaults to `100`) -- `offset` (Number, defaults to `0`) - -

    Use DataSources('Name') (data source name) instead of DataSources(123) (ID) in custom scripts. Data source names are preserved during app clone, so name-based lookups continue to work without manual remapping.

    +[Reading data from other data sources](API/datasources/security-rules.md#reading-another-data-source) --- @@ -38616,6 +38163,8 @@ URL: https://developers.fliplet.com/REST-API/fliplet-ai.md Use these app-scoped endpoints to generate text or images, transcribe audio and create embeddings through Fliplet. Requests use a Fliplet token and the selected provider's payload format; a customer provider key is not required. +These endpoints run models through Fliplet’s server API and require an internet connection. They do not provide an on-device model or offline inference. + ## Contents - [Before your first request](#before-your-first-request) @@ -38657,6 +38206,8 @@ For code running inside an app, use [Fliplet.AI](/API/core/ai), which supplies t Use exactly one input format with an explicit compatible `model`. Non-streaming success returns the provider JSON directly, without a Fliplet response wrapper. +This endpoint does not return an SSE stream. With `stream: true`, HTTP acknowledges the request and Fliplet sockets deliver the generated content; see the [streaming boundary](#streaming-boundary). The external examples below use non-streaming requests. + | Format | Required fields | Plain text in the response | | --- | --- | --- | | OpenAI Chat Completions | `model`, `messages` | `choices[0].message.content` | diff --git a/docs/.well-known/llms.txt b/docs/.well-known/llms.txt index b3483a99..f89b46cc 100644 --- a/docs/.well-known/llms.txt +++ b/docs/.well-known/llms.txt @@ -83,6 +83,9 @@ - [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. - [Data Sources query operators](https://developers.fliplet.com/API/datasources/query-operators.md): Filter Data Source queries with MongoDB/Sift operators ($eq, $gt, $in, $regex, $and, $or) inside connection.find() where clauses. +- [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, private, profile and role-based access rules with data shapes and allowed and denied SDK operations. +- [Data source security rule reference](https://developers.fliplet.com/API/datasources/security-rules.md): Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior. +- [Testing data source security in preview](https://developers.fliplet.com/API/datasources/testing-security.md): Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled. - [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. ## Integrations @@ -212,7 +215,7 @@ - [Fliplet infrastructure data flow](https://developers.fliplet.com/Data-flow.md): Architecture diagram showing how data flows between Fliplet Studio, Fliplet servers, app clients, and connected data sources. - [Data integration service](https://developers.fliplet.com/Data-integration-service.md): Fliplet Agent (Data integration service) is a command line utility to synchronize data to and from Fliplet Servers. - [Data Source Hooks](https://developers.fliplet.com/Data-Source-Hooks.md): Trigger emails, SMS, push notifications, web requests, or column operations on Data Source insert/update/beforeSave/beforeQuery using Sift.js match conditions. -- [Securing Fliplet data sources](https://developers.fliplet.com/Data-source-security.md): Secure your Data Sources with access rules, data requirements, and custom JavaScript security rules. +- [Securing Fliplet data sources](https://developers.fliplet.com/Data-source-security.md): Configure data source access rules and verify allowed and denied operations as authenticated app users. - [Dependencies and assets](https://developers.fliplet.com/Dependencies-and-assets.md): Declare package dependencies and bundled JS/CSS/font assets for Fliplet components and themes, with separate interface and build entries plus appAssets. - [Sending events between components](https://developers.fliplet.com/Event-emitter.md): Components interfaces can send events to Fliplet Studio using a event emitter bus provided with the `fliplet-core` dependency. - [Fliplet app execution flow](https://developers.fliplet.com/Execution-flow.md): The rendering and hook lifecycle Fliplet apps go through before a screen is shown to the user, and where to safely run custom code within it. diff --git a/docs/API/datasources/security-examples.md b/docs/API/datasources/security-examples.md new file mode 100644 index 00000000..983c00f9 --- /dev/null +++ b/docs/API/datasources/security-examples.md @@ -0,0 +1,234 @@ +--- +title: Data source security examples +description: "Pair public, shared, private, profile and role-based access rules with data shapes and allowed and denied SDK operations." +type: guide +tags: [js-api, datasources, security] +v3_relevant: true +deprecated: false +--- + +# Data source security examples + +Use these policies with their stated data shapes and operation limits. Each example pairs the complete rule array with app SDK calls and outcomes to verify with security enforcement enabled. + +## Before you start + +Configure each data source's columns and sample rows in Studio before running the app code. For authenticated examples, configure a **Members** login source using the [authentication guide](../v3/auth.md). Replace illustrative ID `731` in every bound rule with its actual ID. Seed two test identities whose flat authenticated data includes: + +```json +[ + { "Email": "ada@example.org", "Enabled": true, "Roles": ["Author", "Approver"] }, + { "Email": "lin@example.org", "Enabled": true, "Roles": ["Author"] } +] +``` + +`Enabled` is a native boolean, and `Roles` is an array of strings. Protect those identity fields from self-service writes. Columns are names, not enforced types: verify actual stored values. Use disposable test sources and record allowed and denied results using the [preview procedure](testing-security.md). Outcomes below describe the policy contract, not a substitute for authenticated runtime tests. + +SDK query results have `{ id, data }` envelopes. Obtain current fixture IDs from Studio rather than guessing IDs. These plain JavaScript examples use `await` and run inside an app with the Data Sources library available. Authentication happens through the app's configured login flow before these calls. + +## Public lookup + +**Goal:** Anonymous readers can browse a public instrument catalog. App users cannot change it. + +Create **Instrument Catalog** with columns `Instrument` and `Family`, seeded with: + +```json +[ + { "Instrument": "Oboe", "Family": "Woodwind" }, + { "Instrument": "Cello", "Family": "Strings" } +] +``` + +Save this entire rule array: + +```json +[ + { "type": ["select"], "allow": "all" } +] +``` + +Allowed as an anonymous app user: + +```js +const catalog = await Fliplet.DataSources.connectByName('Instrument Catalog', { offline: false }); +const instruments = await catalog.find(); +console.log(instruments.map(entry => entry.data.Instrument)); +// Expected: ['Oboe', 'Cello'] (order is illustrative). +``` + +Denied as either an anonymous or signed-in app user: + +```js +const catalog = await Fliplet.DataSources.connectByName('Instrument Catalog', { offline: false }); +let denied = false; +try { + await catalog.insert({ Instrument: 'Flute', Family: 'Woodwind' }); +} catch (error) { + denied = true; + console.log(error); // Inspect the denial; then confirm no Flute row persisted. +} +if (!denied) throw new Error('Unexpected grant: verify the saved policy and preview mode.'); +``` + +Update and delete are also denied because no rule grants them. Studio administrators may still seed the catalog; that success is separate from app-user evidence. + +## Shared signed-in notices + +**Goal:** Members from the intended login source can read and add shared notices. They cannot update or delete existing notices. + +Create **Rehearsal Notices** with columns `Message` and `Venue`, seeded with `{ "Message": "Bring a music stand", "Venue": "Hall B" }`. Save: + +```json +[ + { + "type": ["select"], + "allow": { "loggedIn": true, "dataSourceId": 731 } + }, + { + "type": ["insert"], + "allow": { "loggedIn": true, "dataSourceId": 731 }, + "require": ["Message", "Venue"] + } +] +``` + +Allowed after Ada signs in through Members: + +```js +const notices = await Fliplet.DataSources.connectByName('Rehearsal Notices', { offline: false }); +await notices.insert({ Message: 'Doors open at six', Venue: 'Hall A' }); +const rows = await notices.find({ where: { Venue: 'Hall A' } }); +console.log(rows.map(entry => entry.data)); +``` + +Denied tests: sign out and call `find()` or `insert()`; sign in through another login source and repeat. As Ada, submit `insert({ Message: 'Missing venue' })`, or update/delete a seeded notice. Each must reject and leave no forbidden persisted effect. The string requirements check presence only: `Venue: null` is not a nonempty validation failure under this policy. + +## Private sketches + +**Goal:** An enabled member reads, creates and deletes sketches scoped to their email. This example deliberately grants no updates, keeping ownership immutable through the app operations it permits. + +Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: + +```json +[ + { "OwnerEmail": "ada@example.org", "Title": "Morning motif" }, + { "OwnerEmail": "lin@example.org", "Title": "Evening variation" } +] +``` + +Save: + +{% raw %} +```json +[ + { + "type": ["select", "insert", "delete"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] + } +] +``` +{% endraw %} + +Allowed as Ada: + +```js +const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); +const mine = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); +console.log(mine.map(entry => entry.data.Title)); // ['Morning motif'] +await sketches.insert({ OwnerEmail: 'ada@example.org', Title: 'Second motif' }); +``` + +Use the fixture ID from Studio to test `removeById(adaSketchId)` on Ada's disposable row; the stored owner grants deletion. Denied as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })`, inserting Lin's email or deleting Lin's fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied. No update is granted, even on Ada's own row. + +The query rule validates `where`; it does not add an owner filter for you. A record read with `findById(id)` instead evaluates the stored row. Repeat both paths when the app uses both. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. + +## Editable profiles + +**Goal:** An enabled member updates their stored profile through single-record writes while excluded fields remain unchanged. This example does not guarantee protection on bulk/commit writes. + +Create **Performer Profiles** with columns `OwnerEmail`, `DisplayName` and `Badge`. Seed: + +```json +[ + { "OwnerEmail": "ada@example.org", "DisplayName": "Ada", "Badge": "Soloist" }, + { "OwnerEmail": "lin@example.org", "DisplayName": "Lin", "Badge": "Member" } +] +``` + +For immutable ownership or protected badges across **all** endpoints, leave app-session updates disabled until an implemented trusted write path has been verified against source and runtime tests. This example does not provide that guarantee: bulk/commit updates can write fields that single-record `exclude` filters out. Omitting bulk methods from the UI is not server enforcement. A script's `query` can also come from caller-supplied `body.where`, so proposed-payload validation cannot be assumed even when `entry.data` exists. + +Save: + +{% raw %} +```json +[ + { + "type": ["select", "update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], + "exclude": ["OwnerEmail", "Badge"] + } +] +``` +{% endraw %} + +For the explicitly limited single-record example, copy Ada's and Lin's fixture IDs from Studio and run as Ada: + +```js +async function verifySingleProfileWrite(adaProfileId, linProfileId) { + const profiles = await Fliplet.DataSources.connectByName('Performer Profiles', { offline: false }); + await profiles.update(adaProfileId, { DisplayName: 'Ada L.' }); + const saved = await profiles.findById(adaProfileId); + console.log(saved.data.DisplayName); // 'Ada L.' + let denied = false; + try { + await profiles.update(linProfileId, { OwnerEmail: 'ada@example.org', DisplayName: 'Changed' }); + } catch (error) { + denied = true; + console.log(error); // Stored Lin ownership does not match Ada. + } + if (!denied) throw new Error('Unexpected grant on another member’s profile.'); +} +``` + +Also submit a single update of Ada's row with `Badge: 'Director'` and `OwnerEmail: 'lin@example.org'`. It can resolve successfully, but those excluded fields must remain unchanged in a trusted Studio readback; the read rule hides them from the SDK response. Anonymous or wrong-source updates, insert and delete must be denied. This distinguishes a rejected operation from a filtered field. + +## Role and state access + +**Goal:** An enabled Approver can update an existing Pending review. This demonstrates stored-state authorization only, not a complete approval workflow. + +Create **Festival Reviews** with columns `WorkTitle`, `Stage` and `Comment`. Seed `{ "WorkTitle": "River quartet", "Stage": "Pending", "Comment": "" }`. + +This policy permits changing Pending directly to any next stage. Do not use it as approval-transition enforcement. Leave strict workflow writes disabled until an implemented trusted write path and actor-operation tests verify caller role, stored stage, proposed stage and protected fields across all allowed endpoints. + +Save this illustrative stored-state policy: + +```json +[ + { + "type": ["select"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } } + }, + { + "type": ["update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true, "Roles": "Approver" } }, + "require": [{ "Stage": { "equals": "Pending" } }] + } +] +``` + +As Ada (`Roles: ['Author', 'Approver']`), a single update of the seeded Pending review's `Comment` is allowed: + +```js +async function addReviewComment(reviewId) { + const reviews = await Fliplet.DataSources.connectByName('Festival Reviews', { offline: false }); + await reviews.update(reviewId, { Comment: 'Ready for rehearsal' }); + const saved = await reviews.findById(reviewId); + console.log(saved.data.Comment); // 'Ready for rehearsal' +} +``` + +Denied tests: Lin (`Roles: ['Author']`), a user with only `['SuperApprover']`, an inactive user or an anonymous caller attempting that update. Ada updating a seeded `Stage: 'Closed'` row must also be denied. Insert and delete have no grant. + +**Transition limit:** Ada can also submit `Stage: 'Published'` while the stored row is Pending. The requirement does not validate the next stage. Do not deploy this example as an approval-transition policy. A strict workflow needs server checks of caller role, stored stage, proposed stage and protected fields on every allowed write path. A single-update script can inspect `entry.data`, but commit lacks `entry`, and even a single-update request can supply `body.where` instead of the proposed payload to the script. Keep strict workflow writes unavailable until an implemented trusted write path and its actor-operation tests establish the required contract. Frontend guards and rule-query mutation do not supply that guarantee. diff --git a/docs/API/datasources/security-rules.md b/docs/API/datasources/security-rules.md new file mode 100644 index 00000000..40966e0d --- /dev/null +++ b/docs/API/datasources/security-rules.md @@ -0,0 +1,183 @@ +--- +title: Data source security rule reference +description: "Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior." +type: reference +tags: [js-api, datasources, security] +v3_relevant: true +deprecated: false +--- + +# Data source security rule reference + +Data source access rules grant app operations using identity, request and stored-record conditions. This reference distinguishes the rule evaluator from endpoint-specific reads and writes. + +## Contents + +- [Evaluation and defaults](#evaluation-and-defaults) +- [Rule properties](#rule-properties) +- [Identity and login binding](#identity-and-login-binding) +- [Requirements by operation](#requirements-by-operation) +- [Column restrictions](#column-restrictions) +- [Custom scripts](#custom-scripts) +- [Errors](#errors) + +## Evaluation and defaults + +Active rules are evaluated in order. The first grant wins; disabled rules, nonmatching actors and failed requirements are skipped. This fallthrough applies to insert, update and delete as well as select. A denied script does not veto a later grant. + +Absent or null `accessRules` retains legacy unrestricted access. An explicit empty array `[]`, or an array with no applicable grant, denies access. Studio administrative access and preview with enforcement disabled can bypass rules; use [authenticated preview testing](testing-security.md) to prove app access. + +## Rule properties + +| Property | Shape | Behavior | +|---|---|---| +| `type` | Array of operation strings | `select`, `insert`, `update`, `delete`. A nonempty `script` runs independently of this filter. | +| `allow` | String or object | `all`, `loggedIn`, a user condition, login-source gate or token-ID gate. A script must make its own access decision. | +| `enabled` | Boolean | `false` skips the rule. Omission leaves it active. | +| `appId` | Array of numbers | Limits the rule to those apps, including the corresponding cloned/production app relationship. Applies to script rules too. | +| `require` | Array of strings or objects | All requirements must match their operation-specific target. | +| `include`, `exclude` | Arrays of column names | Endpoint-specific column restrictions, described below. | +| `name` | String | Descriptive Studio label. | +| `script` | String | JavaScript returning an object with `granted: true` to grant. | + +## Identity and login binding + +`allow.user` checks the authenticated user's data, not the target record. The data-source identity is the signed-in row's flat column values plus its entry metadata; SAML identity comes from the authenticated assertion. Client-written session fields must not be used as proof of identity. A bound data-source rule uses the same source identity for its user gate and templated requirements. + +Use the actual authentication data source ID in `dataSourceId` (one positive integer or an array of IDs). For example, after configuring authentication against your **Members** source with ID `731`, this gate grants reads to users signed in through that source: + +```json +[ + { "type": ["select"], "allow": { "loggedIn": true, "dataSourceId": 731 } } +] +``` + +`"loggedIn"` alone accepts a supported authenticated login without requiring this particular source. An API-token gate uses `{ "tokens": [tokenId] }`; these are token IDs, not token secrets. + +### Typed user conditions + +Literal boolean and numeric user conditions compare native values. Preserve the actual identity types: + +```json +{ + "type": ["select"], + "allow": { + "dataSourceId": 731, + "user": { "Enabled": true, "Clearance": 7, "Roles": "Approver" } + } +} +``` + +For textual comparisons, `equals` compiles a case-insensitive anchored, escaped regular expression. It matches text and string elements of arrays, not native boolean or number values. `{ "Enabled": { "equals": true } }` therefore does not match native `true`. `contains` is a substring check: it matches both `Approver` and `SuperApprover`, including string elements in role arrays. Use a literal `"Approver"` or `{ "equals": "Approver" }` for exact role values. Both match `["Author", "Approver"]`; `contains` is unsuitable for exact role membership. + +Multiple fields in `allow.user` are AND conditions. Use separate rules for alternative grants. Object conditions support `equals`, `notequals` and `contains`; do not invent query operators in this rule syntax. + +### Identity templates + +{% raw %} +`{{user.[Email]}}` resolves the signed-in identity field; brackets also support names with spaces. Use it in `require` to compare a record or query to the caller. Comparing a user's own field to that same template inside `allow.user` does not restrict the actor. Missing identity must deny owner-scoped access; test missing, null and blank fields rather than relying on template output. +{% endraw %} + +Authentication setup and client-side session retrieval belong to the [authentication guide](../v3/auth.md) and [Session API](../fliplet-session.md). + +## Requirements by operation + +String requirements check **key presence**, not a nonempty value or type. For example, `"require": ["Label"]` can accept a submitted `Label: null`. Object requirements check values using the operators below. These two forms have different targets on updates. + +| Operation/path | String requirement target | Object requirement target | +|---|---|---| +| `find()` / `findOne()` query | Submitted `where` keys or supported `$filters` column conditions | Submitted query filter, not every returned row | +| `findById()` record read | Stored record keys | Stored record values | +| Single `insert()` | Submitted keys | Submitted data | +| Single `update()` | Submitted keys | **Stored row**, not proposed data | +| Bulk/commit insert | Keys in every submitted data object | Every submitted data object | +| Bulk/commit update | Keys in every submitted data object | Every loaded stored row; no stored context means the condition cannot grant | +| Single `removeById()` | Stored record keys | Stored record values | +| Query writes | Depends on the request's query and operation checks | Query admission and affected-record checks differ; do not infer access from a single-record test | +| Commit delete | Existing data for the requested IDs | Existing data for the requested IDs | + +A query read requirement checks the client's filter; it does not inject a filter. Send the appropriate `where` clause. `findOne({ where: ... })` follows the query route, whereas `findById(id)` follows the record route and cannot be assumed equivalent. Additional source permissions can reject either route. + +### Requirement operators + +{% raw %} +```json +[ + { + "type": ["select", "update"], + "allow": { "loggedIn": true, "dataSourceId": 731 }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] + } +] +``` +{% endraw %} + +| Operator | Meaning | +|---|---| +| `equals` | Case-insensitive anchored text comparison; query validation also recognizes `$eq` and matching equality `$filters`. | +| `notequals` | Inequality comparison; it is not an existence or nonempty check. | +| `contains` | Substring comparison; query validation recognizes direct strings and relevant `$eq`, `$like`, `$iLike` or equality `$filters` values. | + +Only these three object operators are supported. They are separate from [query operators](query-operators.md). On updates the example authorizes the stored owner's row, but it does **not** prohibit changing `OwnerEmail`. The [examples guide](security-examples.md) shows that limitation beside the write. + +## Column restrictions + +Column restrictions do not have a uniform write contract: + +| Path | `exclude` | `include` | +|---|---|---| +| Select query/record response | Removes excluded data columns | Restricts returned data columns after exclusion; a field removed by `exclude` is not restored by `include` | +| Single insert/update | Removes submitted excluded fields before persistence; this is filtering, not necessarily a rejected request | Does not reliably filter the persisted single-write payload | +| Bulk append/replace/commit | Does not filter each persisted entry | Does not filter each persisted entry | +| Query update | Has separate affected-record filtering behavior | Do not assume single-write or bulk behavior | + +A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. + +## Custom scripts + +A nonempty script runs regardless of `type` and `allow`, subject to the enabled/app filters. It must check operation, authenticated identity and login source itself. Return `{ granted: true }` to grant; bare booleans, missing returns and `{ granted: false }` do not grant. A script grant returns immediately for that rule, so standard `require` is not an additional payload validator. + +```js +// A read-only script for a bound login source; replace 731 with its actual ID. +if (!user || !session || session.dataSourceId !== 731 || type !== 'select') { + return { granted: false }; +} +return { granted: true, exclude: ['PrivateNotes'] }; +``` + +The returned `include`/`exclude` use the endpoint limits above. Put a script into the custom-rule editor; it is server-side rule code, not browser JavaScript. + +### Script inputs and mutation + +| Variable | Shape | +|---|---| +| `type` | Operation string: `select`, `insert`, `update` or `delete` | +| `user` | Authenticated flat identity object, or `undefined` | +| `session` | Read-only verified login context with `loggedIn`, `passports`, `dataSourceId` and `entryId`; data-source IDs are absent for other login types | +| `query` | A **shallow copy** selected in this order: request `body.where`, supplied entry data, request body | +| `entry` | Stored `{ id, data, ... }` for a single update when supplied; absent for commit and initial query evaluation | +| `DataSources` | Server-side lookup library described below | + +An online query read normally receives the unwrapped `where`. A single insert/update normally receives flat submitted data. A commit receives arrays of flat data for each operation; deletes receive loaded existing data. Query writes can receive `body.where` instead of proposed data even during affected-record checks. Do not treat `query` as a universal proposed payload or assume `entry` exists on every update. + +Assigning `query.OwnerEmail` or `query.UpdatedAt` does not establish a persisted transformation. The rule's copied query is not a supported audit-stamping or filter-injection contract. Validate inputs or deny access; do not advertise mutation as server-enforced stamping. The script VM has a 3,000 ms synchronous execution timeout; this is not a guaranteed end-to-end deadline for awaited lookups. + +### Reading another data source + +`DataSources(idOrName).find(options)` and `.findOne(options)` perform server-side reads without applying the target source's access rules. A lookup may therefore expose fields ordinary app users cannot read. Use it only for the minimum policy decision, and do not return its contents to clients. + +```js +if (!user || !session || session.dataSourceId !== 731 || type !== 'select') { + return { granted: false }; +} +const membership = await DataSources('Reading Groups').findOne({ + where: { Email: user.Email, Group: 'Curators' } +}); +return { granted: !!membership }; +``` + +These methods return **flat data**: `find` returns an array and `findOne` returns a flat object or `undefined`. They do not return SDK `{ id, data }` envelopes. Options are `where`, `limit` (up to 100, default 100) and `offset` (default 0). See [query operators](query-operators.md) for supported filters. + +## Errors + +A denied operation rejects the SDK promise. Standard access denials may expose `type: "datasource.access"` with `payload.dataSourceId`, but error shape and HTTP status depend on the endpoint and caller. Query/record reads commonly return HTTP 400; single insert/update access checks can return HTTP 401. The insert denial message uses “write”, while update and delete use their respective operations. Scripts can surface a custom-rule error. Do not classify every rejected promise, HTTP 400 or HTTP 401 as a policy denial: inspect the response and follow [preview diagnosis](testing-security.md#diagnose-a-failure). diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md new file mode 100644 index 00000000..df359ff7 --- /dev/null +++ b/docs/API/datasources/testing-security.md @@ -0,0 +1,84 @@ +--- +title: Testing data source security in preview +description: "Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled." +type: how-to +tags: [js-api, datasources, security, testing] +v3_relevant: true +deprecated: false +--- + +# Testing data source security in preview + +Test data access as the intended app identity with Studio's preview security enforcement enabled. A rendered screen, a successful administrative query or a preview with enforcement disabled does not prove app-user authorization. + +## Prepare a disposable test + +Create dedicated test data sources and records. Record the saved rule array, login source, actual field types and test record IDs. Build an actor-operation table before testing: + +| Actor | Operation | Expected result | +|---|---|---| +| Anonymous | Read private data | Denied | +| Bound enabled member | Read their record | Allowed | +| Bound enabled member | Change another member's record | Denied | +| Wrong-source login | Read protected data | Denied | +| Inactive member | Protected write | Denied | +| Approver with multiple roles | Supported review operation | Allowed | +| Member without required role | Same operation | Denied | + +Adapt the matrix to the configured policy. Include missing/null identity fields, different native types and each write method your app uses. For protected ownership, privilege or state fields, attempt malicious payloads directly through the SDK as well as through the UI. A hidden button does not stop an SDK call. + +## Enable enforcement and sign in + +1. Record the initial preview **Enforce security** setting. +2. Open preview settings and enable **Enforce security**. Wait for the preview to reload before continuing. +3. Use the app's configured login flow to sign in as the test actor. Confirm the identity and login source; a Studio account alone is not the app-user identity. Sign out between actors and test the anonymous case too. +4. Confirm the reloaded preview environment is consistent with enforcement enabled: + +```js +console.log({ + appId: Fliplet.Env.get('appId'), + disableSecurity: Fliplet.Env.get('disableSecurity'), + interact: Fliplet.Env.get('interact'), + development: Fliplet.Env.get('development') +}); +// Require disableSecurity === false, interact === false and development !== true. +// Missing/unknown enforcement state is unverified. +``` + +Also confirm the UI setting is enabled and the preview is outside editor interaction mode. Interaction mode can bypass data source enforcement. A stale document or an unknown setting is unverified. Do not log tokens or copy token-bearing preview URLs into reports. + +## Run allowed and denied operations + +Use an online connection to avoid counting cached reads or queued writes as server proof: + +```js +const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); +const rows = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); +console.log(rows.map(entry => ({ id: entry.id, data: entry.data }))); +``` + +Run this only after configuring the matching [private sketches policy](security-examples.md#private-sketches) and signing in as Ada. Record the successful server response and returned rows. Repeat with another actor's filter and confirm the authorization denial. + +For every allowed write, perform a fresh read and check the exact saved values. For every forbidden write, inspect the rejection and verify that no forbidden insert, update or delete persisted. Use a trusted Studio readback where the app's read rules hide the affected fields; label it as persistence evidence, not an app-user access test. + +Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate single-record `exclude` behavior to bulk writes. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. + +## Diagnose a failure + +A policy denial must be supported by the error response and unchanged forbidden state. An HTTP 400 or 401 alone is insufficient because errors vary by method and caller. + +| Symptom | Check | +|---|---| +| Access denial / `datasource.access` | Saved rule order, operation, login binding, field casing/types and the correct requirement target | +| Empty results | Actual filter and fixtures; an allowed empty read is different from a denied request | +| HTTP 429 | Rate-limit response and retry interval; do not classify it as a rule failure | +| Login/loading screen | Complete authentication before testing data access | +| Network/transport error | Request completion and response body; authorization remains unverified | +| Successful update but field unchanged | Single-write `exclude` may filter the submitted field rather than reject | +| Success only with enforcement off | Rerun with enforcement on and the intended identity | + +Use the [rule reference](security-rules.md) for evaluator and endpoint differences, and the [authentication guide](../v3/auth.md) for sign-in setup. Hooks remain documented in [Data source hooks](../../Data-Source-Hooks.md). + +## Finish and restore preview + +Delete the disposable test records through an authorized test/admin path. Restore the original **Enforce security** setting and wait for a fresh reload. Confirm the effective setting before reporting restoration; a failed reload is unverified. Preserve a compact allowed/denied result table with outstanding gaps, without credentials or private data. diff --git a/docs/API/fliplet-datasources.md b/docs/API/fliplet-datasources.md index c1ccad2a..461ab362 100644 --- a/docs/API/fliplet-datasources.md +++ b/docs/API/fliplet-datasources.md @@ -12,6 +12,12 @@ capabilities: [data source, datasource, query, insert, update, delete, crud, rec Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +## Security and verification + +Configure [data source access rules](../Data-source-security.md) before exposing private data. Use the [rule reference](datasources/security-rules.md), [complete policy examples](datasources/security-examples.md) and [authenticated preview tests](datasources/testing-security.md) alongside this method reference. + +Query reads (`find`/`findOne`) and record reads (`findById`) evaluate different requirement targets. Object update requirements check stored rows, not proposed values. Single and bulk writes have different column restrictions; a successful administrative request or cached/offline result does not establish app-user authorization. + ## ⚠️ Important: All API Calls Are Asynchronous **All Fliplet Data Sources API methods return Promises and must be chained using `.then()` or used with `async/await`.** @@ -953,7 +959,8 @@ const newDataSource = await Fliplet.DataSources.create({ } ], - // Set permissions + // Public sample data only: grants every app user all four operations. + // Use a scoped policy from the security guide for private data. accessRules: [ { type: ['select', 'insert', 'update', 'delete'], allow: 'all' } ] diff --git a/docs/Data-source-security.md b/docs/Data-source-security.md index 8d7e13b4..11504d25 100644 --- a/docs/Data-source-security.md +++ b/docs/Data-source-security.md @@ -1,693 +1,111 @@ --- title: Securing Fliplet data sources -description: "Secure your Data Sources with access rules, data requirements, and custom JavaScript security rules." +description: "Configure data source access rules and verify allowed and denied operations as authenticated app users." type: how-to -tags: [data, source, security] +tags: [js-api, datasources, security] v3_relevant: true deprecated: false --- # Securing Fliplet data sources -How to protect data sources with access rules, required-data validation, column-level restrictions, and custom JavaScript security rules. +Protect app data with server-evaluated access rules, then test the allowed and denied operations as real app users. Rules control access; browser checks and successful Studio administrative requests do not prove authorization. -## Contents +## Configure a policy -- [Security rules](#security-rules) -- [Access rule structure](#access-rule-structure) - - [Defining who can access](#defining-who-can-access) - - [Restricting columns](#restricting-columns) - - [Example: role-based access with protected fields](#example-role-based-access-with-protected-fields) - - [Example: department-scoped access](#example-department-scoped-access) -- [Data requirements and query validation](#data-requirements-and-query-validation) - - [Data requirement types](#data-requirement-types) - - [Handlebars templating](#handlebars-templating) - - [Require syntax](#require-syntax) -- [Custom security rules](#custom-security-rules) - - [Granting access](#granting-access) - - [Modifying the input query](#modifying-the-input-query) - - [Checking data when committing changes](#checking-data-when-committing-changes) - - [Reading data from other Data Sources](#reading-data-from-other-data-sources) +1. Open **App Data** in Fliplet Studio, select the data source and open **Access Rules**. +2. Define the actors, operations and fields that need protection. Start with an explicit empty rules array (`[]`) for a source that should deny app access until configured. Missing or null rules retain legacy unrestricted access. +3. Choose a [complete security example](API/datasources/security-examples.md) and adapt its data shape and login-source binding. Save the rules, then read back the configuration. +4. Test both allowed and denied operations with [Enforce security enabled in preview](API/datasources/testing-security.md). Confirm persisted results using a fresh read. -## Security rules - -Access to Data Sources is secured via the **Access Rules** tab of the **App Data** section in Fliplet Studio. Each rule controls who can access the data source, what operations they can perform, and what conditions must be met. - -Rules are evaluated from top to bottom. The first rule that grants access is used and evaluation stops — later rules are not checked. If no rules match, access is denied by default. If no rules are defined at all, all access is denied — data sources are locked down until you explicitly add rules. - -Note that for read operations, a rule with unmet `require` conditions is **skipped**, not denied — evaluation continues to the next rule. For writes, unmet `require` is a hard rejection. See [Data requirements](#data-requirements-and-query-validation) for details. - -## Access rule structure - -Each access rule is a JSON object with the following properties: - -| Property | Type | Required | Description | -|---|---|---|---| -| `type` | Array of strings | Yes | Operations this rule applies to: `"select"`, `"insert"`, `"update"`, `"delete"` | -| `allow` | String or Object | Yes | Who can access: `"all"`, `"loggedIn"`, `{ "user": {...} }`, or `{ "tokens": [...] }` | -| `enabled` | Boolean | No | Whether the rule is active (defaults to `true`). Set to `false` to disable the rule without deleting it — disabled rules are skipped during evaluation | -| `include` | Array of strings | No | Whitelist of accessible columns. If both `include` and `exclude` are present, `include` takes precedence | -| `exclude` | Array of strings | No | Blacklist of hidden columns. Ignored if `include` is also present | -| `require` | Array | No | Data requirements that must be met (see [Data requirements](#data-requirements-and-query-validation)) | -| `appId` | Array of numbers | No | Restrict this rule to specific app IDs (applies to all apps if omitted) | -| `name` | String | No | Descriptive label for identifying the rule in Studio | -| `script` | String | No | Custom JavaScript code for advanced security logic. When present, `allow` and `type` are ignored — see [Custom security rules](#custom-security-rules) | - -### Defining who can access - -The `allow` property supports four modes: - -**All users** (including anonymous): +A minimal public read-only policy is: -```json -{ "allow": "all" } -``` - -**Logged-in users only:** - -```json -{ "allow": "loggedIn" } -``` - -**Specific users** (filtered by session data). `allow.user` checks the logged-in user's **identity** — use it with literal values to control *who* can access. To control *which records* they can access, use `require` instead (see [Data requirements](#data-requirements-and-query-validation)): - -```json -{ - "allow": { - "user": { - "Role": { "equals": "Admin" } - } - } -} -``` - -User filters support three operators: `equals`, `notequals`, and `contains`. Multiple conditions in the same `user` object are combined with AND logic. For OR logic, create separate rules instead. - -

    Important: allow.user conditions check the logged-in user's session data — not data source records. Always use literal values here (e.g., "Admin", "Manager"). Although Handlebars syntax is supported, using it in allow.user (e.g., {% raw %}{{ user.[Department] }}{% endraw %}) compares the user's field against itself — always true, granting access to any logged-in user. Handlebars templates are useful in require conditions, where they scope queries by the user's data. See Data requirements.

    - -{% raw %} -```json -{ - "allow": { - "user": { - "Role": { "equals": "Manager" }, - "Status": { "notequals": "Inactive" } - } - } -} -``` -{% endraw %} - -**Specific API token:** - -```json -{ - "allow": { - "tokens": [42857] - } -} -``` - -### Restricting columns - -Use `include` to whitelist specific columns, or `exclude` to hide specific columns. If both are specified in the same rule, `include` takes precedence — only the whitelisted columns are returned, and the `exclude` list has no effect. If neither is specified, all columns are accessible. - -```json -{ - "type": ["select"], - "allow": "loggedIn", - "exclude": ["Password", "SSN", "Salary"] -} -``` - -

    Security note: Always exclude privilege fields (e.g., Admin, Role, Permissions) from rules that allow logged-in users to insert or update records. Otherwise, users can escalate their own privileges.

    - -### Example: role-based access with protected fields - -This example shows an "Employees" data source where admins have full access, regular users can read records (without sensitive fields), and can only update their own record or insert records with a non-privileged role. - -**Data source columns:** - -| Email | First Name | Role | Department | Salary | Password | Admin | Permissions | -|---|---|---|---|---|---|---|---| -| alice@acme.com | Alice | Admin | Engineering | 150000 | ••••• | Yes | all | -| bob@acme.com | Bob | User | Marketing | 85000 | ••••• | No | read | -| carol@acme.com | Carol | User | Engineering | 95000 | ••••• | No | read | - -**Security rules:** - -{% raw %} ```json [ - { - "type": ["select", "insert", "update", "delete"], - "allow": { "user": { "Role": { "equals": "Admin" } } }, - "enabled": true - }, - { - "type": ["select"], - "allow": "loggedIn", - "exclude": ["Password", "Salary"], - "enabled": true - }, - { - "type": ["update"], - "allow": "loggedIn", - "require": [ - { "Email": { "equals": "{{user.[Email]}}" } } - ], - "exclude": ["Role", "Admin", "Permissions"], - "enabled": true - }, - { - "type": ["insert"], - "allow": "loggedIn", - "require": [ - "Email", - "First Name", - { "Email": { "equals": "{{user.[Email]}}" } }, - { "Role": { "equals": "User" } } - ], - "exclude": ["Admin", "Permissions"], - "enabled": true - } + { "type": ["select"], "allow": "all" } ] ``` -{% endraw %} - -**Queries that succeed:** - -When Alice (Admin) is logged in, the first rule matches — she has full access to all records and columns: - -```js -// JS API — Alice reads all records including sensitive fields -const connection = await Fliplet.DataSources.connectByName('Employees'); -const allRecords = await connection.find(); -// Returns all 3 records with all columns including Salary and Password -``` - -```plaintext -// REST API equivalent -POST /v1/data-sources/123/data/query -Auth-token: -Content-Type: application/json - -// "type" here is the REST API operation type, not a security rule field -{ "type": "select" } - -// Response (200 OK): all entries with all columns -``` - -When Bob (User) is logged in, the second rule matches for reads — he can see records but `Password` and `Salary` are excluded: - -```js -// JS API — Bob reads records (sensitive columns automatically excluded) -const connection = await Fliplet.DataSources.connectByName('Employees'); -const records = await connection.find(); -// Returns all 3 records, but each record is missing Password and Salary -// e.g. { Email: "alice@acme.com", "First Name": "Alice", Role: "Admin", Department: "Engineering", Admin: "Yes", Permissions: "all" } -``` - -Bob can update his own record (the third rule requires his email in the data): - -```js -// JS API — Bob updates his own name -const connection = await Fliplet.DataSources.connectByName('Employees'); -await connection.update(456, { - Email: 'bob@acme.com', - 'First Name': 'Robert' -}); -// Succeeds — Email matches Bob's session, and Role/Admin/Permissions are excluded from writes -``` - -```plaintext -// REST API equivalent -PUT /v1/data-sources/123/data/456 -Auth-token: -Content-Type: application/json - -{ "Email": "bob@acme.com", "First Name": "Robert" } - -// Response (200 OK): updated entry -``` -**Queries that fail:** +Use this only for data intended for anonymous readers. It grants no insert, update or delete operation. The [public lookup example](API/datasources/security-examples.md#public-lookup) pairs this rule with sample rows and SDK calls. -When a query is denied, the API responds with HTTP status **400** and a JSON error body. The `message` field changes based on the denied operation — for example, `"do not allow this app to read data"` for selects, or `"do not allow this app to insert data"` for inserts: +## Choose the right guide -```json -{ - "message": "The security rules for the Data Source \"Employees\" do not allow this app to read data.", - "type": "datasource.access", - "payload": { "dataSourceId": 123 } -} -``` +| Task | Guide | +|---|---| +| Understand rule order, identity, requirements and endpoint differences | [Security rule reference](API/datasources/security-rules.md) | +| Configure public, shared, private, profile or role/state data | [Security examples](API/datasources/security-examples.md) | +| Prove allowed and denied app behavior | [Authenticated preview testing](API/datasources/testing-security.md) | +| Connect, query or write through JavaScript | [Fliplet.DataSources reference](API/fliplet-datasources.md) | +| Configure authentication or hooks | [App authentication](API/v3/auth.md), [Session API](API/fliplet-session.md) and [Data source hooks](Data-Source-Hooks.md) | -In the JS API, the promise is rejected with this error object. The `message` adapts to include the data source name and the specific operation that was denied (read, insert, update, or delete). The `type` is always `"datasource.access"` and `payload.dataSourceId` identifies the data source. - -```js -// Bob tries to update another user's record — fails -// (Email does not match Bob's session, so the require condition is not met) -await connection.update(789, { - Email: 'carol@acme.com', - 'First Name': 'Carolina' -}); - -// Bob tries to escalate his own role — fails -// (Role is excluded from the update rule, so it cannot be written) -await connection.update(456, { - Email: 'bob@acme.com', - Role: 'Admin' -}); - -// Bob tries to insert a record with an admin role — fails -// (the insert rule requires Role: "User") -await connection.insert({ - Email: 'bob@acme.com', - 'First Name': 'Bob Clone', - Role: 'Admin' -}); - -// Bob tries to delete a record — fails -// (no delete rule matches for non-admin users) -await connection.removeById(789); -``` +## Check the policy's limits -### Example: department-scoped access +Rules grant access in order. A failed rule falls through for writes as well as reads, so a later broad grant can defeat an earlier restriction. -In this example, managers see all records for their department, regular users only see their own record, and inserts are restricted to the user's department. Department scoping is enforced through `require` conditions on the query, not through `allow.user` (which checks the user's identity, not the data being accessed). +Object conditions on updates check the **stored row**, while string requirements check submitted key presence. A condition that checks stored `Owner` or `Status` does not prevent a permitted write from changing those fields. Single-record writes honor `exclude`, but bulk/commit writes do not provide the same field protection. Custom scripts receive a copied input; assigning audit fields to that copy does not reliably persist them. -**Data source columns:** +For immutable ownership or approval transitions, keep app-user writes unavailable until an implemented trusted write path has been verified to check both stored and proposed values on every supported endpoint. See [role and state limits](API/datasources/security-examples.md#role-and-state-access) before choosing a workflow policy. -| Email | Name | Role | Department | Salary | ManagerNotes | -|---|---|---|---|---|---| -| alice@acme.com | Alice | Manager | Engineering | 150000 | Top performer | -| bob@acme.com | Bob | User | Engineering | 85000 | | -| carol@acme.com | Carol | User | Marketing | 95000 | | -| dave@acme.com | Dave | Manager | Marketing | 140000 | Needs training | +## Existing section links -**Security rules:** +These destinations retain links to the previous sections of this guide. -{% raw %} -```json -[ - { - "type": ["select"], - "allow": { - "user": { "Role": { "equals": "Manager" } } - }, - "require": [ - { "Department": { "equals": "{{user.[Department]}}" } } - ], - "exclude": ["Salary"], - "enabled": true - }, - { - "type": ["select", "update"], - "allow": "loggedIn", - "require": [ - { "Email": { "equals": "{{user.[Email]}}" } } - ], - "exclude": ["Salary", "ManagerNotes"], - "enabled": true - }, - { - "type": ["insert"], - "allow": "loggedIn", - "require": [ - "Name", - { "Department": { "equals": "{{user.[Department]}}" } }, - { "CreatedBy": { "equals": "{{user.[Email]}}" } } - ], - "exclude": ["Role", "Admin"], - "enabled": true - } -] -``` -{% endraw %} + -The first rule uses `allow.user` to check the user's role (a literal match — "is this user a Manager?") and `require` to enforce department scoping on the query (the `where` clause must include the user's own department). This combination ensures only managers can use the rule *and* they can only query their own department's data. +[Security rules](API/datasources/security-rules.md#evaluation-and-defaults) -**Queries that succeed:** + -When Alice (Manager, Engineering) is logged in, the first rule matches — she is a Manager, and her query includes `Department: 'Engineering'` which satisfies the `require` condition: +[Access rule structure](API/datasources/security-rules.md#rule-properties) -```js -// JS API — Alice reads all Engineering records -const connection = await Fliplet.DataSources.connectByName('Staff'); -const records = await connection.find({ - where: { Department: 'Engineering' } -}); -// Returns Alice and Bob's records, excluding Salary -// e.g. { Email: "bob@acme.com", Name: "Bob", Role: "User", Department: "Engineering", ManagerNotes: "" } -``` + -```plaintext -// REST API equivalent -POST /v1/data-sources/123/data/query -Auth-token: -Content-Type: application/json +[Defining who can access](API/datasources/security-rules.md#identity-and-login-binding) -{ "type": "select", "where": { "Department": "Engineering" } } + -// Response (200 OK): Alice and Bob's entries, Salary excluded -``` +[Restricting columns](API/datasources/security-rules.md#column-restrictions) -When Bob (User, Engineering) is logged in, the first rule does not match (wrong role). The second rule matches — he must filter by his own email: + -```js -// JS API — Bob reads his own record -const connection = await Fliplet.DataSources.connectByName('Staff'); -const records = await connection.find({ - where: { Email: 'bob@acme.com' } -}); -// Returns Bob's record only, excluding Salary and ManagerNotes -``` +[Example role based access with protected fields](API/datasources/security-examples.md#editable-profiles) -Bob can insert a record in his own department: + -```js -// JS API — Bob creates a record in Engineering -await connection.insert({ - Name: 'New Hire', - Department: 'Engineering', - CreatedBy: 'bob@acme.com' -}); -// Succeeds — all require conditions are met, Role and Admin are excluded from writes -``` +[Example department scoped access](API/datasources/security-examples.md#private-sketches) -```plaintext -// REST API equivalent (Fliplet uses PUT for both inserts and updates on this endpoint) -PUT /v1/data-sources/123/data -Auth-token: -Content-Type: application/json + -{ "Name": "New Hire", "Department": "Engineering", "CreatedBy": "bob@acme.com" } +[Data requirements and query validation](API/datasources/security-rules.md#requirements-by-operation) -// Response (200 OK): new entry -``` + -**Queries that fail:** - -```js -// Alice (Manager) tries to read Marketing records — fails -// (the require condition demands Department matching Alice's own department "Engineering"; -// "Marketing" does not match, so the first rule is skipped; no other rule grants broad access) -await connection.find({ - where: { Department: 'Marketing' } -}); - -// Bob tries to read all records without filtering by email — fails -// (the second rule requires Email matching Bob's session; without a where clause, -// the require condition is not met, the rule is skipped, and no subsequent rule grants access) -await connection.find(); - -// Bob tries to read Alice's record — fails -// (Email does not match Bob's session) -await connection.find({ - where: { Email: 'alice@acme.com' } -}); - -// Bob tries to insert a record in Marketing — fails -// (Department does not match Bob's department "Engineering") -await connection.insert({ - Name: 'Spy', - Department: 'Marketing', - CreatedBy: 'bob@acme.com' -}); - -// Bob tries to insert with a different CreatedBy — fails -// (CreatedBy must equal Bob's email) -await connection.insert({ - Name: 'Fake', - Department: 'Engineering', - CreatedBy: 'alice@acme.com' -}); -``` +[Data requirement types](API/datasources/security-rules.md#requirement-operators) -## Data requirements and query validation + -The `require` property defines conditions that incoming queries must satisfy. This is not the same as querying data — it controls how queries are **assessed against the rule's data requirements**. +[Handlebars templating](API/datasources/security-rules.md#identity-and-login-binding) -

    If you add data requirements to your rules, Fliplet core components that query the data source (e.g., List from Data Source, Chart, Form) may stop working because they issue broad queries without the where clauses that require demands. You will need to either add a permissive rule (without require) scoped to those components' app IDs, or replace the component queries with custom code that satisfies the requirements.

    + +[Require syntax](API/datasources/security-rules.md#requirement-operators) -For example, to allow a specific app's components to read without `require` constraints while keeping other apps locked down: + -```json -{ - "type": ["select"], - "allow": "loggedIn", - "appId": [456, 789], - "enabled": true -} -``` +[Custom security rules](API/datasources/security-rules.md#custom-scripts) -This rule grants read access to logged-in users only when the request originates from app 456 or 789. Place it before stricter rules so that the matching apps get broad access while other apps fall through to rules with `require` conditions. + -The behavior of `require` varies by operation type: +[Granting access](API/datasources/security-rules.md#custom-scripts) -- **For `select` and `delete` operations:** The client's query must include a `where` clause that satisfies all requirements, or the rule does not match. For example, if a rule requires `{ "Email": { "equals": "{{user.[Email]}}" } }`, every `find()` call must include `where: { Email: user.Email }` (or the equivalent `$eq` operator) for that rule to grant access. To obtain the user's session data client-side, use the [Session API](API/fliplet-session.html). If the requirements are not met, the rule is **skipped** (not rejected) — evaluation falls through to the next rule. This means a more permissive rule below could still grant access. Design your rule order carefully: place restrictive rules first and ensure no later rule inadvertently grants broader access than intended. -- **For `insert` and `update` operations:** Validates that the submitted data contains the required fields and values. If the data does not match, the write is **rejected immediately** — unlike reads, unmet requirements on a write do not fall through to the next rule. + -

    Key difference: Unmet require on a read = rule skipped, next rule evaluated. Unmet require on a write = request denied outright. This means a permissive read rule further down the list can still grant access, but a failed write requirement stops evaluation.

    - -### Data requirement types - -`require` accepts an array of strings (required field names) and objects (field conditions). Four requirement types are available: - -| Requirement type | Description | Query patterns that satisfy this requirement | -|---|---|---| -| Field is required | The query must include the specified column | Any query that provides the column as a key | -| Field equals | The query value for the column must match exactly | `{ Field: value }` or `{ Field: { $eq: value } }` | -| Field not equals | The query value for the column must not match the specified value | `{ Field: { $ne: value } }` or any query where the field's value differs from the specified value | -| Field contains | The query value for the column must contain the specified substring | `{ Field: value }` or `{ Field: { $iLike: value } }` or `{ Field: { $like: value } }` | - -

    These requirement types (equals, notequals, contains) are not query operators — they define how the security rule validates incoming queries. For the full list of query operators you can use when reading and writing data, see the query operators reference.

    - -### Handlebars templating - -Condition values can reference the logged-in user's session data using Handlebars syntax. This enables dynamic, per-user access scoping. Use bracket notation (e.g., `[Email]`) for field names — it is required for names with spaces or special characters (e.g., {% raw %}`{{user.[First Name]}}`{% endraw %}), and also works for simple names. Plain dot notation ({% raw %}`{{user.Email}}`{% endraw %}) works only for field names without spaces. - -- {% raw %}`{{user.[Email]}}`{% endraw %} — the user's email address -- {% raw %}`{{user.[Department]}}`{% endraw %} — the user's department -- {% raw %}`{{user.[Role]}}`{% endraw %} — the user's role -- {% raw %}`{{user.[ID]}}`{% endraw %} — the user's data source entry ID - -### Require syntax - -**Required fields** (string) — the query must include these columns: - -```json -"require": ["Email", "FirstName", "Department"] -``` - -**Field conditions** (object with operator) — the query must match these conditions: - -{% raw %} -```json -"require": [ - { "Email": { "equals": "{{user.[Email]}}" } }, - { "Status": { "notequals": "Archived" } }, - { "Department": { "contains": "{{user.[Department]}}" } } -] -``` -{% endraw %} - -You can mix both formats in the same array: - -{% raw %} -```json -"require": [ - "Title", - "Description", - { "CreatedBy": { "equals": "{{user.[Email]}}" } }, - { "Status": { "equals": "Draft" } } -] -``` -{% endraw %} - -For full examples of `require` in practice — including sample data, succeed queries, and fail queries — see [role-based access with protected fields](#example-role-based-access-with-protected-fields) and [department-scoped access](#example-department-scoped-access) above. - -## Custom security rules - -For advanced logic beyond what the standard rule properties support, you can write custom JavaScript security rules. - -![Custom security](assets/img/datasource-custom-security.png) - -The custom security rule editor in Fliplet Studio displays a code editor where you write JavaScript that controls access. The rule name and enabled toggle appear above the code area. - -

    Important: When a rule has a script, the script is the sole determinant of access. Standard rule fields like allow and type are ignored — the script runs regardless of login status or operation type. Your script must perform its own identity and operation checks (e.g., if (!user) return { granted: false };). If the script does not return a value (e.g., an unhandled operation type falls through without a return), access is denied by default.

    - -When writing a custom rule, these variables are available in the script context: - -- `type` (String) — the operation the user is attempting: `select`, `insert`, `update`, or `delete` -- `user` (Object) — the authenticated user's session data, or `undefined` if not logged in. For data-source passport sessions (e.g., Email Verification, Fliplet Login), this contains the flat column values from the user's row in the authentication data source (e.g., `user.Email`, `user.Role`). For SAML2 sessions, it contains the assertion attributes. -- `query` (Object or Array) — **important: the shape of this variable changes depending on the operation type:** - - **`select`**: the unwrapped `where` object from the request (e.g., if the client sends `find({ where: { Email: "a@b.com" } })`, the rule receives `{ Email: "a@b.com" }`) - - **`insert`** / **`update`**: the data being written to the entry. When called via the `commit` endpoint, `query` is an array of entries — see [Checking data when committing changes](#checking-data-when-committing-changes) - - **`delete`**: the current data of the entry being deleted (populated by the server from the existing record, not sent by the client) -- `entry` (Object) — the existing entry being updated, with `id` (Number) and `data` (Object containing the entry's current column values) properties. `undefined` for other operation types -- `DataSources` (Function) — server-side library for reading data from other data sources. See [Reading data from other Data Sources](#reading-data-from-other-data-sources) - -Your code should handle all relevant operation types. Here is an example: - -```js -// Always check for unauthenticated access first -if (!user) { - return { granted: false }; -} - -switch (type) { - case 'select': - // "query" is the unwrapped where object from the API request. - // If the client sends find() with no where clause, query may be empty. - return { granted: query && query.Department === user.Department }; - - case 'insert': - // "query" is the data being inserted. - // It can also be an array when committing multiple records at once. - if (Array.isArray(query)) { - return { granted: query.every(data => data.Department === user.Department) }; - } - - return { granted: query.Department === user.Department }; - - case 'update': - // "query" is the data being updated. - // It can also be an array when committing multiple records at once. - // "entry" is the existing record (with id and data properties) when applicable. - if (Array.isArray(query)) { - return { granted: query.every(data => data.Department === user.Department) }; - } - - return { granted: query.Department === user.Department && entry && entry.data.Active === true }; - - case 'delete': - // "query" is the data of the entry being deleted. - // It can also be an array when deleting multiple records at once. - if (Array.isArray(query)) { - return { granted: query.every(data => data.CreatedBy === user.Email) }; - } - - return { granted: query.CreatedBy === user.Email }; -} -``` - -### Granting access - -Return an object with `granted: true` to grant access. You can also return an `exclude` or `include` array to restrict which columns the user can access: - -```js -if (type === 'select') { - if (!user) { - return { granted: false }; - } - - // Grant full access to admin users - if (user.Admin === 'Yes') { - return { granted: true }; - } - - // Grant access to any other logged-in user, but hide sensitive columns - return { granted: true, exclude: ['Phone', 'NextOfKin'] }; -} - -// No further access is granted by this rule to other operation types -``` - -

    Important: You must return an object — bare boolean values (e.g., return true) are not supported and will be treated as a denial. Always use return { granted: true } or return { granted: false }.

    - -### Modifying the input query - -Rules can modify the `query` object to enforce constraints: - -```js -if (!user) { - return { granted: false }; -} - -if (type === 'select') { - // Only allow reading records for the same office as the user's - query.Office = user.Office; - - return { granted: true }; -} - -if (type === 'insert') { - // Forces writes to have the office field same as the user's - query.Office = user.Office; - - // Also generate a "CreatedAt" datetime field for all records added - query.CreatedAt = Date.now(); - - return { granted: true }; -} -``` - -### Checking data when committing changes - -When a data source is updated via the `commit` endpoint (or JS API), `query` is always an **array** of flat data objects — not a single object. The security rule runs separately for each operation type in the commit (inserts, updates, and deletes). - -

    Note: For all three operation types during a commit, query is an array of flat data objects (the entry's column values). For deletes, the server loads the existing entries and provides their data — you receive the same flat shape as inserts and updates, not an array of IDs.

    - -```js -if (type === 'insert') { - // "query" is the array of entries to insert - // e.g. [{ Name: "Alice", Department: "Engineering" }, { Name: "Bob", Department: "Marketing" }] -} else if (type === 'update') { - // "query" is the array of entry data being written - // e.g. [{ Name: "Alice Updated", Department: "Engineering" }, { Name: "Bob Updated", Department: "Marketing" }] -} else if (type === 'delete') { - // "query" is the array of existing entry data for the entries being deleted - // e.g. [{ Name: "Alice", Department: "Engineering" }, { Name: "Dave", Department: "Marketing" }] -} -``` - -### Reading data from other Data Sources - -Custom rules can read data from other Data Sources using the `find` (multiple records) and `findOne` (single record) methods of the `DataSources` server-side library. These reads run at **server level** and bypass all security rules on the target data source. - -

    Cross-data-source lookups add a database round-trip per request and are not cached. Keep queries efficient and use findOne when you only need a single record. Script execution has a 3-second timeout — if the script does not return within 3 seconds, access is denied. Async operations (like DataSources queries) are supported via await.

    - -Connect using the data source ID (number) or name (string): - -```js -if (!user) { - return { granted: false }; -} - -if (type === 'select') { - const entry = await DataSources(123).findOne({ - where: { - Office: user.Office, - Managers: { $in: user.Manager }, - Country: { $like: '%United Kingdom%' } - } - }); - - // Allow reading data if the user has a manager in the same office - if (entry) { - return { granted: true }; - } -} - -if (type === 'insert') { - const entries = await DataSources('Users').find(); - - // Only allow writes as long as there are fewer than 10 entries in the target Data Source - if (entries && entries.length < 10) { - return { granted: true }; - } -} -``` +[Modifying the input query](API/datasources/security-rules.md#script-inputs-and-mutation) -`find` returns an array of matching entries (empty array if none match). `findOne` returns a single entry object, or `null` if no match is found. + -Both methods accept the following properties: +[Checking data when committing changes](API/datasources/security-rules.md#script-inputs-and-mutation) -- `where` (Object) — query filter supporting [standard query operators](API/datasources/query-operators.html) such as `$eq`, `$ne`, `$like`, `$iLike`, `$gt`, `$gte`, `$lt`, `$lte`, `$in` -- `limit` (Number, defaults to `100`) -- `offset` (Number, defaults to `0`) + -

    Use DataSources('Name') (data source name) instead of DataSources(123) (ID) in custom scripts. Data source names are preserved during app clone, so name-based lookups continue to work without manual remapping.

    \ No newline at end of file +[Reading data from other data sources](API/datasources/security-rules.md#reading-another-data-source) diff --git a/docs/bin/__tests__/datasource-security-docs.test.mjs b/docs/bin/__tests__/datasource-security-docs.test.mjs new file mode 100644 index 00000000..de2740df --- /dev/null +++ b/docs/bin/__tests__/datasource-security-docs.test.mjs @@ -0,0 +1,74 @@ +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CLUSTERS, collectDocs, emitLlmsTxt, emitV3LibraryCatalog, validateFrontmatter } from '../build-agent-indexes.mjs'; +import { copySiblings } from '../copy-md-siblings.mjs'; + +const source = fileURLToPath(new URL('../../', import.meta.url)); +const paths = ['Data-source-security.md', 'API/fliplet-datasources.md', + 'API/datasources/security-rules.md', 'API/datasources/security-examples.md', + 'API/datasources/testing-security.md']; +const moved = ['security-rules', 'access-rule-structure', 'defining-who-can-access', + 'restricting-columns', 'example-role-based-access-with-protected-fields', + 'example-department-scoped-access', 'data-requirements-and-query-validation', + 'data-requirement-types', 'handlebars-templating', 'require-syntax', + 'custom-security-rules', 'granting-access', 'modifying-the-input-query', + 'checking-data-when-committing-changes', 'reading-data-from-other-data-sources']; + +it('discovers every security page in the existing cluster and retains one SDK catalog owner', () => { + const root = mkdtempSync(join(tmpdir(), 'datasource-security-docs-')); + try { + for (const path of paths) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), readFileSync(join(source, path))); + } + const docs = collectDocs(root); + assert.equal(docs.length, paths.length); + assert.deepEqual(validateFrontmatter(docs), []); + const cluster = CLUSTERS.find(item => item.name === 'fliplet-data-sources'); + const index = emitLlmsTxt(docs); + for (const path of paths) { + assert.ok(cluster.match(path), path); + assert.ok(index.includes(`https://developers.fliplet.com/${path}`), path); + } + const catalog = emitV3LibraryCatalog(docs); + assert.equal(catalog.libraries.length, 1); + assert.equal(catalog.libraries[0].docUrl, 'https://developers.fliplet.com/API/fliplet-datasources.md'); + const site = join(root, '_site'); + mkdirSync(site); + assert.equal(copySiblings(root, site), paths.length); + for (const path of paths) assert.equal(readFileSync(join(site, path), 'utf8'), readFileSync(join(source, path), 'utf8')); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +it('retains every moved public security fragment with an explicit destination link', () => { + const body = readFileSync(join(source, paths[0]), 'utf8'); + for (const anchor of moved) { + assert.match(body, new RegExp(String.raw`\s+\[[^\]]+\]\(API/datasources/[^)]+\)`)); + } +}); + +it('security JSON and JavaScript fences parse and templates survive Liquid raw blocks', () => { + const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; + let blocks = 0; + for (const path of paths.filter(path => path !== 'API/fliplet-datasources.md')) { + const body = readFileSync(join(source, path), 'utf8'); + for (const match of body.matchAll(/```(json|js)\n([\s\S]*?)```/g)) { + blocks++; + if (match[1] === 'json') JSON.parse(match[2]); + else new AsyncFunction(match[2]); + } + let raw = false; + for (const line of body.split('\n')) { + if (line.includes('{% raw %}')) raw = true; + if (line.includes('{{')) assert.ok(raw, `${path}: unescaped template`); + if (line.includes('{% endraw %}')) raw = false; + } + } + assert.ok(blocks >= 15, 'example fences must actually be exercised'); +}); From bac0a1f94acc822835e7b6a13b381a8e5b4fde80 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 8 Oct 2026 14:17:24 +0100 Subject: [PATCH 02/13] DEV-2201 Correct security example scope and response verification --- .../fliplet-data-sources/SKILL.md | 2 +- docs/.well-known/agent-skills/index.json | 2 +- docs/.well-known/llms-full.txt | 74 ++++++++++++++----- docs/.well-known/llms.txt | 2 +- docs/API/datasources/security-examples.md | 31 +++++--- docs/API/datasources/security-rules.md | 25 +++++-- docs/API/datasources/testing-security.md | 12 ++- docs/Data-source-security.md | 8 +- .../datasource-security-docs.test.mjs | 36 +++++++++ 9 files changed, 150 insertions(+), 42 deletions(-) diff --git a/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md b/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md index 31c0d732..b67d3813 100644 --- a/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md +++ b/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md @@ -11,7 +11,7 @@ Data sources JavaScript API and security model: query, insert, update, delete re - [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. - [Data Sources query operators](https://developers.fliplet.com/API/datasources/query-operators.md): Filter Data Source queries with MongoDB/Sift operators ($eq, $gt, $in, $regex, $and, $or) inside connection.find() where clauses. -- [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, private, profile and role-based access rules with data shapes and allowed and denied SDK operations. +- [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, owner-filtered read, profile and role-based examples with data shapes and allowed and denied SDK operations. - [Data source security rule reference](https://developers.fliplet.com/API/datasources/security-rules.md): Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior. - [Testing data source security in preview](https://developers.fliplet.com/API/datasources/testing-security.md): Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled. - [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. diff --git a/docs/.well-known/agent-skills/index.json b/docs/.well-known/agent-skills/index.json index b2762963..188efb71 100644 --- a/docs/.well-known/agent-skills/index.json +++ b/docs/.well-known/agent-skills/index.json @@ -11,7 +11,7 @@ "js-api", "security" ], - "sha256": "7a7765e27d4c2ac92a7bc35897860f4c9c25d2128b426fbccbc5dc886ea07efb" + "sha256": "94c8d56f5b7073025d2e1ec79eaaea4624abfc8095e95530755185c2b9651a49" }, { "name": "fliplet-app-actions", diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index bcfbc4b7..5cbdfef5 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -13019,9 +13019,11 @@ console.log(rows.map(entry => entry.data)); Denied tests: sign out and call `find()` or `insert()`; sign in through another login source and repeat. As Ada, submit `insert({ Message: 'Missing venue' })`, or update/delete a seeded notice. Each must reject and leave no forbidden persisted effect. The string requirements check presence only: `Venue: null` is not a nonempty validation failure under this policy. -## Private sketches + -**Goal:** An enabled member reads, creates and deletes sketches scoped to their email. This example deliberately grants no updates, keeping ownership immutable through the app operations it permits. +## Owner-filtered read illustration + +**Goal:** Demonstrate an enabled member's owner-filtered query read and stored-owner record read. Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: @@ -13032,13 +13034,15 @@ Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: ] ``` -Save: +This illustrates the named read checks, not complete source privacy. Write/bulk response routes and actual source permissions require independent verification before using the source for private data. A select-only rule array does not prove every commit request is denied or its response isolated. Do not rely on UI availability, cooperative response options or evaluator-only tests to establish privacy. + +Save this select-only illustration: {% raw %} ```json [ { - "type": ["select", "insert", "delete"], + "type": ["select"], "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] } @@ -13046,18 +13050,27 @@ Save: ``` {% endraw %} -Allowed as Ada: +Allowed as Ada after signing in through Members: ```js const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); const mine = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); console.log(mine.map(entry => entry.data.Title)); // ['Morning motif'] -await sketches.insert({ OwnerEmail: 'ada@example.org', Title: 'Second motif' }); ``` -Use the fixture ID from Studio to test `removeById(adaSketchId)` on Ada's disposable row; the stored owner grants deletion. Denied as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })`, inserting Lin's email or deleting Lin's fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied. No update is granted, even on Ada's own row. +Copy Ada's current fixture ID from Studio and call this function as Ada to check the record-read path: -The query rule validates `where`; it does not add an owner filter for you. A record read with `findById(id)` instead evaluates the stored row. Repeat both paths when the app uses both. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. +```js +async function readOwnSketch(adaSketchId) { + const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); + const own = await sketches.findById(adaSketchId); + console.log(own.data.Title); // 'Morning motif' +} +``` + +Denied read tests as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })` and `findById(linSketchId)` using Lin's actual fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied on these read paths. + +The query rule validates `where`; it does not add an owner filter. `findById(id)` instead evaluates the stored row. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. Before asserting private deployment safety, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under the source's real permissions, including their returned rows and columns. See [adversarial response tests](testing-security.md#check-write-responses). ## Editable profiles @@ -13240,18 +13253,24 @@ String requirements check **key presence**, not a nonempty value or type. For ex | Bulk/commit insert | Keys in every submitted data object | Every submitted data object | | Bulk/commit update | Keys in every submitted data object | Every loaded stored row; no stored context means the condition cannot grant | | Single `removeById()` | Stored record keys | Stored record values | -| Query writes | Depends on the request's query and operation checks | Query admission and affected-record checks differ; do not infer access from a single-record test | +| Query admission | Submitted `where` keys, evaluated as `select` | Submitted `where`, evaluated as `select` | +| Query update, affected-record check | Submitted update-data keys | Stored row | +| Query delete, affected-record check | Stored record keys | Stored record values | | Commit delete | Existing data for the requested IDs | Existing data for the requested IDs | A query read requirement checks the client's filter; it does not inject a filter. Send the appropriate `where` clause. `findOne({ where: ... })` follows the query route, whereas `findById(id)` follows the record route and cannot be assumed equivalent. Additional source permissions can reject either route. +Query writes first require a `select` grant for the submitted `where`. In the inspected supported behavior, each affected row then undergoes an operation check: updates check string requirements against submitted update-data keys and object requirements against the stored row; deletes check stored data. Update persistence omits the affected row's matched-rule exclusions from the proposed data. `include` is not a write whitelist, and source permissions are additional gates. Verify affected-row enforcement on the tested deployment: compatibility settings can skip that check, so local source inspection alone does not prove production enforcement. + ### Requirement operators +This select-only illustration compares the submitted read filter or stored record to the caller. On updates, the same object condition would authorize the **stored** owner's row without prohibiting a proposed change to `OwnerEmail`. See the explicitly limited [single-record profile example](security-examples.md#editable-profiles) before configuring updates; its exclusions do not protect bulk/commit writes. + {% raw %} ```json [ { - "type": ["select", "update"], + "type": ["select"], "allow": { "loggedIn": true, "dataSourceId": 731 }, "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] } @@ -13265,10 +13284,16 @@ A query read requirement checks the client's filter; it does not inject a filter | `notequals` | Inequality comparison; it is not an existence or nonempty check. | | `contains` | Substring comparison; query validation recognizes direct strings and relevant `$eq`, `$like`, `$iLike` or equality `$filters` values. | -Only these three object operators are supported. They are separate from [query operators](query-operators.md). On updates the example authorizes the stored owner's row, but it does **not** prohibit changing `OwnerEmail`. The [examples guide](security-examples.md) shows that limitation beside the write. +Only these three object operators are supported. They are separate from [query operators](query-operators.md). ## Column restrictions +When the read-filtering helpers run, source-level `definition.include`/`definition.exclude` lists are unioned with the matching rule's corresponding lists. A rule include list is not an exclusive whitelist, and an empty rule include does not define a whitelist. For example, source include `PrivateNotes`, rule include `PublicName, Badge` and source exclude `Badge` leave `PrivateNotes, PublicName`: exclusions win. + +The inspected query/record read routes invoke each helper when the matching rule has the corresponding **nonempty** list. Do not infer that a source list alone is always applied by those routes. These helpers filter read responses; they do not establish write-persistence restrictions. + +Successful write authorization does not itself prove that returned rows or columns satisfy read restrictions. The inspected `commit()` response can return source entries without owner-select filtering or rule column filtering; hash redaction is separate. Source permissions and caller-supplied response options also affect this path. Test response isolation independently before choosing a writable policy for private data; setting `returnEntries: false` in cooperative clients does not enforce that option on other callers. + Column restrictions do not have a uniform write contract: | Path | `exclude` | `include` | @@ -13276,7 +13301,7 @@ Column restrictions do not have a uniform write contract: | Select query/record response | Removes excluded data columns | Restricts returned data columns after exclusion; a field removed by `exclude` is not restored by `include` | | Single insert/update | Removes submitted excluded fields before persistence; this is filtering, not necessarily a rejected request | Does not reliably filter the persisted single-write payload | | Bulk append/replace/commit | Does not filter each persisted entry | Does not filter each persisted entry | -| Query update | Has separate affected-record filtering behavior | Do not assume single-write or bulk behavior | +| Query update | Omits matched exclusions from affected-row update data when operation checks run | Not a write whitelist | A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. @@ -13313,8 +13338,11 @@ Assigning `query.OwnerEmail` or `query.UpdatedAt` does not establish a persisted `DataSources(idOrName).find(options)` and `.findOne(options)` perform server-side reads without applying the target source's access rules. A lookup may therefore expose fields ordinary app users cannot read. Use it only for the minimum policy decision, and do not return its contents to clients. +Before using this script, create **Reading Groups** with `Email` and `Group` columns and membership rows. The signed-in identity must have a nonempty string `Email`; column names alone do not enforce that type. The script validates its presence without normalizing or rewriting it. + ```js -if (!user || !session || session.dataSourceId !== 731 || type !== 'select') { +if (!user || !session || session.dataSourceId !== 731 || type !== 'select' + || typeof user.Email !== 'string' || user.Email.trim().length === 0) { return { granted: false }; } const membership = await DataSources('Reading Groups').findOne({ @@ -13351,6 +13379,8 @@ Create dedicated test data sources and records. Record the saved rule array, log | Inactive member | Protected write | Denied | | Approver with multiple roles | Supported review operation | Allowed | | Member without required role | Same operation | Denied | +| Bound member with commit/bulk access | Own-data write with response enabled/default | Returned rows and columns satisfy the intended read isolation | +| Denied or wrong-source actor | Direct response-bearing request | No forbidden data returned or persisted | Adapt the matrix to the configured policy. Include missing/null identity fields, different native types and each write method your app uses. For protected ownership, privilege or state fields, attempt malicious payloads directly through the SDK as well as through the UI. A hidden button does not stop an SDK call. @@ -13376,7 +13406,7 @@ Also confirm the UI setting is enabled and the preview is outside editor interac ## Run allowed and denied operations -Use an online connection to avoid counting cached reads or queued writes as server proof: +Configure the fixtures and select-only policy from the [owner-filtered read illustration](security-examples.md#private-sketches), then sign in as Ada through its bound Members login source. Use an online connection to avoid counting cached reads or queued writes as server proof: ```js const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); @@ -13384,12 +13414,18 @@ const rows = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); console.log(rows.map(entry => ({ id: entry.id, data: entry.data }))); ``` -Run this only after configuring the matching [private sketches policy](security-examples.md#private-sketches) and signing in as Ada. Record the successful server response and returned rows. Repeat with another actor's filter and confirm the authorization denial. +Record the successful server response and returned rows. This read illustration does not establish complete source privacy. Repeat with another actor's filter and confirm the authorization denial. For every allowed write, perform a fresh read and check the exact saved values. For every forbidden write, inspect the rejection and verify that no forbidden insert, update or delete persisted. Use a trusted Studio readback where the app's read rules hide the affected fields; label it as persistence evidence, not an app-user access test. Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate single-record `exclude` behavior to bulk writes. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. +## Check write responses + +For grants that admit commit/bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data append or commit with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. + +Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. + ## Diagnose a failure A policy denial must be supported by the error response and unchanged forbidden state. An HTTP 400 or 401 alone is insufficient because errors vary by method and caller. @@ -35712,7 +35748,7 @@ Protect app data with server-evaluated access rules, then test the allowed and d 1. Open **App Data** in Fliplet Studio, select the data source and open **Access Rules**. 2. Define the actors, operations and fields that need protection. Start with an explicit empty rules array (`[]`) for a source that should deny app access until configured. Missing or null rules retain legacy unrestricted access. -3. Choose a [complete security example](API/datasources/security-examples.md) and adapt its data shape and login-source binding. Save the rules, then read back the configuration. +3. Choose a [security example with explicit scope](API/datasources/security-examples.md) and adapt its data shape and login-source binding. Save the rules, then read back the configuration. 4. Test both allowed and denied operations with [Enforce security enabled in preview](API/datasources/testing-security.md). Confirm persisted results using a fresh read. A minimal public read-only policy is: @@ -35730,7 +35766,7 @@ Use this only for data intended for anonymous readers. It grants no insert, upda | Task | Guide | |---|---| | Understand rule order, identity, requirements and endpoint differences | [Security rule reference](API/datasources/security-rules.md) | -| Configure public, shared, private, profile or role/state data | [Security examples](API/datasources/security-examples.md) | +| Explore public, shared, owner-filtered reads, profile or role/state examples | [Security examples](API/datasources/security-examples.md) | | Prove allowed and denied app behavior | [Authenticated preview testing](API/datasources/testing-security.md) | | Connect, query or write through JavaScript | [Fliplet.DataSources reference](API/fliplet-datasources.md) | | Configure authentication or hooks | [App authentication](API/v3/auth.md), [Session API](API/fliplet-session.md) and [Data source hooks](Data-Source-Hooks.md) | @@ -35765,11 +35801,11 @@ These destinations retain links to the previous sections of this guide. -[Example role based access with protected fields](API/datasources/security-examples.md#editable-profiles) +[Replacement: owner-scoped single-record profiles](API/datasources/security-examples.md#editable-profiles). This example demonstrates enabled-owner edits and single-write filtering, not the former role/protected-field policy. -[Example department scoped access](API/datasources/security-examples.md#private-sketches) +[Replacement: owner-filtered read illustration](API/datasources/security-examples.md#private-sketches). This illustrates individual owner reads, not department/manager access or complete source privacy. diff --git a/docs/.well-known/llms.txt b/docs/.well-known/llms.txt index f89b46cc..b964d52d 100644 --- a/docs/.well-known/llms.txt +++ b/docs/.well-known/llms.txt @@ -83,7 +83,7 @@ - [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. - [Data Sources query operators](https://developers.fliplet.com/API/datasources/query-operators.md): Filter Data Source queries with MongoDB/Sift operators ($eq, $gt, $in, $regex, $and, $or) inside connection.find() where clauses. -- [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, private, profile and role-based access rules with data shapes and allowed and denied SDK operations. +- [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, owner-filtered read, profile and role-based examples with data shapes and allowed and denied SDK operations. - [Data source security rule reference](https://developers.fliplet.com/API/datasources/security-rules.md): Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior. - [Testing data source security in preview](https://developers.fliplet.com/API/datasources/testing-security.md): Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled. - [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. diff --git a/docs/API/datasources/security-examples.md b/docs/API/datasources/security-examples.md index 983c00f9..05d38698 100644 --- a/docs/API/datasources/security-examples.md +++ b/docs/API/datasources/security-examples.md @@ -1,6 +1,6 @@ --- title: Data source security examples -description: "Pair public, shared, private, profile and role-based access rules with data shapes and allowed and denied SDK operations." +description: "Pair public, shared, owner-filtered read, profile and role-based examples with data shapes and allowed and denied SDK operations." type: guide tags: [js-api, datasources, security] v3_relevant: true @@ -103,9 +103,11 @@ console.log(rows.map(entry => entry.data)); Denied tests: sign out and call `find()` or `insert()`; sign in through another login source and repeat. As Ada, submit `insert({ Message: 'Missing venue' })`, or update/delete a seeded notice. Each must reject and leave no forbidden persisted effect. The string requirements check presence only: `Venue: null` is not a nonempty validation failure under this policy. -## Private sketches + -**Goal:** An enabled member reads, creates and deletes sketches scoped to their email. This example deliberately grants no updates, keeping ownership immutable through the app operations it permits. +## Owner-filtered read illustration + +**Goal:** Demonstrate an enabled member's owner-filtered query read and stored-owner record read. Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: @@ -116,13 +118,15 @@ Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: ] ``` -Save: +This illustrates the named read checks, not complete source privacy. Write/bulk response routes and actual source permissions require independent verification before using the source for private data. A select-only rule array does not prove every commit request is denied or its response isolated. Do not rely on UI availability, cooperative response options or evaluator-only tests to establish privacy. + +Save this select-only illustration: {% raw %} ```json [ { - "type": ["select", "insert", "delete"], + "type": ["select"], "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] } @@ -130,18 +134,27 @@ Save: ``` {% endraw %} -Allowed as Ada: +Allowed as Ada after signing in through Members: ```js const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); const mine = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); console.log(mine.map(entry => entry.data.Title)); // ['Morning motif'] -await sketches.insert({ OwnerEmail: 'ada@example.org', Title: 'Second motif' }); ``` -Use the fixture ID from Studio to test `removeById(adaSketchId)` on Ada's disposable row; the stored owner grants deletion. Denied as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })`, inserting Lin's email or deleting Lin's fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied. No update is granted, even on Ada's own row. +Copy Ada's current fixture ID from Studio and call this function as Ada to check the record-read path: + +```js +async function readOwnSketch(adaSketchId) { + const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); + const own = await sketches.findById(adaSketchId); + console.log(own.data.Title); // 'Morning motif' +} +``` + +Denied read tests as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })` and `findById(linSketchId)` using Lin's actual fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied on these read paths. -The query rule validates `where`; it does not add an owner filter for you. A record read with `findById(id)` instead evaluates the stored row. Repeat both paths when the app uses both. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. +The query rule validates `where`; it does not add an owner filter. `findById(id)` instead evaluates the stored row. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. Before asserting private deployment safety, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under the source's real permissions, including their returned rows and columns. See [adversarial response tests](testing-security.md#check-write-responses). ## Editable profiles diff --git a/docs/API/datasources/security-rules.md b/docs/API/datasources/security-rules.md index 40966e0d..b334ec57 100644 --- a/docs/API/datasources/security-rules.md +++ b/docs/API/datasources/security-rules.md @@ -93,18 +93,24 @@ String requirements check **key presence**, not a nonempty value or type. For ex | Bulk/commit insert | Keys in every submitted data object | Every submitted data object | | Bulk/commit update | Keys in every submitted data object | Every loaded stored row; no stored context means the condition cannot grant | | Single `removeById()` | Stored record keys | Stored record values | -| Query writes | Depends on the request's query and operation checks | Query admission and affected-record checks differ; do not infer access from a single-record test | +| Query admission | Submitted `where` keys, evaluated as `select` | Submitted `where`, evaluated as `select` | +| Query update, affected-record check | Submitted update-data keys | Stored row | +| Query delete, affected-record check | Stored record keys | Stored record values | | Commit delete | Existing data for the requested IDs | Existing data for the requested IDs | A query read requirement checks the client's filter; it does not inject a filter. Send the appropriate `where` clause. `findOne({ where: ... })` follows the query route, whereas `findById(id)` follows the record route and cannot be assumed equivalent. Additional source permissions can reject either route. +Query writes first require a `select` grant for the submitted `where`. In the inspected supported behavior, each affected row then undergoes an operation check: updates check string requirements against submitted update-data keys and object requirements against the stored row; deletes check stored data. Update persistence omits the affected row's matched-rule exclusions from the proposed data. `include` is not a write whitelist, and source permissions are additional gates. Verify affected-row enforcement on the tested deployment: compatibility settings can skip that check, so local source inspection alone does not prove production enforcement. + ### Requirement operators +This select-only illustration compares the submitted read filter or stored record to the caller. On updates, the same object condition would authorize the **stored** owner's row without prohibiting a proposed change to `OwnerEmail`. See the explicitly limited [single-record profile example](security-examples.md#editable-profiles) before configuring updates; its exclusions do not protect bulk/commit writes. + {% raw %} ```json [ { - "type": ["select", "update"], + "type": ["select"], "allow": { "loggedIn": true, "dataSourceId": 731 }, "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] } @@ -118,10 +124,16 @@ A query read requirement checks the client's filter; it does not inject a filter | `notequals` | Inequality comparison; it is not an existence or nonempty check. | | `contains` | Substring comparison; query validation recognizes direct strings and relevant `$eq`, `$like`, `$iLike` or equality `$filters` values. | -Only these three object operators are supported. They are separate from [query operators](query-operators.md). On updates the example authorizes the stored owner's row, but it does **not** prohibit changing `OwnerEmail`. The [examples guide](security-examples.md) shows that limitation beside the write. +Only these three object operators are supported. They are separate from [query operators](query-operators.md). ## Column restrictions +When the read-filtering helpers run, source-level `definition.include`/`definition.exclude` lists are unioned with the matching rule's corresponding lists. A rule include list is not an exclusive whitelist, and an empty rule include does not define a whitelist. For example, source include `PrivateNotes`, rule include `PublicName, Badge` and source exclude `Badge` leave `PrivateNotes, PublicName`: exclusions win. + +The inspected query/record read routes invoke each helper when the matching rule has the corresponding **nonempty** list. Do not infer that a source list alone is always applied by those routes. These helpers filter read responses; they do not establish write-persistence restrictions. + +Successful write authorization does not itself prove that returned rows or columns satisfy read restrictions. The inspected `commit()` response can return source entries without owner-select filtering or rule column filtering; hash redaction is separate. Source permissions and caller-supplied response options also affect this path. Test response isolation independently before choosing a writable policy for private data; setting `returnEntries: false` in cooperative clients does not enforce that option on other callers. + Column restrictions do not have a uniform write contract: | Path | `exclude` | `include` | @@ -129,7 +141,7 @@ Column restrictions do not have a uniform write contract: | Select query/record response | Removes excluded data columns | Restricts returned data columns after exclusion; a field removed by `exclude` is not restored by `include` | | Single insert/update | Removes submitted excluded fields before persistence; this is filtering, not necessarily a rejected request | Does not reliably filter the persisted single-write payload | | Bulk append/replace/commit | Does not filter each persisted entry | Does not filter each persisted entry | -| Query update | Has separate affected-record filtering behavior | Do not assume single-write or bulk behavior | +| Query update | Omits matched exclusions from affected-row update data when operation checks run | Not a write whitelist | A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. @@ -166,8 +178,11 @@ Assigning `query.OwnerEmail` or `query.UpdatedAt` does not establish a persisted `DataSources(idOrName).find(options)` and `.findOne(options)` perform server-side reads without applying the target source's access rules. A lookup may therefore expose fields ordinary app users cannot read. Use it only for the minimum policy decision, and do not return its contents to clients. +Before using this script, create **Reading Groups** with `Email` and `Group` columns and membership rows. The signed-in identity must have a nonempty string `Email`; column names alone do not enforce that type. The script validates its presence without normalizing or rewriting it. + ```js -if (!user || !session || session.dataSourceId !== 731 || type !== 'select') { +if (!user || !session || session.dataSourceId !== 731 || type !== 'select' + || typeof user.Email !== 'string' || user.Email.trim().length === 0) { return { granted: false }; } const membership = await DataSources('Reading Groups').findOne({ diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md index df359ff7..fe93eb14 100644 --- a/docs/API/datasources/testing-security.md +++ b/docs/API/datasources/testing-security.md @@ -24,6 +24,8 @@ Create dedicated test data sources and records. Record the saved rule array, log | Inactive member | Protected write | Denied | | Approver with multiple roles | Supported review operation | Allowed | | Member without required role | Same operation | Denied | +| Bound member with commit/bulk access | Own-data write with response enabled/default | Returned rows and columns satisfy the intended read isolation | +| Denied or wrong-source actor | Direct response-bearing request | No forbidden data returned or persisted | Adapt the matrix to the configured policy. Include missing/null identity fields, different native types and each write method your app uses. For protected ownership, privilege or state fields, attempt malicious payloads directly through the SDK as well as through the UI. A hidden button does not stop an SDK call. @@ -49,7 +51,7 @@ Also confirm the UI setting is enabled and the preview is outside editor interac ## Run allowed and denied operations -Use an online connection to avoid counting cached reads or queued writes as server proof: +Configure the fixtures and select-only policy from the [owner-filtered read illustration](security-examples.md#private-sketches), then sign in as Ada through its bound Members login source. Use an online connection to avoid counting cached reads or queued writes as server proof: ```js const sketches = await Fliplet.DataSources.connectByName('Composition Sketches', { offline: false }); @@ -57,12 +59,18 @@ const rows = await sketches.find({ where: { OwnerEmail: 'ada@example.org' } }); console.log(rows.map(entry => ({ id: entry.id, data: entry.data }))); ``` -Run this only after configuring the matching [private sketches policy](security-examples.md#private-sketches) and signing in as Ada. Record the successful server response and returned rows. Repeat with another actor's filter and confirm the authorization denial. +Record the successful server response and returned rows. This read illustration does not establish complete source privacy. Repeat with another actor's filter and confirm the authorization denial. For every allowed write, perform a fresh read and check the exact saved values. For every forbidden write, inspect the rejection and verify that no forbidden insert, update or delete persisted. Use a trusted Studio readback where the app's read rules hide the affected fields; label it as persistence evidence, not an app-user access test. Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate single-record `exclude` behavior to bulk writes. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. +## Check write responses + +For grants that admit commit/bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data append or commit with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. + +Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. + ## Diagnose a failure A policy denial must be supported by the error response and unchanged forbidden state. An HTTP 400 or 401 alone is insufficient because errors vary by method and caller. diff --git a/docs/Data-source-security.md b/docs/Data-source-security.md index 11504d25..1368ec75 100644 --- a/docs/Data-source-security.md +++ b/docs/Data-source-security.md @@ -15,7 +15,7 @@ Protect app data with server-evaluated access rules, then test the allowed and d 1. Open **App Data** in Fliplet Studio, select the data source and open **Access Rules**. 2. Define the actors, operations and fields that need protection. Start with an explicit empty rules array (`[]`) for a source that should deny app access until configured. Missing or null rules retain legacy unrestricted access. -3. Choose a [complete security example](API/datasources/security-examples.md) and adapt its data shape and login-source binding. Save the rules, then read back the configuration. +3. Choose a [security example with explicit scope](API/datasources/security-examples.md) and adapt its data shape and login-source binding. Save the rules, then read back the configuration. 4. Test both allowed and denied operations with [Enforce security enabled in preview](API/datasources/testing-security.md). Confirm persisted results using a fresh read. A minimal public read-only policy is: @@ -33,7 +33,7 @@ Use this only for data intended for anonymous readers. It grants no insert, upda | Task | Guide | |---|---| | Understand rule order, identity, requirements and endpoint differences | [Security rule reference](API/datasources/security-rules.md) | -| Configure public, shared, private, profile or role/state data | [Security examples](API/datasources/security-examples.md) | +| Explore public, shared, owner-filtered reads, profile or role/state examples | [Security examples](API/datasources/security-examples.md) | | Prove allowed and denied app behavior | [Authenticated preview testing](API/datasources/testing-security.md) | | Connect, query or write through JavaScript | [Fliplet.DataSources reference](API/fliplet-datasources.md) | | Configure authentication or hooks | [App authentication](API/v3/auth.md), [Session API](API/fliplet-session.md) and [Data source hooks](Data-Source-Hooks.md) | @@ -68,11 +68,11 @@ These destinations retain links to the previous sections of this guide. -[Example role based access with protected fields](API/datasources/security-examples.md#editable-profiles) +[Replacement: owner-scoped single-record profiles](API/datasources/security-examples.md#editable-profiles). This example demonstrates enabled-owner edits and single-write filtering, not the former role/protected-field policy. -[Example department scoped access](API/datasources/security-examples.md#private-sketches) +[Replacement: owner-filtered read illustration](API/datasources/security-examples.md#private-sketches). This illustrates individual owner reads, not department/manager access or complete source privacy. diff --git a/docs/bin/__tests__/datasource-security-docs.test.mjs b/docs/bin/__tests__/datasource-security-docs.test.mjs index de2740df..3bdd2547 100644 --- a/docs/bin/__tests__/datasource-security-docs.test.mjs +++ b/docs/bin/__tests__/datasource-security-docs.test.mjs @@ -72,3 +72,39 @@ it('security JSON and JavaScript fences parse and templates survive Liquid raw b } assert.ok(blocks >= 15, 'example fences must actually be exercised'); }); + +it('membership script denies missing or blank email before privileged lookup', async () => { + const body = readFileSync(join(source, 'API/datasources/security-rules.md'), 'utf8'); + const script = [...body.matchAll(/```js\n([\s\S]*?)```/g)] + .map(match => match[1]).find(code => code.includes("DataSources('Reading Groups')")); + assert.ok(script); + const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; + const run = new AsyncFunction('user', 'session', 'type', 'DataSources', script); + let lookups = 0; + const DataSources = name => { + assert.equal(name, 'Reading Groups'); + return { findOne: async options => { + lookups++; + assert.deepEqual(options.where, { Email: 'ada@example.org', Group: 'Curators' }); + return { Email: 'ada@example.org', Group: 'Curators' }; + } }; + }; + for (const user of [undefined, {}, { Email: null }, { Email: 7 }, { Email: '' }, { Email: ' ' }]) { + assert.deepEqual(await run(user, { dataSourceId: 731 }, 'select', DataSources), { granted: false }); + } + assert.equal(lookups, 0); + assert.deepEqual(await run({ Email: 'ada@example.org' }, { dataSourceId: 731 }, 'select', DataSources), { granted: true }); + assert.equal(lookups, 1); +}); + +it('compatibility links describe the actual replacement scopes', () => { + const entry = readFileSync(join(source, paths[0]), 'utf8'); + assert.match(entry, /\[Replacement: owner-scoped single-record profiles\]\(API\/datasources\/security-examples\.md#editable-profiles\)/); + assert.match(entry, /\[Replacement: owner-filtered read illustration\]\(API\/datasources\/security-examples\.md#private-sketches\)/); + assert.ok(entry.includes('not department/manager access or complete source privacy')); + const examples = readFileSync(join(source, 'API/datasources/security-examples.md'), 'utf8'); + const section = examples.slice(examples.indexOf(''), examples.indexOf('## Editable profiles')); + const policy = JSON.parse(section.match(/```json\n(\[\s*\{\s*"type"[\s\S]*?)```/)[1]); + assert.deepEqual(policy[0].type, ['select']); + assert.ok(section.includes('does not prove every commit request is denied')); +}); From 632f7277dfd2467b59eba2f99045c54bbcc0080c Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 8 Oct 2026 15:55:16 +0100 Subject: [PATCH 03/13] DEV-2201 Refactor Data Sources SDK references and verified contracts --- .../fliplet-data-sources/SKILL.md | 12 +- docs/.well-known/agent-skills/index.json | 2 +- docs/.well-known/llms-full.txt | 3491 +++++++---------- docs/.well-known/llms-v3-libraries.json | 4 +- docs/.well-known/llms.txt | 12 +- docs/API/datasources/file-attachments.md | 141 + docs/API/datasources/joins.md | 128 +- docs/API/datasources/managing-data-sources.md | 154 + docs/API/datasources/offline-database.md | 202 + docs/API/datasources/query-operators.md | 114 +- docs/API/datasources/reading-data.md | 163 + docs/API/datasources/subscriptions.md | 203 + docs/API/datasources/views.md | 22 +- docs/API/datasources/writing-data.md | 229 ++ docs/API/fliplet-datasources.md | 2065 +--------- .../datasource-reference-docs.test.mjs | 47 + docs/v3/capabilities.md | 2 +- 17 files changed, 2856 insertions(+), 4135 deletions(-) create mode 100644 docs/API/datasources/file-attachments.md create mode 100644 docs/API/datasources/managing-data-sources.md create mode 100644 docs/API/datasources/offline-database.md create mode 100644 docs/API/datasources/reading-data.md create mode 100644 docs/API/datasources/subscriptions.md create mode 100644 docs/API/datasources/writing-data.md create mode 100644 docs/bin/__tests__/datasource-reference-docs.test.mjs diff --git a/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md b/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md index b67d3813..0828dc02 100644 --- a/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md +++ b/docs/.well-known/agent-skills/fliplet-data-sources/SKILL.md @@ -9,13 +9,19 @@ Data sources JavaScript API and security model: query, insert, update, delete re ## Documentation -- [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. +- [Data Source file attachments](https://developers.fliplet.com/API/datasources/file-attachments.md): Attach files to Data Source entries with insert or update, handle upload constraints and distinguish file uploads from dataset imports. +- [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows with named join configuration and read them from each record’s joins property. +- [Managing data sources](https://developers.fliplet.com/API/datasources/managing-data-sources.md): Source metadata, creation, configuration, deletion and Studio user roles, separate from app record access. +- [Offline Data Source database](https://developers.fliplet.com/API/datasources/offline-database.md): Read and modify native bundled Data Sources with Database handles, local collection methods, views, indexes, and explicit persistence boundaries. - [Data Sources query operators](https://developers.fliplet.com/API/datasources/query-operators.md): Filter Data Source queries with MongoDB/Sift operators ($eq, $gt, $in, $regex, $and, $or) inside connection.find() where clauses. +- [Reading Data Source records](https://developers.fliplet.com/API/datasources/reading-data.md): Read records, filter and sort queries, paginate with cursors, and retrieve distinct column values with Fliplet.DataSources. - [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, owner-filtered read, profile and role-based examples with data shapes and allowed and denied SDK operations. - [Data source security rule reference](https://developers.fliplet.com/API/datasources/security-rules.md): Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior. +- [Data Source subscriptions](https://developers.fliplet.com/API/datasources/subscriptions.md): Subscribe to Data Source changes, handle entry payloads, and stop monitoring when a screen is hidden or removed. - [Testing data source security in preview](https://developers.fliplet.com/API/datasources/testing-security.md): Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled. -- [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. -- [Fliplet.DataSources](https://developers.fliplet.com/API/fliplet-datasources.md): Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +- [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Request named filters that bind to the current login session. Views filter query results; access rules must enforce privacy independently. +- [Writing Data Source records](https://developers.fliplet.com/API/datasources/writing-data.md): Insert, update, delete and commit Data Source records with the correct input shapes, options, write outcomes and security limits. +- [Fliplet.DataSources](https://developers.fliplet.com/API/fliplet-datasources.md): Connect to Fliplet data sources and find references for queries, writes, subscriptions, native storage and source management. - [Fliplet infrastructure data flow](https://developers.fliplet.com/Data-flow.md): Architecture diagram showing how data flows between Fliplet Studio, Fliplet servers, app clients, and connected data sources. - [Data Source Hooks](https://developers.fliplet.com/Data-Source-Hooks.md): Trigger emails, SMS, push notifications, web requests, or column operations on Data Source insert/update/beforeSave/beforeQuery using Sift.js match conditions. - [Securing Fliplet data sources](https://developers.fliplet.com/Data-source-security.md): Configure data source access rules and verify allowed and denied operations as authenticated app users. diff --git a/docs/.well-known/agent-skills/index.json b/docs/.well-known/agent-skills/index.json index 188efb71..2e079c22 100644 --- a/docs/.well-known/agent-skills/index.json +++ b/docs/.well-known/agent-skills/index.json @@ -11,7 +11,7 @@ "js-api", "security" ], - "sha256": "94c8d56f5b7073025d2e1ec79eaaea4624abfc8095e95530755185c2b9651a49" + "sha256": "53b36c4633457251dca464b2ccdcc0b27d09340f7ff062d00da7f0a596f3bfae" }, { "name": "fliplet-app-actions", diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 5cbdfef5..789c46c6 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -11859,12 +11859,151 @@ Fliplet.Foo.getAll({ type: 'bar' }).then((instances) => { /* Returns an array of --- +# Data Source file attachments +URL: https://developers.fliplet.com/API/datasources/file-attachments.md + +# Data Source file attachments + +Single-record `insert` and `update` accept file attachments alongside column values. Uploaded fields become stored file URLs with associated media IDs; dataset import is a separate capability. + +## Before you start + +Load the [Data Sources library](../fliplet-datasources.md), configure app-user [write permissions](../../Data-source-security.md) and use a source with an attachment column. The examples use **Equipment requests** with `Item` and `Receipt` columns. The caller also needs the appropriate app/media context and available storage. + +The example requires a selected browser `File` from an ``. Upload only after the user chooses the intended file. Use an online connection and `{ offline: false }` for `insert` when the flow must receive a server record; multipart input cannot be queued after a failed send. + +## Attachment inputs + +| Input | Example | Behavior | +|---|---|---| +| `FormData` | `form.append('Receipt', file, file.name)` | Sent as multipart. Include ordinary fields in the same form. | +| Plain insert object containing `File` or `Blob` | `{ Item: 'Headset', Receipt: file }` | For `insert`, the SDK converts detected files to multipart. For `update`, pass a single File/Blob using explicit FormData: a nonarray object field is JSON-serialized rather than preserved. A File supplies its filename; a Blob needs a usable MIME type or filename. | +| Plain object containing a base64 data URL | `{ Receipt: 'data:image/png;base64,...' }` | Detected as file input and converted to multipart. Use a complete data URL, not an arbitrary base64 string. | +| Array of file inputs | `{ Receipt: [fileA, fileB] }` | File array fields are sent using the `Receipt[]` name and return URL/ID arrays. | + +In FormData, ordinary scalar values are strings. Serialize nested nonfile values intentionally rather than relying on browser object coercion. The upload response and read-back are the authoritative stored shapes. + +## Insert an entry with a file + +This function receives a browser File. It checks that the write returned a server entry, then reads that ID to verify the stored attachment field. The read-back requires read permission. + +```js +async function saveReceipt(file) { + if (!(file instanceof File)) { + throw new Error('Choose a receipt file first.'); + } + + const connection = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false + }); + const form = new FormData(); + form.append('Item', 'Headset'); + form.append('Receipt', file, file.name); + + const created = await connection.insert(form, { offline: false }); + if (!created || !created.id || !created.data) { + throw new Error('The upload did not return a saved record.'); + } + + const saved = await connection.findById(created.id); + console.log('Stored receipt URL:', saved.data.Receipt); + return saved; +} +``` + +Call it from your existing file-input handler with `event.target.files[0]`, checking that a file exists. Catch errors in that handler and display the returned message; do not immediately repeat the upload because a failed response can follow a saved entry or file. + +## Update an existing attachment + +`connection.update(id, formData, options?)` uploads files onto an existing row. Obtain `id` from a current query/read; use a File selected by the user. This example uses the same source and field as the insertion example: + +```js +async function replaceReceipt(entryId, file) { + if (!entryId || !(file instanceof File)) { + throw new Error('Choose an existing request and a receipt file.'); + } + const connection = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false + }); + const form = new FormData(); + form.append('Receipt', file, file.name); + await connection.update(entryId, form); // allowOffline remains false + const saved = await connection.findById(entryId); + console.log('Stored receipt:', saved.data.Receipt); + return saved; +} +``` + +Existing array-valued attachment fields can have new file URLs appended rather than replacing the array. Do not assume uploading a replacement deletes earlier media files. Media lifecycle and file access are covered by the [Media API](../fliplet-media.md). + +## Options and returned fields + +The [individual-write reference](writing-data.md#individual-writes) owns all insert/update options. `folderId` selects a media folder for attachments; it does not grant access to that folder. `ack` concerns local reconciliation, not upload authorization. `public` requests shared-content behavior for the entry and is not a file-access permission switch. + +The returned record uses a `{ id, data, ... }` envelope. For a file field named `Receipt`, stored `data.Receipt` contains its URL, or an array of URLs for a multi-file field. `data.ReceiptMediaFileId` contains the associated media ID or ID array. Excluded fields might be absent, and protected hashes are redacted. + +An entry response does not mean every server hook, malware scan or downstream action completed. Check file access using the intended app identity; visibility in Studio is not sufficient. + +### Per-field file schema + +The optional `_flSchema` input is an object keyed by attachment field name. In FormData it is supplied as a JSON string. It configures file handling rather than enforcing a Data Source column type. + +| `_flSchema.Receipt` property | Type | Behavior | +|---|---|---| +| `append` | Boolean | `false` discards existing attachment arrays in favor of the requested files/values. Omitted values allow retaining existing arrays for array uploads. This does not delete the earlier media objects. | +| `name` | String | Overrides the stored uploaded filename. | +| `mediaFolderId` | Number | Overrides the request's `folderId` for this attachment field. | +| `encryptionKey` | String | Supplies a private file-encryption key to the configured media upload path. Use only as part of an established encrypted-media integration; supplying a key is not an access-rule grant. | + +For an intentionally replaced multi-file field, include the schema and file array in the same request: + +```js +function receiptForm(files) { + if (!Array.isArray(files) || !files.length || !files.every(file => file instanceof File)) { + throw new Error('Choose at least one receipt file.'); + } + const form = new FormData(); + form.append('_flSchema', JSON.stringify({ Receipt: { append: false } })); + files.forEach(file => form.append('Receipt[]', file, file.name)); + return form; // pass to connection.update(existingEntryId, form) +} +``` + +## Upload constraints and failures + +The current server accepts a maximum of **500 MiB per attached file** (524,288,000 bytes). A multipart request envelope above **510 MiB** can be rejected before individual file processing. Plan lower app limits when several files share a request; hosting limits and available organization storage can further restrict uploads. + +Files need a recognizable filename extension or a supported MIME type from which an extension can be determined. Unrecognized formats and over-limit files reject with HTTP 422. Executable, script, binary and archive extensions rejected by the current validator are: + +```text +exe msi dll bat cmd sh bash jar bin sys +js rb py php ts pl cgi +so drv vxd +zip tar 7z rar gz iso +``` + +Use normal lowercase filename extensions and a MIME type matching the file. Do not treat an extension list or client-side validation as an upload security guarantee. Server storage, access and scanning controls still apply. + +`insert` can expose permanent HTTP 401/422 failures as `{ code: 2, message }`. A multipart send failure can reject `{ code: 1, message }`; the message can contain a nested error object. Update normally rejects the request error. Enabling `allowOffline` for update can instead resolve an offline-shaped value after failure, so leave it disabled when confirming an upload. See [write outcomes and recovery](writing-data.md#failures-and-recovery). + +## Dataset imports + +`connection.import(data)` exists as a transport wrapper: it applies the client `insert` hook and posts its input without automatic processing or a content-type declaration. That signature does **not** establish CSV-file parsing support. The inspected destination consumes an `entries` batch, and a FormData file alone does not establish that an import occurred. No CSV `FormData` example is recommended here. + +For data you have already parsed and validated into flat column objects, use [append](writing-data.md#appending-and-replacing-a-batch) to add rows or `replaceWith` for an intentional complete replacement. This does not provide a CSV parser. Follow the supported Studio import workflow for file-based dataset import, or supply your own verified parser before calling a batch method. + +`append`, `replaceWith` and `commit` are not single-record file upload methods. Do not pass File/Blob values to their JSON batch payloads and assume they will upload attachments. + +[Back to Data Sources](../fliplet-datasources.md) + +--- + # Data Source joins URL: https://developers.fliplet.com/API/datasources/joins.md # Data Source joins -Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. +Fetch related rows with named join configuration and read them from each record’s joins property. Joins are defined by a unique name and their configuration options; any number of joins can be defined when fetching data from one data source: @@ -11876,10 +12015,10 @@ const connection = await Fliplet.DataSources.connect(123); const result = await connection.find({ join: { // ... with their comments - Comments: { options }, + Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them - Users: { options } + Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }); console.log(result); @@ -11893,16 +12032,16 @@ Fliplet.DataSources.connect(123).then(function (connection) { return connection.find({ join: { // ... with their comments - Comments: { options }, + Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them - Users: { options } + Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }) }).then(console.log) ``` -Before we dive into complete examples, let's start with the three types of joins we support. +The following sections describe join configurations and result shapes. ## Types of joins @@ -11919,6 +12058,8 @@ Consider **an example** where two dataSources are created as follows: #### Articles +Here `ID` is the record metadata `id`, and `ArticleID` is a data column in Comments. + | ID | Title | |----|-------------------------| | 1 | A great blog post | @@ -11939,9 +12080,9 @@ We can simply reference the entries between the two dataSources as follows: const result = await connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -11954,9 +12095,9 @@ const result = await connection.find({ connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -11975,9 +12116,9 @@ Inner joins are defined like left joins but with the `required` attribute set to const result = await connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, required: true } @@ -11991,9 +12132,9 @@ const result = await connection.find({ connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, required: true } @@ -12056,9 +12197,9 @@ Example input: const result = await connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -12071,9 +12212,9 @@ const result = await connection.find({ connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -12086,18 +12227,18 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { Comments: [ { id: 3, - dataSourceId: 123, + dataSourceId: 456, data: { ArticleID: 1, 'Comment text': 'Thanks! This was worth reading.', Likes: 5 } }, { id: 4, - dataSourceId: 123, + dataSourceId: 456, data: { ArticleID: 1, 'Comment text': 'Loved it, would read it again.', Likes: 2 } } ] @@ -12118,9 +12259,9 @@ Example input: const result = await connection.find({ join: { HasComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, has: true } @@ -12134,9 +12275,9 @@ const result = await connection.find({ connection.find({ join: { HasComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, has: true } @@ -12150,7 +12291,7 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { HasComments: true @@ -12158,7 +12299,7 @@ Example of the returned data: }, { id: 2, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'Something worth reading' }, joins: { HasComments: false @@ -12179,9 +12320,9 @@ Example input: const result = await connection.find({ join: { NumberOfComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, count: true } @@ -12195,9 +12336,9 @@ const result = await connection.find({ connection.find({ join: { NumberOfComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, count: true } @@ -12211,7 +12352,7 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { NumberOfComments: 2 @@ -12219,7 +12360,7 @@ Example of the returned data: }, { id: 2, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'Something worth reading' }, joins: { NumberOfComments: 0 @@ -12240,9 +12381,9 @@ Example input: const result = await connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, sum: 'Likes' } @@ -12256,9 +12397,9 @@ const result = await connection.find({ connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, sum: 'Likes' } @@ -12272,7 +12413,7 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { LikesForComments: 7 @@ -12280,7 +12421,7 @@ Example of the returned data: }, { id: 2, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'Something worth reading' }, joins: { LikesForComments: 0 @@ -12301,9 +12442,9 @@ Use the `where` parameter to define a filtering query for the data to be selecte const result = await connection.find({ join: { LikesForPopularComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, where: { // only fetch a comment when it has more than 10 likes @@ -12320,9 +12461,9 @@ const result = await connection.find({ connection.find({ join: { LikesForPopularComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, where: { // only fetch a comment when it has more than 10 likes @@ -12343,9 +12484,9 @@ Use the `attributes` parameter to define which fields should only be returned fr const result = await connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -12360,9 +12501,9 @@ const result = await connection.find({ connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -12381,9 +12522,9 @@ Use the `limit` parameter to define how many entries should be returned at most const result = await connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch up to 5 comments at most limit: 5 @@ -12398,9 +12539,9 @@ const result = await connection.find({ connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch up to 5 comments at most limit: 5 @@ -12425,9 +12566,9 @@ Use the `order` parameter to define the order at which entries are returned for const result = await connection.find({ join: { MostRecentComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the 5 most recent comments, combining order and limit // note: inside a join, order is a single flat [column, direction] pair @@ -12444,9 +12585,9 @@ const result = await connection.find({ connection.find({ join: { MostRecentComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the 5 most recent comments, combining order and limit // note: inside a join, order is a single flat [column, direction] pair @@ -12469,7 +12610,7 @@ const result = await connection.find({ LikesForComments: { dataSourceName: 'User comments', on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -12486,7 +12627,7 @@ connection.find({ LikesForComments: { dataSourceName: 'User comments', on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -12500,6 +12641,366 @@ connection.find({ [Back to DataSources general documentation](../fliplet-datasources) {: .buttons} +## Combinations and result counts + +When reduction options are combined, `count` takes precedence over `has`, which takes precedence over `sum`. Reductions use the matched rows before `limit` and `attributes`; those options apply to array results. `sum` parses the named data column as a number and treats unparseable values as zero. Prefer one reduction option per join. + +Online parent pagination is applied before required-join filtering. Therefore `pagination.total` can include parents removed by a required join, and a returned page can contain fewer rows than its limit. Joins use an SDK/API composition, not a promise of a database-native SQL JOIN or a universal performance improvement. Native local join behavior differs; verify the target platform rather than assuming all online combinations behave identically. + +[Reading records and pagination](reading-data) + +--- + +# Managing data sources +URL: https://developers.fliplet.com/API/datasources/managing-data-sources.md + +# Managing data sources + +Namespace methods manage source metadata and settings. They differ from connection methods that read and write records, and require a caller authorized for the relevant management operation. + +## Before you start + +Use these methods in an authorized Studio or management integration context. Ordinary app-user access rules do not grant source administration. Available sources and metadata depend on the current app, organization and caller permissions. To implement app record CRUD, use [reading data](reading-data.md) and [writing data](writing-data.md). + +Source columns are field names, not a typed schema. `accessRules` configure app operations; source-user roles configure Studio users' source permissions. [App authentication](../v3/auth.md) owns `connection.sendValidation(data)` and `connection.validate(data)`, including their inputs and responses. `validate` refreshes the cached user session after successful validation. + +## List sources + +`Fliplet.DataSources.get(data?, options?)` returns `Promise`. Omitted arguments default to empty objects. + +| `data` property | Type and behavior | +|---|---| +| `appId` | App identifier; the caller must have the necessary app access. | +| `attributes` | Array of names or comma-separated string selecting source metadata fields. | +| `include` | Comma-separated associated metadata names supported by the listing endpoint. | +| `type` | Source type; an explicitly empty value requests ordinary sources. | +| `excludeTypes` | Comma-separated source types to exclude; system-source exclusions also apply. | +| `order`, `direction` | Metadata sort field and direction; default `name`, `ASC`. | +| `roles` | Comma-separated Studio app role names, applied within the caller's listing context. These are not source `crudq` permission letters or app-login `Roles` fields. | +| `definition` | Object filter on source definitions in the app context. The SDK serializes it and disables request caching. This is not an entry `where` query. | +| `includeInUse` | String `'true'` requests in-use information in supported listing contexts. | +| `includeCount` | Presence requests counts in supported listing contexts. | + +`options.cache` defaults to `true`; explicit `false` bypasses SDK request caching. Listing rejects on access, request or filter failures. Results are source objects such as `{ id, name, columns, definition }`, with fields determined by projection and permissions. + +```js +async function listSourceNames() { + const sources = await Fliplet.DataSources.get({ attributes: ['id', 'name'] }, { + cache: false + }); + return sources.map(source => ({ id: source.id, name: source.name })); +} +``` + +## Read source metadata + +`Fliplet.DataSources.getById(id, options?)` returns `Promise` and rejects for missing/inaccessible sources or request failures. + +| Option | Type and behavior | +|---|---| +| `attributes` | Array or comma-separated string of metadata fields. | +| `cache` | Request caching; default `true`. | +| `params` | Query-parameter object. `includeEntriesCount` requests an entry count when present. `include: 'associatedPages'` requests page associations when an app context exists. | + +```js +async function inspectSource(sourceId) { + return Fliplet.DataSources.getById(sourceId, { + attributes: ['id', 'name', 'columns'], + cache: false, + params: { includeEntriesCount: true } + }); +} +``` + +Reading source metadata does not prove permission to read every entry or bypass app rules. + +## Create a source + +`Fliplet.DataSources.create(options)` returns `Promise`. Use a nonempty `name` and supply the authorized app/organization scope. The SDK does not automatically add `appId` or `organizationId`; the request needs one of these scopes. + +| Option | Type and consequence | +|---|---| +| `name` | Required nonempty String. | +| `appId` or `organizationId` | Required management scope identifier; supply the app or organization the caller can administer. | +| `columns` | Array of column names. | +| `entries` | Optional seed-entry array handled during source creation; returned value is the source, not a list of inserted rows. | +| `accessRules` | Rule array. If omitted/falsy, the SDK supplies `[]`, denying app operations until an applicable grant is configured. | +| `definition` | Source definition Object; login, view and index settings need their owning references. | +| `hooks` | Server-hook configuration; see [Data Source Hooks](../../Data-Source-Hooks.md). | +| `bundle` | Bundling setting for native apps. | +| `encrypted`, `type`, `appCapabilities` | Source configuration fields for the relevant management workflow; do not infer app-user permissions from them. | + +This setup example creates an empty source with app access denied. Run it once in a disposable, authorized management context; repeated calls create additional sources rather than guaranteeing name-based reuse. + +```js +async function createDisposableSource(appId) { + if (!appId) throw new Error('Supply the disposable test app ID.'); + const source = await Fliplet.DataSources.create({ + appId, + name: 'Workshop Test Records', + columns: ['Title', 'Status'], + accessRules: [] + }); + console.log('Created source', source.id); + return source; +} +``` + +Invalid configuration, missing scope, feature limits or permission failures reject. Configure and verify [access rules](../../Data-source-security.md) before app users use the new source. + +## Update source settings + +`Fliplet.DataSources.update(id, data)` and `Fliplet.DataSources.update({ id, ...data })` return `Promise`. Missing ID throws synchronously in the online SDK path. The object overload removes `id` from the supplied object; pass a fresh object if you need to retain it. + +The source must allow the caller to update settings. Supported settings include `name`, `columns`, `definition`, `hooks`, `bundle`, `accessRules` and `appCapabilities`. Each field has its own setter/validation behavior; do not assume recursive merging or that this is a record update. A successful update clears SDK request caches. Invalid names/settings, denied permission or request failures reject. + +```js +async function renameSource(sourceId) { + const source = await Fliplet.DataSources.update(sourceId, { + name: 'Workshop Archive' + }); + return source.name; +} +``` + +`connection.update(id, data, options?)` updates one entry instead. Its separate signature and outcomes are in [writing data](writing-data.md). + +## Delete a source + +`Fliplet.DataSources.delete(id)` returns a Promise resolving the deletion response. It deletes the source rather than an individual entry; do not expect an entry array. Use only a disposable source or a deliberate management action. App-token callers are rejected by the source-deletion endpoint. Permission and request failures reject. + +Delete one entry with `connection.removeById(id)` instead. Do not implement source deletion as app-user record cleanup. + +## Studio user roles + +These connection methods manage source permissions for Studio users. They do not create app login records, set a login record's `Roles` array or prove app-user authorization. + +| Method | Arguments and result | +|---|---| +| `connection.getUsers()` | Returns `Promise` of `{ fullName, firstName, lastName, email, dataSourceRole }`. Email is masked and `dataSourceRole.userId` is opaque, not a numeric account ID. | +| `connection.addUserRole(data)` | `data.userId` is a numeric Studio account ID; `permissions` defaults to `'crudq'` and accepts only those permission letters; optional `definition` holds role configuration. Returns the created role object. | +| `connection.removeUserRole(userId)` | Uses the source-role identifier expected by the management endpoint, which can be the opaque identifier from the listing. Returns the deletion response, not the removed user. | + +`dataSourceRole` includes `userId`, `permissions`, `definition`, `createdAt`, `updatedAt` and `dataSourceId`. Do not feed a masked email or opaque listing identifier into `addUserRole` as the numeric account ID. Invalid permission letters or a nonnumeric creation ID reject with a validation error; missing role/access and request failures reject. + +## Mapped-row helper + +`Fliplet.DataSources.fetchWithOptions(options)` is a convenience wrapper. Prefer `connection.find` when entry IDs/metadata or pagination envelopes are needed. + +| Option | Contract | +|---|---| +| `dataSourceId` | Required source identifier. Missing/falsy value rejects. | +| `query` | Query object passed to `find`. Keep normal array results; pagination/object envelopes are not compatible with its row mapping. | +| `columns` | Array of field names or single-key maps, e.g. `['Name', { label: 'Title' }]`. Omitted/empty selects row data directly. | +| `compact` | Default `true`; excludes mapped null/undefined/blank strings. `false` retains these mapped values. | +| `cache` | Converted to Boolean; default `false` for the entry request. | + +The helper connects online and normally resolves `{ dataSource, dataSourceEntries }`. `dataSourceEntries` contains flat column objects, not `{ id, data }` entries. `dataSource.columns` is recalculated from available mapped columns. Empty mapped rows are removed. A metadata failure is tolerated and can leave metadata incomplete; an entry query failure still rejects. When `query.aggregate` is supplied, it returns the raw aggregation result instead of this wrapper. + +## Client helpers and internal exports + +[The main reference](../fliplet-datasources.md#sdk-helper-boundaries) classifies client hooks, temporary IDs and connection lookup helpers. They do not enforce server authorization. Underscore helpers and core queue plumbing are not recommended app interfaces. Native `applyDiff` is described only as an update-pipeline compatibility surface in [offline storage](offline-database.md). + +--- + +# Offline Data Source database +URL: https://developers.fliplet.com/API/datasources/offline-database.md + +# Offline Data Source database + +`Fliplet.DataSources.Database` opens bundled Data Source data and returns local collection handles. Native collection writes change the local bundle; they do not make an online Data Source write or establish server authorization. + +## Choose the correct API + +Use [Data Source connections](../fliplet-datasources) for application reads and writes. Native connections integrate bundled reads and local acknowledgments with online operations. The lower-level `Database` API is for code that intentionally manages local bundle data. + +A native bundle and `Fliplet.Native.Maintenance` must be available. Opening a database on web fetches bundle data, but local write methods still call native file APIs. A successful web read does not establish native filesystem or synchronization behavior. Native applications must verify file persistence, incremental delivery and recovery on their supported devices. + +Offline data and cached-session views are not substitutes for [security-enabled online tests](testing-security). + +## Open a bundled Data Source + +`Fliplet.DataSources.Database(options)` returns `Promise`. + +| Option | Type | Required | Behavior | +|---|---|---|---| +| `id` | Number or String | Yes when not supplying `name` | Data Source ID; a string name requires `isName: true`. | +| `isName` | Boolean | No | With a truthy value, resolves `name || id` from the app's bundled-source metadata. Use `true` for names. | +| `name` | String | No | Alternative name when `isName: true`; it takes precedence over `id`. | +| `appId` | Number | No | Defaults to the current app ID. Selects the native app data directory. | + +The following native example assumes `Products` is bundled in the app. It reads without changing the bundle. + +```js +const database = await Fliplet.DataSources.Database({ + id: 'Products', + isName: true +}); +const products = database.dataSource(null, { name: 'Products' }); +const entries = await products.find({}, {}); +for (const entry of entries) { + console.log(entry.id, entry.data.Name); +} +``` + +An ID-based connection uses matching IDs at both stages: + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const entry = await products.findById(101); +if (entry) console.log(entry.data.Name); +``` + +The filename-string overload, such as `Database('dataSources.db')`, supports older multi-source bundles. It is a compatibility form used by the connection API. Prefer the ID/name options for dedicated bundles; do not assume a dedicated bundle includes other Data Sources. + +There is no public close/dispose method on a database handle. Do not depend on repeated native opens avoiding file reads; use the handle you already opened for related operations. + +## Select a collection + +`database.dataSource(id, options?)` returns a collection handle synchronously. + +| Argument | Type | Behavior | +|---|---|---| +| `id` | Number or numeric String | Selects a Data Source present in the loaded bundle. | +| `options.name` | String | Resolves the name within the loaded bundle's source descriptors instead of using the supplied ID. | + +Selecting an unavailable source can throw synchronously. Choose a source actually present in the opened bundle and wrap selection as well as awaited operations in error handling. + +Collection entries have metadata and a nested data object: + +```js +const exampleEntry = { id: 101, data: { Name: 'USB-C hub', Category: 'Accessories', Price: 49.99 } }; +``` + +## Read local entries + +### `find(where, options)` + +Returns `Promise>`. Both arguments should be supplied: use `{}` for an empty filter and `{}` for no options. Omitting `options` can throw synchronously. + +`where` filters the entry's `data` fields using the local Sift matcher. This API does not inherit every online query option. It does not provide pagination counts, joins, online authorization or server-side sorting. + +| Option | Type | Default | Behavior | +|---|---|---|---| +| `limit` | Number | No limit | A truthy value limits the matched entries. `0` does not limit results. | +| `views` | Array of strings | None | Applies matching named views before the filter. Multiple resolved view filters are combined with OR. | + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const affordableAccessories = await products.find({ + Category: 'Accessories', + Price: { $lte: 50 } +}, { limit: 20 }); +console.log(affordableAccessories.map(entry => entry.data.Name)); +``` + +Views resolve values from cached Data Source session data and, when available, the device UUID. Unknown view names or missing view definitions can leave results unfiltered. Treat views as local display filtering, not as an access-control boundary. See [Data Source views](views). + +### `findById(id)` + +Returns `Promise`. The filter addresses the entry's metadata `id`, not a column named `ID`. + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const product = await products.findById(101); +if (product) console.log(product.data.Name); +else console.log('Product is not in the local bundle.'); +``` + +## Write the local bundle + +Each method below changes in-memory data before writing the full JSON bundle to native storage. A file-write failure rejects the promise; it does not undo the earlier memory change. Local writes do not send server updates, validate server rules, or generate server IDs. Use [online writes](writing-data) when the server must persist the change. + +| Method | Arguments | Resolved value | +|---|---|---| +| `insert(entry)` | Complete entry object, including its ID and `data` | The supplied entry object. | +| `update(existingEntry, patch)` | A fetched collection entry and a partial entry object | The supplied patch, not the complete merged entry. | +| `remove(entryOrPredicate)` | Entry object matched by `id`, or a function returning true for entries to remove | The supplied object or predicate function, not a list of removed entries. | + +`update` deep-merges the patch into the existing entry in place. These operations are local bookkeeping; an ID supplied to `insert` does not reserve that ID on the server. + +This example deliberately changes a local copy of an existing product. Verify device persistence before using this behavior in an application workflow. + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const product = await products.findById(101); +if (!product) throw new Error('Product 101 is missing from the local bundle.'); + +const patch = { data: { Price: 44.99 } }; +const result = await products.update(product, patch); +console.log(result.data.Price); // 44.99: result is the patch +console.log(product.data.Name); // Existing fields remain on the merged entry +``` + +For predicate removal, inspect the affected rows first: + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const discontinued = await products.find({ Status: 'Discontinued' }, {}); +console.log('Local entries selected for removal:', discontinued.map(entry => entry.id)); +// Run only when the application intends to remove these local entries: +// await products.remove(entry => entry.data.Status === 'Discontinued'); +``` + +## Apply a server diff + +`collection.applyDiff(diff)` returns `Promise`. This method supports the native update pipeline; it is not an online write or a custom synchronization service. + +| Field | Type | Behavior | +|---|---|---| +| `updated` | Array of entries | Required in incremental mode. Removes existing entries with the same IDs, then appends the supplied replacements. | +| `deleted` | Array of objects with `id` | Required in incremental mode. Removes matching IDs. | +| `fullRefresh` | Boolean | When truthy, replaces the collection with `updated` or an empty array if `updated` is absent. | + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +// Example diff for a disposable native fixture, not an online server write. +await products.applyDiff({ + updated: [{ id: 101, data: { Name: 'USB-C hub', Price: 49.99 } }], + deleted: [{ id: 103 }] +}); +``` + +A full refresh is destructive to local entries absent from the supplied array. Incremental replacements do not deep-merge old entry data. + +## Read local indexes + +`indexes(columns)` returns `Promise` mapping each requested column to unique values. `index(column)` returns `Promise` for one column. + +Precomputed bundle indexes are used when present. Otherwise values are computed from the collection: array values are flattened, and falsy values such as `false`, `0`, `''`, `null` and `undefined` are omitted. A computed index is not a complete enumeration of all boolean or numeric values. + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const categories = await products.index('Category'); +console.log(categories); // Example: ['Accessories', 'Lighting'] +const values = await products.indexes(['Category', 'Status']); +console.log(values.Category); +``` + +Empty collections, missing indexes and unavailable collection data can reject. A missing single-column index is not guaranteed to resolve to `[]`. Precomputed indexes are not recomputed by the mutation methods described above; do not assume they reflect a subsequent local edit. + +## Errors and recovery + +| Operation | Failure behavior | +|---|---| +| Open by name | Rejects when the name cannot be resolved from the app's bundled-source metadata. | +| Open native file | Rejects for missing data or invalid JSON, and propagates native read failures. | +| Select a collection | Can throw immediately if the source is absent from the loaded bundle. | +| Read/write invalid collection | Rejects when collection entries are unavailable. `find` can also throw when options are omitted. | +| Persist a local mutation | Rejects native write failures after the memory change. | +| Read an index | Rejects unavailable or empty index results as described above. | + +Error messages are localized; do not match their English text for application control flow. When a local write fails, report the failure and reload authoritative data through the application's established update flow rather than treating the in-memory change as durable. + +[Data Sources API](../fliplet-datasources) · [Subscriptions](subscriptions) · [Testing security](testing-security) + --- # Data Sources query operators @@ -12519,10 +13020,10 @@ Fliplet Data Sources uses [Sift.js](https://github.com/Fliplet/sift.js) which pr |----------|-------------|---------|-------| | `$eq` | Equal to | `{ age: { $eq: 25 } }` | Can be simplified to `{ age: 25 }` | | `$ne` | Not equal to | `{ status: { $ne: 'inactive' } }` | | -| `$gt` | Greater than | `{ score: { $gt: 80 } }` | Optimized for performance | -| `$gte` | Greater than or equal | `{ score: { $gte: 80 } }` | Optimized for performance | -| `$lt` | Less than | `{ age: { $lt: 30 } }` | Optimized for performance | -| `$lte` | Less than or equal | `{ age: { $lte: 30 } }` | Optimized for performance | +| `$gt` | Greater than | `{ score: { $gt: 80 } }` | | +| `$gte` | Greater than or equal | `{ score: { $gte: 80 } }` | | +| `$lt` | Less than | `{ age: { $lt: 30 } }` | | +| `$lte` | Less than or equal | `{ age: { $lte: 30 } }` | | ```js // Examples @@ -12542,7 +13043,7 @@ const highScores = await connection.find({ | Operator | Description | Example | Notes | |----------|-------------|---------|-------| -| `$in` | Value in array | `{ category: { $in: ['tech', 'science'] } }` | Optimized for performance | +| `$in` | Value in array | `{ category: { $in: ['tech', 'science'] } }` | | | `$nin` | Value not in array | `{ status: { $nin: ['banned', 'suspended'] } }` | | | `$all` | Array contains all values | `{ tags: { $all: ['urgent', 'review'] } }` | For array fields | | `$size` | Array size equals | `{ items: { $size: 3 } }` | For array fields | @@ -12565,8 +13066,8 @@ const completeTasks = await connection.find({ | Operator | Description | Example | Notes | |----------|-------------|---------|-------| -| `$and` | Logical AND | `{ $and: [{ age: { $gte: 18 } }, { status: 'active' }] }` | Optimized for performance | -| `$or` | Logical OR | `{ $or: [{ role: 'admin' }, { role: 'moderator' }] }` | Optimized for performance | +| `$and` | Logical AND | `{ $and: [{ age: { $gte: 18 } }, { status: 'active' }] }` | | +| `$or` | Logical OR | `{ $or: [{ role: 'admin' }, { role: 'moderator' }] }` | | | `$nor` | Logical NOR | `{ $nor: [{ status: 'banned' }, { status: 'suspended' }] }` | None of the conditions | | `$not` | Logical NOT | `{ age: { $not: { $lt: 18 } } }` | Negates the condition | @@ -12597,8 +13098,8 @@ const activeUsers = await connection.find({ | Operator | Description | Example | Notes | |----------|-------------|---------|-------| -| `$regex` | Regular expression | `{ email: { $regex: /@company\.com$/i } }` | Case-insensitive with `i` flag | -| `$iLike` | Case-insensitive partial match | `{ name: { $iLike: 'john' } }` | Fliplet-specific, optimized | +| `$regex` | Regular expression | `{ email: { $regex: '@company\\.com$', $options: 'i' } }` | Case-insensitive with `i` flag | +| `$iLike` | Case-insensitive partial match | `{ name: { $iLike: 'john' } }` | Fliplet-specific | ```js // Example @@ -12610,10 +13111,12 @@ const johnUsers = await connection.find({ ### Existence and Type Operators +Online queries use JSON. A JavaScript `RegExp` object becomes `{}` during serialization; use a string `$regex` and `$options` instead. The bundled `$type` implementation expects constructors, which are not JSON-safe, rather than strings such as `number`. + | Operator | Description | Example | Notes | |----------|-------------|---------|-------| | `$exists` | Field exists | `{ phone: { $exists: true } }` | Checks if field is present | -| `$type` | Field type check | `{ score: { $type: 'number' } }` | Types: string, number, boolean, array, object | +| `$type` | Not supported as an online JSON type-name filter | — | Do not pass string type names or constructors in online queries. | ```js // Examples @@ -12621,9 +13124,7 @@ const usersWithPhone = await connection.find({ where: { phone: { $exists: true } } }); -const numericScores = await connection.find({ - where: { score: { $type: 'number' } } -}); +// Validate field types in application code; $type is not a JSON type-name filter. ``` ### Mathematical Operators @@ -12633,7 +13134,7 @@ const numericScores = await connection.find({ | `$mod` | Modulo operation | `{ id: { $mod: [2, 0] } }` | `[divisor, remainder]` | ```js -// Examples - find even IDs +// Find even values in the data column named id (not record metadata IDs) const evenIds = await connection.find({ where: { id: { $mod: [2, 0] } } }); @@ -12663,7 +13164,7 @@ const studentsWithGoodGrades = await connection.find({ ## Fliplet Custom $filters Operator -Fliplet provides a custom `$filters` operator that offers optimized performance and additional conditions not available in standard MongoDB operators. +Fliplet provides a custom `$filters` operator for named filter conditions. Filters are combined with AND. Online database optimization depends on the expression and input values; nested filters may be evaluated in memory. ### Syntax @@ -12693,6 +13194,10 @@ Fliplet provides a custom `$filters` operator that offers optimized performance | `<` | Less than | Number | `{ column: 'Price', condition: '<', value: 100 }` | | `<=` | Less than or equal | Number | `{ column: 'Quantity', condition: '<=', value: 50 }` | | `contains` | Case-insensitive partial match | String | `{ column: 'Email', condition: 'contains', value: '@company.com' }` | +| `notcontain`, `notcontains` | Excludes matching text; missing values do not count as a negative match | String | `{ column: 'Email', condition: 'notcontains', value: '@example.com' }` | +| `regex` | Regular-expression string | String | `{ column: 'Email', condition: 'regex', value: '@example[.]com$' }` | +| `notoneof`, `notin` | Excludes values from a list | Array/String | `{ column: 'Status', condition: 'notin', value: ['Archived'] }` | +| `none` | Inactive filter; matches all rows | None | `{ column: 'Status', condition: 'none' }` | | `empty` | Field is empty | None | `{ column: 'Notes', condition: 'empty' }` | | `notempty` | Field is not empty | None | `{ column: 'Description', condition: 'notempty' }` | | `between` | Numeric range (inclusive) | Object | `{ column: 'Age', condition: 'between', value: { from: 18, to: 65 } }` | @@ -12712,7 +13217,7 @@ Fliplet provides a custom `$filters` operator that offers optimized performance For date conditions, you can optionally specify a unit of comparison: ```js -{ +const birthdayFilter = { column: 'Birthday', condition: 'dateis', value: '1990-01-01', @@ -12786,12 +13291,12 @@ const getFilteredUsers = async (filters = {}) => { }; // Usage with destructuring -const { length: userCount, ...users } = await getFilteredUsers({ +const users = await getFilteredUsers({ minAge: 25, departments: ['Engineering', 'Design'] }); -console.log(`Found ${userCount} users matching criteria`); +console.log(`Found ${users.length} users matching criteria`); ``` --- @@ -12800,43 +13305,11 @@ console.log(`Found ${userCount} users matching criteria`); ### Optimized Operators -The following operators are optimized for better performance with Fliplet's database: - -**MongoDB-style (optimized):** -- `$or`, `$and`, `$gte`, `$lte`, `$gt`, `$lt`, `$eq`, `$in` - -**Fliplet $filters (optimized):** -- `==`, `contains` (especially optimized) - -**Optimized Value Types:** -- Strings and numbers perform better than complex objects +Some simple `$or`, `$and`, comparison and `$in` expressions can be evaluated by the online database. Supported `$filters` conditions can also be promoted when their structure and values permit. Other expressions use in-memory filtering. Operator choice alone does not guarantee a particular execution plan or faster query. ### Best Practices -1. **Use $filters for complex conditions** - Better performance than equivalent MongoDB operators -2. **Prefer optimized operators** - Use `$gte` instead of `$not: { $lt: value }` -3. **Index-friendly queries** - Simple equality and range queries perform best -4. **Combine efficiently** - Use `$and` for multiple conditions on different fields - -```js -// Good - optimized query -const optimized = await connection.find({ - where: { - $filters: [ - { column: 'Status', condition: '==', value: 'Active' }, - { column: 'Score', condition: '>=', value: 80 } - ] - } -}); - -// Also good - using optimized MongoDB operators -const mongoOptimized = await connection.find({ - where: { - Status: 'Active', - Score: { $gte: 80 } - } -}); -``` +Use the operator that expresses the required result. Add a positive `limit` for bounded reads and verify representative data and result counts. Combining filters does not turn a caller-controlled query into an access rule. --- @@ -12886,42 +13359,195 @@ const userData = await getComplexUserData({ ## Using operators in custom security rules -When writing [custom security rules](/Data-source-security#custom-security-rules), you can query other Data Sources using the `DataSources` server-side library. The `find` and `findOne` methods support the same query operators listed above. +The `DataSources(idOrName)` library inside [custom security scripts](security-rules#custom-scripts) has a separate lookup contract. Its `find(options?)` returns flat data objects, not SDK `{ id, data }` records; `findOne(options?)` returns one flat object or `undefined`. + +| Property | Type / default | Behavior | +|---|---|---| +| `where` | Object; omitted | Filters data fields. Scalar operator operands are restricted to `$or`, `$and`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$not`, `$like`, `$iLike`, `$notLike`, `$notILike`, `$eq`, `$contains`. Do not assume the app Sift operators or `$filters` work here. | +| `limit` | Number; at most `100` | Maximum lookup records; `findOne` sets it to `1`. | +| `offset` | Number; omitted | Skip records. | ```js -// Custom security rule: grant access if user is a manager in the same office -if (type === 'select') { - var entry = await DataSources(123).findOne({ - where: { - Office: user.Office, - Managers: { $in: [user.Email] }, - Status: { $ne: 'Inactive' } - } +// Security script: user is a flat login record and may be absent. +if (user && user.Office && user.Email) { + const manager = await DataSources(123).findOne({ + where: { Office: user.Office, Email: user.Email, Status: 'Active' } }); - - if (entry) { - return { granted: true }; - } + if (manager) { return { granted: true }; } } +return { granted: false }; ``` -Both `find` and `findOne` accept: - -| Property | Type | Default | Description | -|---|---|---|---| -| `where` | Object | — | Query filter using any operators from this page | -| `limit` | Number | `100` | Maximum number of records to return | -| `offset` | Number | `0` | Number of records to skip | - -

    The operators on this page are for querying data. The require property in security rules uses a different set of requirement types (equals, notequals, contains) to validate incoming queries — see data requirements and query validation.

    +Array operands and complex nested expressions must be checked against this lookup contract independently; the whitelist is not a promise of full MongoDB compatibility. A missing or inaccessible source can throw. The rule `require` property uses requirement types such as `equals`, `notequals` and `contains`, described in [security rules](security-rules); it does not use this query operator table. [Back to Data Sources Documentation](../fliplet-datasources) {: .buttons} --- -# Data source security examples -URL: https://developers.fliplet.com/API/datasources/security-examples.md +# Reading Data Source records +URL: https://developers.fliplet.com/API/datasources/reading-data.md + +# Reading Data Source records + +A Data Source connection reads records with `find`, `query`, `findOne` and `findById`. Query options control filtering and result shape; source permissions and [security rules](security-rules) control access. + +The examples use an existing source with numeric ID `123`. Replace this ID with the source attached to the app. Online calls can reject for inaccessible sources, invalid input, access rules or transport failures. Studio sample data is not evidence that app-user access works; verify in [preview with security enabled](testing-security). + +## Record shape + +Normal query results are arrays of records with `id` and `data`. Metadata such as `order`, `createdAt` and `updatedAt` may also be present. Aggregation can change this shape. + +```js +// Example record, not an insert payload +const record = { id: 41, data: { Name: 'Mina', Department: 'Design', Score: 86 } }; +console.log(record.id, record.data.Name); +``` + +## find and query + +`connection.find(query?, options?)` returns a Promise. `connection.query(query?, options?)` is an alias with the same arguments. Both default their arguments to `{}`. For online query updates and deletion, see [writing records](writing-data). + +| Query property | Type / default | Behavior | +|---|---|---| +| `where` | Object; omitted | Filters fields within `record.data`; see [query operators](query-operators). An omitted filter selects all accessible records. | +| `id`, `createdAt`, `updatedAt` | Number/date or query object; omitted | Online filters on record metadata, outside `where`. For incremental reads, `updatedAt` can also return deleted-entry metadata in an object response. Not applied by native local reads. | +| `dataSourceEntryId` | Number; omitted | Online filter on the record's metadata ID. `where: { id: ... }` instead filters a data column named `id`. | +| `type` | String; omitted | Omitted or `select` reads records. Online `update` and `delete` are writes; see [writing records](writing-data). Native local reads do not implement the online query-update path. | +| `data` | Object; omitted | Patch for online `type: 'update'`. Do not supply this for ordinary reads. | +| `attributes` | String[]; omitted | Returns the listed data fields, subject to security field restrictions. Does not change metadata ID. | +| `limit` | Number; omitted | Maximum records. Supply a positive integer; `0` is treated as omitted rather than an empty page. Online negative or nonnumeric values reject; fractions are rounded down. | +| `offset` | Number; omitted | Skip records. Use a nonnegative integer; online negative or nonnumeric values reject. | +| `order` | Array of pairs; online default `[['order', 'ASC'], ['id', 'DESC']]` | See [sorting](#sorting-and-ordering). Native local queries do not apply the same online ordering pipeline. | +| `includePagination` | Boolean; omitted | Online literal `true` returns the root response with `entries` and `pagination`. Native local reads still return an array. | +| `distinct` | String; omitted | Online retains the first row for each value of the named data column, after pagination. Other fields come from that row. Not applied by native local reads. | +| `views` | String[]; omitted | Requests named [views](views); use this plural array form. Views do not replace security rules. | +| `join` | Object; omitted | Named [joins](joins). Online and native implementations differ; verify the target platform. | +| `aggregate` | Array; omitted | Mingo aggregation pipeline applied to records. Stage paths refer to record properties such as `data.Score`. Native execution requires Mingo to be loaded. | + +| Request option | Type / default | Behavior | +|---|---|---| +| `cache` | API cache option; omitted | Forwarded to the online API request; use `false` when fresh verification is needed. Native reads use the loaded local database. | +| `authenticate` | Boolean; true when `Fliplet.Page` exists | Applies SDK entry authentication in supported paths. This is not a switch for server security enforcement. Delete queries turn it off. | +| `returnAsObject` | Boolean; omitted | Online returns `{ entries, deletedEntries }`, taking precedence over `includePagination`. Native reads still return an array. | +| `ack` | Boolean; omitted | Relevant to query deletion and native reconciliation; see [writing records](writing-data). | + +```js +const connection = await Fliplet.DataSources.connect(123); +const records = await connection.find({ + where: { Department: 'Design', Score: { $gte: 80 } }, + attributes: ['Name', 'Score'], + limit: 20, + order: [['data.Score', 'DESC']] +}, { cache: false }); +records.forEach(function(record) { console.log(record.id, record.data.Name); }); +``` + +### Pagination results + +Online `includePagination: true` returns `{ entries, dataSourceId, count, source, pagination, indexes, deletedEntries }`. Some optional properties may be absent. `count` is the returned array length. `pagination` contains `total`, `limit` and `offset`; omitted limits/offsets may be absent. `total` is calculated before distinct and required-join filtering, so it can exceed the final returned record count. + +```js +const page = await connection.find({ limit: 20, offset: 0, includePagination: true }); +console.log(page.entries.length, page.pagination.total); +``` + +Do not combine this envelope with `findOne` or `findWithCursor`, which expect array-producing queries. `returnAsObject` produces a different envelope without pagination metadata. + +## findOne + +`connection.findOne(query?, options?)` sets `query.limit` to `1` and resolves the first record or `undefined`. It modifies the supplied query object. With online `returnAsObject: true`, it resolves `{ entries: recordOrUndefined, deletedEntries }`; `entries` is a single record here, not an array. Avoid combining `returnAsObject` with native local reads. + +```js +const record = await connection.findOne({ where: { Email: 'mina@example.com' } }); +if (record) { console.log(record.id, record.data.Email); } +``` + +## findById + +`connection.findById(id)` resolves a single record. Online missing records reject with a 404 response and invalid IDs reject with a 400 response. Native local lookup can resolve `undefined`. The authenticated `session` identifier is reserved for session lookup; use numeric record IDs for ordinary reads. This method has no second request-options argument. + +```js +try { + const record = await connection.findById(41); + if (record) { console.log(record.data.Name); } +} catch (error) { + // Handle missing, denied or failed online requests in the app UI. + console.error(error); +} +``` + +## Sorting and ordering + +Top-level `order` is an array of `[column, direction]` pairs. Metadata columns are `id`, `order`, `createdAt`, `deletedAt` and `updatedAt`. Data columns use a `data.` prefix and must exist on the source. Use `ASC` or `DESC`; online data-column directions are normalized to uppercase and reject other values. Data-column numeric-looking values are sorted numerically, while other values use text ordering. A join's `order` instead takes one flat pair. + +```js +const records = await connection.find({ + order: [['data.Department', 'ASC'], ['data.Score', 'DESC']] +}); +``` + +## findWithCursor + +`connection.findWithCursor(query?, options?)` resolves an array with cursor properties and methods after fetching its first page. Supply a positive integer `limit` and an array-producing query. Cursor movement changes the query synchronously; `update()` performs the next read. + +| Cursor member | Behavior | +|---|---| +| `query` | Mutable query; pagination is `cursor.query.limit` / `cursor.query.offset`, not `cursor.limit` / `cursor.offset`. | +| `currentPage` | Zero-based page index, initially `0`. | +| `isFirstPage`, `isLastPage` | Flags updated during movement/fetch. Last-page detection fetches one extra record. | +| `firstRecordInNextPage` | Extra fetched record when another page exists; otherwise undefined. | +| `idSet`, `containsId(id)` | Set of fetched IDs and membership check. | +| `next()`, `prev()`, `setPage(page)` | Mutate page/offset and return the cursor. They do not fetch. Negative page/offset is clamped to zero. | +| `update({ keepExisting? })` | Promise resolving the cursor. Default replaces its rows and ID set. `keepExisting: true` appends rows without deduplicating overlapping pages. | + +```js +const cursor = await connection.findWithCursor({ + limit: 20, order: [['id', 'ASC']] +}); +console.log(cursor.length, cursor.query.offset); +if (!cursor.isLastPage) { + await cursor.next().update(); + console.log(cursor.currentPage, cursor.map(function(record) { return record.id; })); +} +``` + +## Column indexes + +`connection.getIndex(column)` resolves an array of distinct values. `connection.getIndexes(columns)` resolves an object mapping each requested column to its values. These source-wide lookups do not accept the `where`, views, pagination or request options of `find`; do not use them to infer the values visible to a filtered user. Verify returned values separately when configuring security. + +| Argument | Type | Required | +|---|---|---| +| `column` | String | Yes for `getIndex`. | +| `columns` | String[] | Yes for `getIndexes`; use a nonempty array. | + +Online values are extracted as text, with missing/null values represented as `null`; for example numeric `12` can return `'12'`. When the source declares columns, requesting an undeclared column rejects with a 400 response. Native indexes may use precomputed values or scan local rows; computed native indexes skip falsy values, including `false`, `0` and `''`. See [offline database](offline-database). + +```js +const departments = await connection.getIndex('Department'); +const indexes = await connection.getIndexes(['Department', 'Status']); +console.log(departments, indexes.Department, indexes.Status); +``` + +## Aggregation + +Aggregation operates on records, so field paths include `data.`. The result follows the supplied pipeline rather than guaranteeing `{ id, data }` records. Invalid pipelines can reject the query. + +```js +const summary = await connection.find({ + aggregate: [ + { $group: { _id: '$data.Department', count: { $sum: 1 } } } + ] +}); +console.log(summary); // [{ _id: 'Design', count: ... }, ...] +``` + +[Data Sources API](../fliplet-datasources) + +--- + +# Data source security examples +URL: https://developers.fliplet.com/API/datasources/security-examples.md # Data source security examples @@ -13359,6 +13985,207 @@ A denied operation rejects the SDK promise. Standard access denials may expose ` --- +# Data Source subscriptions +URL: https://developers.fliplet.com/API/datasources/subscriptions.md + +# Data Source subscriptions + +`connection.subscribe()` delivers changed entries to a callback. Online connections poll for changes; native connections with a working offline database receive incremental-update events. + +## Before subscribing + +The app must support incremental updates. `subscribe()` throws synchronously when incremental updates are disabled or the callback is missing. Establish a [Data Source connection](../fliplet-datasources) first. + +A subscription reports changes after registration, not the initial dataset. Read the initial rows separately. For [authorization tests](testing-security), use an online connection and direct reads or writes as the signed-in actor; a subscription callback is not proof of server authorization. + +## Register and clean up + +Both signatures return a subscription handle immediately: + +```js +connection.subscribe(callback); +connection.subscribe(options, callback); +``` + +This example logs changes and returns a cleanup function. The app must have incremental updates enabled and access to a Data Source named `Products`. + +```js +async function watchProducts() { + if (!Fliplet.App.supportsIncrementalUpdates()) { + throw new Error('Enable incremental updates before subscribing.'); + } + + const connection = await Fliplet.DataSources.connectByName('Products', { + offline: false + }); + + const subscription = connection.subscribe((changes) => { + for (const entry of changes.inserted || []) { + console.log('Inserted product:', entry.id, entry.data.Name); + } + for (const entry of changes.updated || []) { + console.log('Updated product:', entry.id, entry.data.Name); + } + for (const entry of changes.deleted || []) { + console.log('Deleted product:', entry.id); + } + }); + + function onVisibilityChange() { + if (document.hidden && subscription.status() === 'active') { + subscription.pause(); + } else if (!document.hidden && subscription.status() === 'paused') { + subscription.resume(); + } + } + + document.addEventListener('visibilitychange', onVisibilityChange); + onVisibilityChange(); + + return function stopWatching() { + document.removeEventListener('visibilitychange', onVisibilityChange); + subscription.unsubscribe(); + }; +} + +const stopWatchingProducts = await watchProducts(); +// Call stopWatchingProducts() when the screen is removed. +``` + +Change a product from another session to observe a callback. Call the returned cleanup function when leaving the screen; placing that call immediately after registration would end the subscription before it can deliver changes. + +## Options and callback results + +| Option | Type | Default | Behavior | +|---|---|---|---| +| `events` | Array of strings | `['insert', 'update', 'delete']` | Selects insert and update notifications. Deletion entries can still be delivered with an event subset; callbacks must handle or ignore `deleted` explicitly. | +| `cursor` | Cursor handle | None | Limits delivered entries to IDs contained in that cursor. See [reading data](reading-data). | +| `filter` | Function | None | Called with an entry object. Returning a falsy value omits the entry from the callback. | + +For ID or field matching, use a filter function. Object-valued `filter` is not applied to callback delivery. The SDK also recognizes an `id` option, but cleanup of ID-scoped subscriptions is unreliable; use the function form instead. + +```js +const connection = await Fliplet.DataSources.connectByName('Products', { + offline: false +}); +const subscription = connection.subscribe({ + filter: entry => entry.id === 101 || (entry.data && entry.data.Category === 'Accessories') +}, changes => console.log(changes)); +// Call subscription.unsubscribe() on screen teardown. +``` + +The callback receives an object with any of these nonempty arrays: + +```js +const exampleChanges = { + inserted: [{ id: 101, data: { Name: 'USB-C hub' }, createdAt: '...', updatedAt: '...' }], + updated: [{ id: 102, data: { Name: 'Desk lamp' }, createdAt: '...', updatedAt: '...' }], + deleted: [{ id: 103 }] +}; +``` + +Keys with no matching entries are omitted. An empty change set does not trigger the callback. An updated entry is classified as an insert when its `createdAt` and `updatedAt` values are equal; otherwise it is classified as an update. Deleted entries may contain less data than other entries, so ID-based removal does not require `entry.data`. + +## Subscription handle + +These methods are synchronous and do not return promises. + +| Method | Result | Behavior | +|---|---|---| +| `status()` | `'active'` or `'paused'` | Reports the handle's current monitoring status. | +| `pause()` | `undefined` | Stops delivery to this subscription. Monitoring stops when no active subscriptions remain on its connection. | +| `resume()` | `undefined` | Makes the subscription active. When it restarts online monitoring, a check is scheduled immediately. | +| `unsubscribe()` | `undefined` | Removes the subscription. Monitoring stops when no active subscriptions remain. | +| `updateOptions(options)` | `undefined` | Replaces the options object; it does not merge options. | + +`unsubscribe()` does not change `status()` to an “unsubscribed” value. Treat disposal as final and create a new subscription to subscribe again. The exposed `isInScope(entry)` helper has different matching behavior from callback delivery; use the options above for application filtering rather than treating that helper as a delivery guarantee. + +```js +subscription.updateOptions({ + events: ['update'], + filter: entry => !!entry.data && entry.data.Category === 'Accessories' +}); +``` + +This replaces earlier options, including an earlier cursor or filter. The callback remains the one supplied at registration. + +## Delivery timing and usage + +Online monitoring waits 30 seconds between completed checks. Network time, failures and browser timer scheduling add to that interval. There is no public subscription option for changing the interval, and no guarantee of instant notification. + +Subscriptions on a reused connection share monitoring. Pausing one subscription does not stop requests needed by another active subscription on that connection. Resuming while shared monitoring is already active does not force an immediate check or guarantee delivery of every change from the paused period. + +Native connections with a working offline database use incremental-update events instead of the online polling timer. If the local database cannot be loaded, the connection falls back to online monitoring. Pausing and resuming does not make the native update mechanism fetch on demand. + +Keep visibility cleanup even when the UI framework unmounts screens correctly. Avoid adding a second polling timer unless the application has a separately established need; it creates additional reads. Polling failures are logged and monitoring schedules another check; the subscription API has no error-callback parameter. + +## Framework lifecycle integration + +### Vue + +Use the plain JavaScript `watchProducts()` function above. Handle the case where setup finishes after the component has already unmounted. + +```js +import { onMounted, onUnmounted } from 'vue'; + +let stopWatching; +let disposed = false; + +onMounted(async () => { + try { + const stop = await watchProducts(); + if (disposed) stop(); + else stopWatching = stop; + } catch (error) { + console.error('Cannot subscribe to products:', error); + } +}); + +onUnmounted(() => { + disposed = true; + if (stopWatching) stopWatching(); +}); +``` + +### React + +Use the same plain JavaScript setup function inside an effect. + +```js +import { useEffect } from 'react'; + +function useProductSubscription() { + useEffect(() => { + let disposed = false; + let stopWatching; + + watchProducts().then(stop => { + if (disposed) stop(); + else stopWatching = stop; + }).catch(error => console.error('Cannot subscribe to products:', error)); + + return () => { + disposed = true; + if (stopWatching) stopWatching(); + }; + }, []); +} +``` + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Registration throws | Confirm incremental updates are enabled and supply a callback function. | +| No initial rows appear | Load the initial dataset with a read; registration does not return current rows. | +| No callback arrives | Confirm access, active status and filter/cursor scope. Allow for online polling or native incremental delivery. | +| Checks continue after hiding a screen | Another active subscription may share the connection. Dispose each screen's handle and visibility listener. | +| A deletion appears despite `events: ['update']` | Ignore unwanted `deleted` entries in the callback; the subset does not suppress deletion delivery. | + +[Data Sources API](../fliplet-datasources) · [Offline database](offline-database) + +--- + # Testing data source security in preview URL: https://developers.fliplet.com/API/datasources/testing-security.md @@ -13453,7 +14280,7 @@ URL: https://developers.fliplet.com/API/datasources/views.md # Data Source views -Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. +Request named filters that bind to the current login session. Views filter query results; access rules must enforce privacy independently. Example use cases: @@ -13467,7 +14294,7 @@ A view must be defined in the **data source definition JSON**, which can be edit Each view must define a `name` and the `filter`. You can also specify whether the view must be bundled to the app (defaults to `false`). - `name`: *(required)* the name of view. -- `filter`: *(required)* an object which is passed through the **Sift.js** query engine to filter the data source. The value of each attribute can be a literal value or a string to read from a target property in the user's connected session, e.g. `session.foo`, which most likely reads from a data source entry depending on how your app's login is set up. +- `filter`: *(required)* an object which is passed through the **Sift.js** query engine to filter the data source. Primitive filter values are context property paths, such as `session.EmailAddress`, resolved from the connected login session. They are not literal constants: `"Yes"` does not mean a literal Yes comparison. Use an ordinary `where` filter for static conditions. Missing session fields resolve to undefined; require and test the expected login session. - `bundle` *(optional)* a boolean defaulting to `false` defining whether the view should be bundled for offline use in your apps. ```json @@ -13477,7 +14304,6 @@ Each view must define a `name` and the `filter`. You can also specify whether th "name": "userBookmarks", "bundle": true, "filter": { - "IsBookmark": "Yes", "UserEmail": "session.EmailAddress" } } @@ -13487,127 +14313,363 @@ Each view must define a `name` and the `filter`. You can also specify whether th ## Querying data for a specific view -Both DataSources **JS APIs** and **REST APIs** allow you to request data for a specific view by defining its name through the `view` property. You can also provide an **array** of `views` if you prefer to extract more at once. +Request one or more view names with the plural `views` array. Use this form rather than the singular `view` property. Multiple resolved views are combined with OR, so a row matching any requested view can be returned. Unknown names are omitted during lookup; they do not establish a security boundary. ```js Fliplet.DataSources.connect(123).then(function (connection) { // Only extract bookmarks for the current user return connection.find({ - view: 'userBookmarks' + views: ['userBookmarks'], + where: { IsBookmark: 'Yes' } }) }).then(console.log) ``` ---- +## Security and offline use -# fliplet-analytics-spa -URL: https://developers.fliplet.com/API/fliplet-analytics-spa.md +Views are explicitly requested query filters. An app can omit the request or change it; configure [security rules](security-rules) for enforced read restrictions and test both filtered and unfiltered calls. A successful view query is not proof that another user's rows are inaccessible. -# `fliplet-analytics-spa` +`bundle: true` requests bundling for native offline use; it does not authorize server access or revoke already downloaded rows. Verify session bindings and the actual bundled data on the target platform. -`fliplet-analytics-spa` is a self-installing runtime that gives every V3 SPA app automatic page-view tracking. It monkey-patches the browser History API and listens for navigation events so that every client-side route change produces a `Fliplet.App.Analytics.pageView()` call enriched with the matched route pattern, route name, and extracted params. No application code is required. +[Reading records](reading-data) · [Testing security](testing-security) --- -## What you get for free +# Writing Data Source records +URL: https://developers.fliplet.com/API/datasources/writing-data.md -Installing this package on a V3 app monkey-patches `history.pushState` and `history.replaceState`, and attaches listeners for `popstate` and `hashchange`. Every distinct client-side navigation — from any routing framework, a manual `history.pushState` call, or a browser back/forward gesture — fires `Fliplet.App.Analytics.pageView()` with the resolved route context. The initial page view fires when `Fliplet.ready()` resolves, so the core analytics queue is already wired up before the first event is sent. Consecutive navigations to the same URL are de-duplicated, so a router that calls both `pushState` and `replaceState` on the same transition only produces one event. +# Writing Data Source records ---- +Data Source connections write individual records or batches. This reference describes request shapes, results and the difference between a server response, a queued submission and a native local change. -## Boot sequence and install guard +## Before you start -The IIFE runs synchronously at script load time and completes in microseconds. Its first action is to set `window.__FLIPLET_ANALYTICS_SPA = true`. If that flag is already set — because the script loaded twice — it exits immediately, preventing duplicate patching. +Load the [Data Sources library](../fliplet-datasources.md) and configure [access rules](../../Data-source-security.md) for the intended app identity. Use a disposable source for replacement and deletion examples. An administrative request or native cached result does not establish app-user authorization. -After setting the guard, the script checks that `window.Fliplet`, `Fliplet.Env`, `Fliplet.App`, and `Fliplet.App.Analytics` are all present. If any is missing, it exits silently without patching anything. +The examples use a source named **Equipment requests** with `Item`, `Quantity` and `Status` columns. Column names do not impose a typed schema. Read IDs from actual returned records rather than guessing them. -Next it reads the route manifest: +For a write that must receive an online server response, open the connection with `{ offline: false }`. Set `insert`'s separate `{ offline: false }` option to prevent a failed submission from being queued. Leave `update`'s `allowOffline` option false. A rejected request can still follow a server-side effect, so inspect persisted state before retrying a write. ```js -var manifest = (Fliplet.Env.get('appSettings') || {}).v3 || {}; -var routes = Array.isArray(manifest.routes) ? manifest.routes : []; -``` +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); -If `routes` is empty or missing, the script exits without patching — there is nothing to match against, so History patching would produce low-quality analytics. +try { + const created = await requests.insert({ + Item: 'Portable monitor', + Quantity: 1, + Status: 'Requested' + }, { offline: false }); -Once all guards pass, the History API is patched and the initial page view is queued: + if (!created || !created.id || !created.data) { + throw new Error('The submission did not return a server record.'); + } -```js -if (typeof Fliplet.ready === 'function') { - Fliplet.ready().then(firePageView); -} else { - setTimeout(firePageView, 0); + const saved = await requests.findById(created.id); + console.log('Persisted request:', saved.id, saved.data); +} catch (error) { + console.error('Check the response and persisted state before retrying:', error); } ``` -The `setTimeout` fallback ensures the initial `firePageView` is always deferred at least one tick so `window.location` is stable. +The read-back requires read permission. A successful write can still be followed by a denied read; record those outcomes separately. For files, use the [attachment reference](file-attachments.md). ---- +## Individual writes -## How it intercepts route changes +### insert(data, options?) -`history.pushState` and `history.replaceState` are each replaced with a wrapper that calls the original method first — committing the navigation — and then schedules `firePageView` via `setTimeout(firePageView, 0)`. The one-tick deferral ensures `window.location.pathname`, `.search`, and `.hash` already reflect the new URL when the page-view payload is read. +`connection.insert(data, options?)` submits a new record. `data` is a flat object of column values or a `FormData` object for attachments. It is not a `{ data: ... }` record envelope. Missing/falsy `data` throws synchronously; validation and network failures occur asynchronously. -`popstate` (browser back/forward) and `hashchange` (hash-only fragment changes) are covered by direct event listeners on `window`. Both call `firePageView` synchronously because the browser has already updated `window.location` by the time these events fire. +| Option | Type | Default | Behavior | +|---|---|---|---| +| `offline` | Boolean | Unspecified | `false` prevents queueing a failed submission. Other values allow the insertion queue where the input can be stored. This differs from the connection's native database option. | +| `ack` | Boolean | Unspecified | Attempts native local reconciliation after a server result. Native database connections also attempt reconciliation automatically. Single-write reconciliation requires a working native database connection; `ack: true` does not enable it on an `offline: false` connection. It does not establish server persistence. | +| `folderId` | Number | Unspecified | Target media folder for uploaded attachments. | +| `flUserToken` | String | Unspecified | Existing Fliplet user-token context forwarded with the request. It is not an app role or arbitrary identity claim. | +| `public` | Boolean | Unspecified | `true` requests a shared-content slug. Requires an app context and the appropriate shared-content configuration. It does not grant Data Source permissions. | +| `source` | String | Unspecified | Context forwarded to server hooks. It is caller-supplied, not trusted attribution. | ---- +Possible resolved outcomes: -## Route resolution and the app manifest +| Outcome | Shape or observation | Meaning | +|---|---|---| +| Online server record | `{ id, dataSourceId, data, ... }` | The server returned the saved entry. Fields can be transformed, excluded or redacted. | +| Native pending record | `{ id: 'temp_...', data, _pending: true, _tempId: 'temp_...', ... }` | A local optimistic entry awaits a server result and ID reconciliation. | +| Stored queued submission | Result of local queue storage; not guaranteed to contain `id` or `data` | The input was accepted locally after a failed send. It is not a saved server entry. | -Routes are loaded once at boot from `(Fliplet.Env.get('appSettings') || {}).v3.routes`. Each entry is a plain object with at minimum a `path` string, and optionally a `name` string: +Development sample mode returns the submitted flat data rather than a server entry. Do not use that mode to verify persistence or security. -```json -{ "path": "/orders/:id/edit", "name": "Edit order" } -``` +### update(id, data, options?) -Routes are sorted longest-pattern-first before any matching happens, so `/orders/:id/edit` is tested before `/orders/:id`. This ensures more-specific patterns win. +`connection.update(id, data, options?)` updates an existing record. Online single-record updates shallowly merge the submitted column values into stored `data`; omitted fields remain and `null` is a value. Nested objects are replaced as values rather than deeply merged. -`matchPattern(pattern, actual)` splits both strings on `/` and requires equal segment counts. Literal segments must match exactly. Segments whose first character is `:` are named parameters — their corresponding actual-path segment is decoded via `decodeURIComponent` (falling back to the raw value if decoding throws) and stored in a params object keyed by the parameter name without the leading colon. +| Argument or option | Type | Default | Behavior | +|---|---|---|---| +| `id` | Number or `'session'` | Required | Existing entry ID. `'session'` targets the authenticated row for this source when that login exists. | +| `data` | Object or FormData | Required | Flat column values or attachment input. | +| `allowOffline` | Boolean | False when omitted | Requests required-request handling and, on request failure, resolves an `{ id, dataSourceId, data, offline: true }` result instead of rejecting. It does not prove the server accepted the update. | +| `ack` | Boolean | Unspecified | Attempts local reconciliation only when native database support is active on the connection; also automatic for those connections. `ack: true` does not override `offline: false`. | +| `folderId` | Number | Unspecified | Media folder for attachments. | +| `public` | Boolean | Unspecified | `true` requests shared-content slug creation where supported. | +| `source` | String | Unspecified | Caller-supplied server-hook context. | -Before matching, the base path is stripped from `window.location.pathname` using `Fliplet.Env.get('basePath')`. This normalizes slug-hosted URLs (`/my-slug/login` → `/login`) and preview-iframe URLs so analytics always log the logical V3 route rather than the host-specific prefix. +An ordinary online success resolves the server entry. Native reconciliation can return a local update result rather than the same complete envelope. Development sample mode resolves without a record. Read the saved entry separately when the complete persisted state matters. -If no route pattern matches the current path, the matched pattern field falls back to the path-only portion of the raw logical URL (no query string, no hash). +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const pending = await requests.findOne({ where: { Status: 'Requested' } }); ---- +if (pending) { + await requests.update(pending.id, { Status: 'Collected' }); + const saved = await requests.findById(pending.id); + console.log('Stored status:', saved.data.Status); +} +``` -## Event payload +Object update requirements evaluate stored rows. They do not validate every proposed transition. Read [operation-specific security](security-rules.md#requirements-by-operation) before using a status field as an approval workflow. -Each call to `Fliplet.App.Analytics.pageView()` receives: +### removeById(id, options?) -| Field | Type | Value | -| --- | --- | --- | -| `_pageTitle` | string | `routeName` if the matched route has a `name`; otherwise the matched pattern; otherwise the raw path. | -| `_route` | string | The matched route pattern. Identical to `_pageTitle` when the route has no name. Raw path when no route matches. | -| `_routeRaw` | string | The full logical URL: base-path-stripped pathname + `window.location.search` + `window.location.hash`. | -| `_routeParams` | object | Named parameter values extracted from the matched pattern. Empty object when no route matches. | -| `_routeName` | string \| undefined | The `name` field from the matched manifest route entry. `undefined` when the route has no name or no route matches. | +`connection.removeById(id, { ack? })` deletes one entry. A missing/falsy ID throws synchronously. An online missing entry rejects with HTTP 404; authorization and source permission failures can reject with HTTP 400. -The V3 analytics guide at [V3 app analytics and event tracking](v3/analytics) documents how `_pageTitle` and `_routeParams` appear in the analytics dashboards and explains what the core tracker adds automatically (session ID, platform, user email, page ID). +Online success resolves `{}`. With native reconciliation enabled by `ack` or the connection, the result can instead be the removed local entry. Development sample mode resolves without a value. No `connection.remove()` method is provided by the online connection; the [native collection API](offline-database.md) is separate. ---- +## Writes selected by a query -## Deduplication +`connection.query(data, options?)` is an alias for `find`. For writes, `data.type` selects `update` or `delete`. These calls require query admission and permission for the requested write operation; they are distinct from single-record writes. -`firePageView` begins by computing `rawPath`: +| Field | Type | Behavior | +|---|---|---| +| `type` | String | `'update'` or `'delete'`. | +| `where` | Object | Filters data-column values. An omitted or broad filter can affect the whole source. | +| `data` | Object | Required for update; flat patch shallowly merged into each matched row. | +| `options.returnAsObject` | Boolean | Returns `{ entries, deletedEntries }`; `deletedEntries` is not the list of IDs deleted by this request. | +| `options.ack` | Boolean | For query deletion, attempts removal of returned deleted IDs from a supported local database. | + +An online update normally resolves an array of updated entries. Online query deletion normally resolves `[]`, not a count or removed-row list. Do not infer the deleted count from that empty array. `returnAsObject` changes the SDK envelope but does not expose the endpoint's `deleted` ID list. With query deletion and `ack: true`, a local reconciliation failure can resolve `{ entries, deletedEntries }` even when `returnAsObject` is omitted. Successful reconciliation returns the entries array instead of retaining an `includePagination` root response. Handle these branches or omit `ack` when consuming a predictable online response. Native local `find` routing is not an online query-update implementation; use an online connection for query writes. ```js -var rawPath = stripBasePath(window.location.pathname) - + window.location.search - + window.location.hash; +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const updated = await requests.query({ + type: 'update', + where: { Status: 'Collected' }, + data: { Status: 'Archived' } +}); +console.log('Updated entries:', updated.length); ``` -This is compared to a module-level `lastPath` variable initialized to `null`. If `rawPath === lastPath`, the function returns immediately and no event is sent. Otherwise, `lastPath` is updated to the new value before calling `Fliplet.App.Analytics.pageView()`. +Query write authorization, returned fields and hook behavior must be tested independently from `update(id, ...)`. See [testing security](testing-security.md). -This means the first of any pair of identical consecutive navigations passes through, and all subsequent identical ones are suppressed. It is the behavior that prevents double-firing when a router calls both `pushState` and an internal `replaceState` (to normalize the URL) during the same transition. +## Appending and replacing a batch ---- +Both methods accept an array of **flat column objects**, not record envelopes. A top-level input `id` is treated as an entry ID rather than a data-column value. Prefer omitting it for new rows. -## What this package does not expose +| Method | Arguments | Result and side effects | +|---|---|---| +| `append(entries?, options?)` | Array; `{ runHooks?: Boolean }` | Adds rows and preserves existing rows. Omitted entries defaults to `[]`. Online result is an array of created entry envelopes. Server `runHooks` defaults to `true`. | +| `replaceWith(entries?)` | Array | Removes existing rows and imports the supplied dataset. Omitted entries defaults to `[]`, which clears the source when authorized. Online result is an array of created entries. No options argument is implemented. | -`fliplet-analytics-spa` has no public JavaScript surface. It provides: +These operations can expand declared column names. They do not enforce column value types. Replacement requires deletion permission in addition to insertion permission for nonempty rows. Neither method supplies an atomic rollback guarantee. A failure can follow deletion or partial mutation. -- No constructor, class, or namespace on `window` or `Fliplet` -- No configuration object — the only input is the manifest routes array in `appSettings.v3.routes` +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const created = await requests.append([ + { Item: 'USB-C adapter', Quantity: 2, Status: 'Requested' }, + { Item: 'Headset', Quantity: 1, Status: 'Requested' } +]); +console.log('Created records:', created.map(entry => entry.id)); +``` + +For replacement, back up the source and verify the complete desired dataset before calling `replaceWith`. Development sample mode has different results and does not establish the online contract. + +## commit(data, columns?) + +`connection.commit(data, columns?)` sends several changes in **one request**. It is not an atomic transaction: deletion, insertion, updating and hook work do not share a universal rollback boundary. Never retry the entire commit automatically after an ambiguous failure. + +The entry shape differs from append/replace: commit receives `{ data: { ... } }` for new rows and `{ id, data: { ... } }` for updates. An array shorthand becomes `{ entries: data, columns: columns || [] }`. The second `columns` argument applies only to that shorthand. + +**Deletion defaults are consequential.** Without `append: true` or an explicit `delete` array, rows omitted from `entries` can be removed. An empty/default commit can clear the source. Set `append: true` for an insert/update-only commit, or explicitly provide the IDs to delete. + +| Field | Type | Default | Behavior | +|---|---|---|---| +| `entries` | Array | `[]` when omitted | Wrapped new/updated entries. A supplied nonarray rejects HTTP 400. Provide `entries` or `delete`. | +| `entries[].id` | Number | Omitted | Existing entry ID for updates. Without an ID, `data` creates a new entry. | +| `entries[].data` | Object | No default | Column values. Prefer complete values unless using `extend`. | +| `entries[].order` | Number | Unspecified | Entry order for a supplied row. | +| `entries[].clientId` | String | Unspecified | Caller correlation value for a new entry; returned alongside the assigned server ID. | +| `append` | Boolean | False when omitted | `true` skips removal when no explicit `delete` array is present. | +| `delete` | Array of IDs | Unspecified | Deletes the specified IDs instead of treating omitted rows as removed. An empty array explicitly requests no deletions. | +| `extend` | Boolean | False when omitted | Shallowly merges update `data` with stored data. With update hooks enabled the handler also merges values, so use `extend: true` explicitly for patch semantics. | +| `runHooks` | Array of strings | `[]` | Operation names such as `['insert', 'update']`. It is not append's Boolean option. | +| `returnEntries` | Boolean | True unless explicitly false | `false` omits the returned dataset. It does not grant access or enforce privacy. | +| `columns` | Array of strings | Inferred/merged when omitted | Explicit list replaces the declared column list and is augmented from row data. These names are not a typed schema. | +| `source` | String | Unspecified | Caller-supplied context for server hooks. | +| `normalizeOrder` | Object | Unspecified | `{ gap: positiveSafeInteger }` renumbers existing order values. A no-entry normalization requires `append: true` or an explicit `delete` array to prevent unintended removal. Excessive gaps reject. | + +Use a disposable source with read/write permission for this example: + +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const result = await requests.commit({ + entries: [ + { clientId: 'monitor-request', data: { + Item: 'Portable monitor', Quantity: 1, Status: 'Requested' + } } + ], + append: true, // preserve rows not supplied in this request + runHooks: ['insert'], // server hook operation names, not a Boolean + returnEntries: false // request only the correlation response +}); +const mapping = (result.clientIds || []).find(row => row.clientId === 'monitor-request'); +if (mapping) { + const saved = await requests.findById(mapping.id); + console.log('Persisted new row:', saved.id, saved.data); +} +``` + +The online result is `{ entries?, clientIds: [{ id, clientId }, ...] }`. By default, `entries` can contain the source dataset, not only changed rows. Hashed fields are redacted, but query-read filters and projections must not be assumed to protect this write response. Test returned rows and columns as each actor before enabling commits on private data. A cooperative caller's `returnEntries: false` is not authorization enforcement. See [write-response tests](testing-security.md#check-write-responses). + +## Hooks and completion + +Client hooks and [server hooks](../../Data-Source-Hooks.md) have separate ownership: + +| Method | Client hook | Server behavior relevant to completion | +|---|---|---| +| `insert` / `update` | `insert` / `update` | Server entry hooks can run in the background after saving. A record response does not prove downstream actions completed. | +| `append` | `append` | Server operation hooks requested by Boolean `runHooks`, default true. Setting false still runs the client hook. | +| `replaceWith` | No explicit package hook call | No implemented options argument for selecting hooks. | +| `commit` | `commit` | Selected server operations from `runHooks` array. Update-hook work is not a guarantee that every downstream action completed before response. | +| `query` writes / `removeById` | No equivalent insert/update client hook call | Do not assume the single-record hook pipeline is reused. | + +Hooks can transform values; client hooks and caller-supplied `source` cannot establish trusted identity or universal authorization. Verify each write path the app exposes. + +## Failures and recovery + +Failures are not uniformly HTTP 403. Source permissions, malformed batches and query failures can reject HTTP 400; missing IDs can reject 404; file validation can reject 422; throttling can reject 429. `insert` can wrap permanent 401/422 responses as `{ code: 2, message }` and multipart send failures as `{ code: 1, message }`. Preserve the actual error shape rather than assuming a status property always exists. + +Show pending/queued writes as pending. Inspect server records before retrying a write after a timeout, network failure or non-atomic batch error. Prevent duplicate submissions in the UI and use application-level duplicate detection where needed. Do not use an unbounded retry loop. Keep local state, server response and persisted verification as separate observations. + +[Back to Data Sources](../fliplet-datasources.md) + +--- + +# fliplet-analytics-spa +URL: https://developers.fliplet.com/API/fliplet-analytics-spa.md + +# `fliplet-analytics-spa` + +`fliplet-analytics-spa` is a self-installing runtime that gives every V3 SPA app automatic page-view tracking. It monkey-patches the browser History API and listens for navigation events so that every client-side route change produces a `Fliplet.App.Analytics.pageView()` call enriched with the matched route pattern, route name, and extracted params. No application code is required. + +--- + +## What you get for free + +Installing this package on a V3 app monkey-patches `history.pushState` and `history.replaceState`, and attaches listeners for `popstate` and `hashchange`. Every distinct client-side navigation — from any routing framework, a manual `history.pushState` call, or a browser back/forward gesture — fires `Fliplet.App.Analytics.pageView()` with the resolved route context. The initial page view fires when `Fliplet.ready()` resolves, so the core analytics queue is already wired up before the first event is sent. Consecutive navigations to the same URL are de-duplicated, so a router that calls both `pushState` and `replaceState` on the same transition only produces one event. + +--- + +## Boot sequence and install guard + +The IIFE runs synchronously at script load time and completes in microseconds. Its first action is to set `window.__FLIPLET_ANALYTICS_SPA = true`. If that flag is already set — because the script loaded twice — it exits immediately, preventing duplicate patching. + +After setting the guard, the script checks that `window.Fliplet`, `Fliplet.Env`, `Fliplet.App`, and `Fliplet.App.Analytics` are all present. If any is missing, it exits silently without patching anything. + +Next it reads the route manifest: + +```js +var manifest = (Fliplet.Env.get('appSettings') || {}).v3 || {}; +var routes = Array.isArray(manifest.routes) ? manifest.routes : []; +``` + +If `routes` is empty or missing, the script exits without patching — there is nothing to match against, so History patching would produce low-quality analytics. + +Once all guards pass, the History API is patched and the initial page view is queued: + +```js +if (typeof Fliplet.ready === 'function') { + Fliplet.ready().then(firePageView); +} else { + setTimeout(firePageView, 0); +} +``` + +The `setTimeout` fallback ensures the initial `firePageView` is always deferred at least one tick so `window.location` is stable. + +--- + +## How it intercepts route changes + +`history.pushState` and `history.replaceState` are each replaced with a wrapper that calls the original method first — committing the navigation — and then schedules `firePageView` via `setTimeout(firePageView, 0)`. The one-tick deferral ensures `window.location.pathname`, `.search`, and `.hash` already reflect the new URL when the page-view payload is read. + +`popstate` (browser back/forward) and `hashchange` (hash-only fragment changes) are covered by direct event listeners on `window`. Both call `firePageView` synchronously because the browser has already updated `window.location` by the time these events fire. + +--- + +## Route resolution and the app manifest + +Routes are loaded once at boot from `(Fliplet.Env.get('appSettings') || {}).v3.routes`. Each entry is a plain object with at minimum a `path` string, and optionally a `name` string: + +```json +{ "path": "/orders/:id/edit", "name": "Edit order" } +``` + +Routes are sorted longest-pattern-first before any matching happens, so `/orders/:id/edit` is tested before `/orders/:id`. This ensures more-specific patterns win. + +`matchPattern(pattern, actual)` splits both strings on `/` and requires equal segment counts. Literal segments must match exactly. Segments whose first character is `:` are named parameters — their corresponding actual-path segment is decoded via `decodeURIComponent` (falling back to the raw value if decoding throws) and stored in a params object keyed by the parameter name without the leading colon. + +Before matching, the base path is stripped from `window.location.pathname` using `Fliplet.Env.get('basePath')`. This normalizes slug-hosted URLs (`/my-slug/login` → `/login`) and preview-iframe URLs so analytics always log the logical V3 route rather than the host-specific prefix. + +If no route pattern matches the current path, the matched pattern field falls back to the path-only portion of the raw logical URL (no query string, no hash). + +--- + +## Event payload + +Each call to `Fliplet.App.Analytics.pageView()` receives: + +| Field | Type | Value | +| --- | --- | --- | +| `_pageTitle` | string | `routeName` if the matched route has a `name`; otherwise the matched pattern; otherwise the raw path. | +| `_route` | string | The matched route pattern. Identical to `_pageTitle` when the route has no name. Raw path when no route matches. | +| `_routeRaw` | string | The full logical URL: base-path-stripped pathname + `window.location.search` + `window.location.hash`. | +| `_routeParams` | object | Named parameter values extracted from the matched pattern. Empty object when no route matches. | +| `_routeName` | string \| undefined | The `name` field from the matched manifest route entry. `undefined` when the route has no name or no route matches. | + +The V3 analytics guide at [V3 app analytics and event tracking](v3/analytics) documents how `_pageTitle` and `_routeParams` appear in the analytics dashboards and explains what the core tracker adds automatically (session ID, platform, user email, page ID). + +--- + +## Deduplication + +`firePageView` begins by computing `rawPath`: + +```js +var rawPath = stripBasePath(window.location.pathname) + + window.location.search + + window.location.hash; +``` + +This is compared to a module-level `lastPath` variable initialized to `null`. If `rawPath === lastPath`, the function returns immediately and no event is sent. Otherwise, `lastPath` is updated to the new value before calling `Fliplet.App.Analytics.pageView()`. + +This means the first of any pair of identical consecutive navigations passes through, and all subsequent identical ones are suppressed. It is the behavior that prevents double-firing when a router calls both `pushState` and an internal `replaceState` (to normalize the URL) during the same transition. + +--- + +## What this package does not expose + +`fliplet-analytics-spa` has no public JavaScript surface. It provides: + +- No constructor, class, or namespace on `window` or `Fliplet` +- No configuration object — the only input is the manifest routes array in `appSettings.v3.routes` - No custom events for the app to subscribe to - No methods to pause, resume, or flush tracking - No way to override the payload fields per-navigation @@ -15323,1937 +16385,200 @@ URL: https://developers.fliplet.com/API/fliplet-datasources.md # `Fliplet.DataSources` -Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +Fliplet.DataSources connects app code to data sources. The references below describe supported arguments, result shapes, errors and the differences between online operations, native storage and source management. -## Security and verification +## Before you start -Configure [data source access rules](../Data-source-security.md) before exposing private data. Use the [rule reference](datasources/security-rules.md), [complete policy examples](datasources/security-examples.md) and [authenticated preview tests](datasources/testing-security.md) alongside this method reference. +Load the `fliplet-datasources` library in your app and create a data source in Studio. Configure [access rules](../Data-source-security.md) for the intended app users before exposing private data. Calls use the current app/page context and identity; a connection does not supply an independent login. -Query reads (`find`/`findOne`) and record reads (`findById`) evaluate different requirement targets. Object update requirements check stored rows, not proposed values. Single and bulk writes have different column restrictions; a successful administrative request or cached/offline result does not establish app-user authorization. +Connection and network methods return Promises. Subscription registration and cursor navigation return synchronous handles; their references identify which calls need `await`. Use the plural namespace `Fliplet.DataSources`. -## ⚠️ Important: All API Calls Are Asynchronous +## Read records -**All Fliplet Data Sources API methods return Promises and must be chained using `.then()` or used with `async/await`.** +This example needs a source named `Directory` with `Name` and `Department` columns and permission to query it. It uses an online connection and handles failed requests. ```js -// Using async/await (recommended) -async function example() { - const connection = await Fliplet.DataSources.connect(dataSourceId); - const records = await connection.find(); - return records; +async function readDirectory() { + try { + const connection = await Fliplet.DataSources.connectByName('Directory', { + offline: false + }); + const entries = await connection.find({ + where: { Department: 'Operations' }, + attributes: ['Name', 'Department'], + order: [['data.Name', 'ASC']], + limit: 25 + }, { cache: false }); + return entries.map(entry => ({ id: entry.id, name: entry.data.Name })); + } catch (error) { + console.error('Directory query failed', error); + throw error; + } } - -// Using .then() chaining -Fliplet.DataSources.connect(dataSourceId) - .then(connection => connection.find()) - .then(records => { - // Process records - }) - .catch(error => { - // Handle errors - }); -``` - ---- - -## Core Workflow: Connect First - -### Connect to a Data Source by Name - -**Purpose:** Connect using the data source name instead of ID (recommended for portability) -**Syntax:** `Fliplet.DataSources.connectByName(name)` -**Returns:** `Promise` -**When to use:** Preferred method - data source names remain consistent when copying apps between environments - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -``` - -**Why use connectByName:** Data source names typically remain the same when copying apps between environments, while IDs change. This makes your code more portable and maintainable. - -### Connect to a Data Source by ID - -**Purpose:** Establish a connection to work with a specific data source by ID -**Syntax:** `Fliplet.DataSources.connect(dataSourceId, options?)` -**Returns:** `Promise` -**When to use:** Only when you specifically need to target a data source by ID (less portable) - -```js -// Basic connection by ID (not recommended for portability) -const connection = await Fliplet.DataSources.connect(123); - -// Advanced connection with options -const connection = await Fliplet.DataSources.connect(123, { - offline: false // Force online-only queries (mobile apps default to offline) -}); ``` -**Note:** Fliplet apps on mobile devices attempt to connect to the **offline bundled data sources by default**. You can optionally prevent a data source from being bundled by editing its settings in Fliplet Studio, or by using the `offline: false` parameter to connect only to the live online data source. - ---- - -## Essential Connection Methods - -### 1. Find Records (Query Data) - -**Purpose:** Retrieve records from the data source -**Syntax:** `connection.find(options?)` -**Returns:** `Promise` -**When to use:** Reading/querying data (most common operation) - -#### Basic Usage -```js -// Complete example: Get all user records -const connection = await Fliplet.DataSources.connectByName("Users"); -const allUsers = await connection.find(); -console.log(`Found ${allUsers.length} users:`, allUsers); - -// Complete example: Get users from London office -const connection = await Fliplet.DataSources.connectByName("Users"); -const londonUsers = await connection.find({ - where: { Office: 'London' } -}); -console.log('London users:', londonUsers); +The default read result is an array of entries. Each entry has metadata such as `id`, `createdAt` and `updatedAt`, with column values under `data`: -// Complete example: Get specific user data columns only -const connection = await Fliplet.DataSources.connectByName("Users"); -const userNames = await connection.find({ - attributes: ['Name', 'Email', 'Department'] -}); -console.log('User names and emails:', userNames); +```json +{ + "id": 101, + "data": { "Name": "Ada Patel", "Department": "Operations" }, + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-01-10T09:00:00.000Z" +} ``` -#### Advanced Querying -```js -// Complete example: Complex query with multiple conditions -const connection = await Fliplet.DataSources.connectByName("Users"); -const seniorEngineers = await connection.find({ - where: { - Office: 'London', - Age: { $gte: 25 }, - Department: { $in: ['Engineering', 'Design'] }, - Status: 'Active' - }, - order: [['data.Name', 'ASC']], - limit: 50 -}); -console.log('Senior engineers in London:', seniorEngineers); - -// Complete example: Using Fliplet's custom $filters (optimized for performance) -const connection = await Fliplet.DataSources.connectByName("Users"); -const activeUsers = await connection.find({ - where: { - $filters: [ - { - column: 'Email', - condition: 'contains', - value: '@company.com' - }, - { - column: 'Status', - condition: '==', - value: 'Active' - } - ] - } -}); -console.log('Active company users:', activeUsers); -``` +A query's `where` filters column values under `data`; entry metadata filters use top-level query parameters. Pagination, aggregation and request options can change the result shape. See [reading data](datasources/reading-data.md) before combining them. -> **Combining data from another data source?** Don't fetch a second data source and match records in JavaScript. Pass a `join` option to `find()` and the server returns the related records in a single query — see [Joining data from other data sources](#joining-data-from-other-data-sources) below and the full [joins documentation](datasources/joins). +## API navigation -#### Query Operators Reference +| Task | Reference and methods | +|---|---| +| Read, filter and paginate | [Reading data](datasources/reading-data.md): `find`, `query`, `findOne`, `findById`, `findWithCursor`, cursor methods, `getIndex`, `getIndexes` and aggregation | +| Query operators | [Query operators](datasources/query-operators.md): JSON-safe filters and separate server rule lookup semantics | +| Related records | [Joins](datasources/joins.md): named joins, reductions and pagination limits | +| Session-bound filters | [Views](datasources/views.md): definitions, requested views and security boundaries | +| Write records | [Writing data](datasources/writing-data.md): `insert`, `update`, `removeById`, query writes, `append`, `replaceWith` and `commit` | +| Attach files or import | [File attachments](datasources/file-attachments.md): attachment inputs, limits and `import` capability boundaries | +| Listen for changes | [Subscriptions](datasources/subscriptions.md): `subscribe` and subscription handle methods | +| Native local storage | [Offline database](datasources/offline-database.md): `Database`, handles and collections | +| Manage source settings | [Managing data sources](datasources/managing-data-sources.md): `get`, `getById`, `create`, namespace `update`, `delete` and source roles | +| Authenticate app users | [App authentication](v3/auth.md): `sendValidation` and `validate` | +| Secure and verify access | [Security entry](../Data-source-security.md), [rule reference](datasources/security-rules.md), [policy examples](datasources/security-examples.md) and [preview testing](datasources/testing-security.md) | +| Server hooks | [Data source hooks](../Data-Source-Hooks.md) | + +## Connect to a source + +`Fliplet.DataSources.connect(id, connectionOptions?)` and `Fliplet.DataSources.connectByName(name, connectionOptions?)` return `Promise`. The options-object overload `connect({ name, offline })` also selects a named source. + +| Argument | Type and behavior | +|---|---| +| `id` | Required source identifier for the ID form. A missing/falsy identifier rejects. | +| `name` | Required source name for the named form. Name lookup uses the current request context; it does not grant cross-app access. | +| `connectionOptions.offline` | Optional Boolean. `false` forces online operations. When omitted, native apps can use bundled storage if the Database API is available. `true` does not enable native storage in a browser. | -**MongoDB-style Operators (Sift.js)** ```js -// Comparison operators -{ field: { $gt: 10 } } // Greater than -{ field: { $gte: 10 } } // Greater than or equal -{ field: { $lt: 10 } } // Less than -{ field: { $lte: 10 } } // Less than or equal -{ field: { $eq: 'value' } } // Equal to -{ field: { $ne: 'value' } } // Not equal to - -// Array operators -{ field: { $in: ['a', 'b'] } } // Value in array -{ field: { $nin: ['a', 'b'] } } // Value not in array - -// Text operators -{ field: { $iLike: 'john' } } // Case-insensitive partial match -{ field: { $regex: /pattern/i } } // Regular expression - -// Logical operators -{ $and: [{ field1: 'a' }, { field2: 'b' }] } -{ $or: [{ field1: 'a' }, { field2: 'b' }] } -{ $nor: [{ field1: 'a' }, { field2: 'b' }] } // None of the conditions -{ field: { $not: { $lt: 18 } } } // Logical NOT - -// Other operators -{ field: { $exists: true } } // Field exists -{ field: { $mod: [4, 0] } } // Modulo operation -{ field: { $all: ['a', 'b'] } } // Array contains all values -{ field: { $size: 3 } } // Array size -{ field: { $type: 'string' } } // Type check -{ field: { $elemMatch: { $gte: 80 } } } // Array element matching +const online = await Fliplet.DataSources.connectByName('Directory', { offline: false }); +const byId = await Fliplet.DataSources.connect(123, { offline: false }); ``` -**Fliplet Custom $filters Operator** +The SDK may reuse a connection for the same identifier and offline setting. Connecting establishes a handle; it does not prove a successful server query or authorized access. Native bundle loading is lazy and failed local loading can cause later reads to use the server. There is no public connection `close`, `disconnect` or `destroy` method. Unsubscribe each listener when its owner is disposed. -The `$filters` operator provides optimized performance and additional conditions: - -```js -{ - where: { - $filters: [ - // Exact match (case-insensitive) - { - column: 'Email', - condition: '==', - value: 'user@email.com' - }, - // Not equal - { - column: 'Status', - condition: '!=', - value: 'Inactive' - }, - // Numeric comparisons - { - column: 'Age', - condition: '>', // '>', '>=', '<', '<=' - value: 25 - }, - // Contains (case-insensitive partial match) - { - column: 'Name', - condition: 'contains', - value: 'John' - }, - // Empty/not empty checks - { - column: 'Notes', - condition: 'empty' // or 'notempty' - }, - // Range check - { - column: 'Score', - condition: 'between', - value: { from: 80, to: 100 } - }, - // One of multiple values - { - column: 'Department', - condition: 'oneof', - value: ['Engineering', 'Design', 'Marketing'] - }, - // Date comparisons - { - column: 'Birthday', - condition: 'dateis', // 'datebefore', 'dateafter', 'datebetween' - value: '1990-01-01' - } - ] - } -} -``` +## Security and verification -**📖 Complete Operators Reference:** [View detailed query operators documentation](datasources/query-operators) +Query reads and record reads evaluate different requirement targets. Object update requirements inspect stored rows, not proposed values. Field restrictions vary across single and bulk writes. Views, projections and client hooks do not replace server access rules. -### 2. Insert Records (Add Data) +For authorization tests, use an online connection, the intended app identity and confirmed preview enforcement. Check denied operations, returned data and persisted effects. Administrative calls, local results and preview with enforcement disabled cannot establish app-user authorization. A resolved write may be queued or pending; use the write reference's persistence checks. -**Purpose:** Add new records to the data source -**Syntax:** `connection.insert(data, options?)` -**Returns:** `Promise` -**When to use:** Creating new entries +## SDK helper boundaries -```js -// Complete example: Insert a single user record -const connection = await Fliplet.DataSources.connectByName("Users"); -const newUser = await connection.insert({ - Name: 'John Doe', - Email: 'john.doe@company.com', - Department: 'Engineering', - Office: 'London', - Age: 28, - Status: 'Active' -}); -console.log('Created new user:', newUser); +These exposed helpers have narrower purposes than record CRUD. They are not additional authorization or synchronization APIs. -// Complete example: Insert with acknowledgment for immediate local update -const connection = await Fliplet.DataSources.connectByName("Users"); -const newUser = await connection.insert({ - Name: 'Jane Smith', - Email: 'jane.smith@company.com', - Department: 'Design', - Office: 'New York', - Age: 26, - Status: 'Active' -}, { - ack: true // Ensure local database updates immediately -}); -console.log('Created user with immediate sync:', newUser); +| Helper | Signature and outcome | +|---|---| +| Temporary IDs | `isTempId(id)` returns a Boolean synchronously; true for strings starting with `temp_`. A false result alone does not prove server persistence. | +| Connection lookup | `clearActiveConnections()` returns `undefined` and resets the reuse lookup. It does not unsubscribe existing listeners. | +| Client hook registration | `bindHooks(id, hooks)` registers an object synchronously and rejects duplicate bindings or nonobject inputs by throwing. These run in the client, separately from server hooks. | +| Client hook lookup | `hasHook(id)` returns a Boolean synchronously. | +| Client hook application | `applyHooks(id, method, entries)` invokes the bound callback. Array input returns `Promise.all` of callback results; a single item returns the callback's value/Promise. Without a callback it resolves `undefined`. | +| Mapped-row convenience | `fetchWithOptions(options)` returns a Promise. See [mapped rows](datasources/managing-data-sources.md#mapped-row-helper) for its different result shape and options. | -// Complete example: Insert with file upload (FormData) -const connection = await Fliplet.DataSources.connectByName("Users"); -const formData = new FormData(); -formData.append('Name', 'Bob Johnson'); -formData.append('Email', 'bob.johnson@company.com'); -formData.append('Department', 'Marketing'); -formData.append('Avatar', fileInput.files[0]); // File from input element +Underscore-prefixed tracking/reconciliation helpers and core queue-storage helpers are internal implementation surfaces. Use the documented insert/update outcomes rather than manipulating the queue. Native `applyDiff` belongs to the update pipeline, not a custom app synchronization recipe. -const newUserWithFile = await connection.insert(formData, { - folderId: 123 // Specify media folder for file uploads -}); -console.log('Created user with avatar:', newUserWithFile); -``` +## Existing reference links -**Options:** -- `folderId` (Number): MediaFolder ID where uploaded files should be stored -- `ack` (Boolean): If true, ensures the local offline database gets updated immediately +Existing method-family fragments route to the references below. These references own the current signatures and outcomes. -### 3. Update Records (Modify Data) + + + + + + + + + + + + + + + + + -**Purpose:** Modify existing records -**Syntax:** `connection.update(recordId, data, options?)` -**Returns:** `Promise` -**When to use:** Changing existing entries +[Read methods, sorting, cursors and aggregation](datasources/reading-data.md). -```js -// Complete example: Update a user record by ID -const connection = await Fliplet.DataSources.connectByName("Users"); + + + + + + -// First find the user to get their ID -const users = await connection.find({ - where: { Email: 'john.doe@company.com' } -}); +[Connection setup and asynchronous behavior](#connect-to-a-source). -if (users.length > 0) { - const updatedUser = await connection.update(users[0].id, { - Office: 'Berlin', - Department: 'Product Engineering' - }); - console.log('Updated user:', updatedUser); -} + -// Complete example: Update user with file upload -const connection = await Fliplet.DataSources.connectByName("Users"); -const formData = new FormData(); -formData.append('Office', 'San Francisco'); -formData.append('Avatar', newAvatarFile); +[Query operators](datasources/query-operators.md). -const updatedUser = await connection.update(456, formData, { - folderId: 789 -}); -console.log('Updated user with new avatar:', updatedUser); -``` + + + + + + + + -### 4. Find Single Record +[Individual and batch writes](datasources/writing-data.md). -**Purpose:** Get one specific record -**Syntax:** `connection.findOne(options)` or `connection.findById(id)` -**Returns:** `Promise` -**When to use:** Looking for a specific entry + + + + + + -```js -// Complete example: Find one user by email -const connection = await Fliplet.DataSources.connectByName("Users"); -const user = await connection.findOne({ - where: { Email: 'john.doe@company.com' } -}); +[Subscriptions and lifecycle cleanup](datasources/subscriptions.md). -if (user) { - console.log('Found user:', user); -} else { - console.log('User not found'); -} + -// Complete example: Find user by ID (if you know the ID) -const connection = await Fliplet.DataSources.connectByName("Users"); -const user = await connection.findById(123); +[File attachments and import boundaries](datasources/file-attachments.md). -if (user) { - console.log('Found user by ID:', user); -} else { - console.log('User with ID 123 not found'); -} -``` + + + + + + -### 5. Remove Records (Delete Data) +[Source management and setup](datasources/managing-data-sources.md). -**Purpose:** Delete records from the data source -**Syntax:** `connection.removeById(id)` or `connection.query({ type: 'delete', where: {...} })` -**Returns:** `Promise` -**When to use:** Removing unwanted entries + -```js -// Complete example: Remove user by ID -const connection = await Fliplet.DataSources.connectByName("Users"); +[Related records and joins](datasources/joins.md). -// First find the user to get their ID -const users = await connection.find({ - where: { Email: 'obsolete.user@company.com' } -}); + -if (users.length > 0) { - await connection.removeById(users[0].id); - console.log('User removed successfully'); -} +[Requested views](datasources/views.md). -// Complete example: Remove multiple users matching criteria -const connection = await Fliplet.DataSources.connectByName("Users"); -const deletedEntries = await connection.query({ - type: 'delete', - where: { Status: 'Inactive' } -}); -console.log('Removed inactive users:', deletedEntries); -``` + + + + + + + + + + + + + + ---- - -## Bulk Operations - -### Insert Multiple Records - -**Purpose:** Add many records at once (more efficient than individual inserts) -**Syntax:** `connection.append(recordsArray, options?)` -**Returns:** `Promise` -**When to use:** Bulk data import - -```js -// Complete example: Add multiple users at once -const connection = await Fliplet.DataSources.connectByName("Users"); - -const newUsers = [ - { - Name: 'Alice Cooper', - Email: 'alice.cooper@company.com', - Department: 'Engineering', - Office: 'London', - Age: 29, - Status: 'Active' - }, - { - Name: 'Bob Wilson', - Email: 'bob.wilson@company.com', - Department: 'Design', - Office: 'New York', - Age: 31, - Status: 'Active' - }, - { - Name: 'Charlie Brown', - Email: 'charlie.brown@company.com', - Department: 'Marketing', - Office: 'Berlin', - Age: 27, - Status: 'Active' - } -]; - -await connection.append(newUsers); -console.log(`Added ${newUsers.length} new users`); - -// Complete example: Bulk insert with disabled hooks for better performance -const connection = await Fliplet.DataSources.connectByName("Users"); -await connection.append(newUsers, { runHooks: false }); -console.log('Bulk insert completed (hooks disabled for performance)'); -``` - -### Replace All Data - -**Purpose:** Replace entire data source contents with new records -**Syntax:** `connection.replaceWith(recordsArray)` -**Returns:** `Promise` -**When to use:** Complete data refresh - -```js -// Complete example: Replace all users with new dataset -const connection = await Fliplet.DataSources.connectByName("Users"); - -const newUserDataset = [ - { - Name: 'Admin User', - Email: 'admin@company.com', - Department: 'IT', - Office: 'London', - Age: 35, - Status: 'Active' - }, - { - Name: 'Test User', - Email: 'test@company.com', - Department: 'QA', - Office: 'London', - Age: 28, - Status: 'Active' - } -]; - -await connection.replaceWith(newUserDataset); -console.log('Replaced all user data with new dataset'); -``` - -### Commit Multiple Changes - -**Purpose:** Insert, update, and delete in a single operation -**Syntax:** `connection.commit(options)` -**Returns:** `Promise` -**When to use:** Complex bulk operations - -```js -// Complete example: Multiple operations in one transaction -const connection = await Fliplet.DataSources.connectByName("Users"); - -await connection.commit({ - entries: [ - // Insert new user - { - data: { - Name: 'New Employee', - Email: 'new.employee@company.com', - Department: 'Sales', - Office: 'Paris', - Age: 24, - Status: 'Active' - } - }, - - // Update existing user (assuming ID 123 exists) - { - id: 123, - data: { - Office: 'Remote', - Department: 'Engineering - Remote' - } - } - ], - - // Delete users by ID (assuming these IDs exist) - delete: [456, 789], - - // Keep existing entries not mentioned - append: true, - - // Merge with existing data instead of replacing - extend: true, - - // Don't return all entries (faster) - returnEntries: false -}); - -console.log('Bulk operations completed successfully'); -``` - ---- - -## Pagination and Performance - -### Basic Pagination - -```js -// Complete example: Paginated user retrieval -const connection = await Fliplet.DataSources.connectByName("Users"); - -// Get first 10 users -const page1 = await connection.find({ - limit: 10, - offset: 0, - order: [['data.Name', 'ASC']] -}); -console.log('Page 1 users:', page1); - -// Get next 10 users -const page2 = await connection.find({ - limit: 10, - offset: 10, - order: [['data.Name', 'ASC']] -}); -console.log('Page 2 users:', page2); - -// Complete example: Get pagination info -const connection = await Fliplet.DataSources.connectByName("Users"); -const result = await connection.find({ - limit: 10, - offset: 0, - includePagination: true, - order: [['data.Name', 'ASC']] -}); - -console.log(`Users: ${result.entries.length}`); -console.log(`Total users: ${result.pagination.total}`); -console.log(`Page: ${(result.pagination.offset / result.pagination.limit) + 1}`); -``` - -### Advanced Pagination with Cursor - -**Purpose:** Navigate through large datasets efficiently -**Syntax:** `connection.findWithCursor(options)` -**Returns:** `Promise` -**When to use:** Building paginated interfaces - -```js -// Complete example: Using cursor for pagination -const connection = await Fliplet.DataSources.connectByName("Users"); - -const cursor = await connection.findWithCursor({ - where: { Status: 'Active' }, - limit: 5, - order: [['data.Name', 'ASC']] -}); - -console.log(`Page ${cursor.currentPage + 1} users:`, cursor); -console.log(`Total users on page: ${cursor.length}`); -console.log(`Is first page: ${cursor.isFirstPage}`); -console.log(`Is last page: ${cursor.isLastPage}`); - -// Navigate to next page -if (!cursor.isLastPage) { - const nextPage = await cursor.next().update(); - console.log('Next page users:', nextPage); -} - -// Navigate to previous page -if (!cursor.isFirstPage) { - const prevPage = await cursor.prev().update(); - console.log('Previous page users:', prevPage); -} - -// Jump to specific page -const page3 = await cursor.setPage(2).update(); // 0-indexed, so 2 = page 3 -console.log('Page 3 users:', page3); -``` - -**Cursor Properties:** -- `limit`: The page size (number of records per page) -- `offset`: Current offset from first page -- `currentPage`: Current page number (0-based) -- `isFirstPage`: Whether current page is first -- `isLastPage`: Whether current page is last -- `query`: Options used for the cursor - -**Cursor Methods:** -- `next()`: Move to next page -- `prev()`: Move to previous page -- `setPage(n)`: Go to specific page -- `update()`: Refresh current page data - ---- - -## Sorting and Ordering - -**Purpose:** Control the order of returned records -**Syntax:** Use `order` array in find options -**When to use:** When you need specific sorting - -### The `order` contract - -`order` is validated by the API before the query runs. A value that breaks the -shape or column rules below is rejected with `400 Bad Request` — the query does -not fall back to the default sorting. - -| Rule | What it means | -|---|---| -| **Shape** — `order` is an array of arrays | Each sort is its own `[column, direction]` pair, so the value is `order: [['data.Name', 'ASC']]`. A flat `order: ['data.Name', 'ASC']` is rejected. | -| **Column** — five columns may appear unprefixed, and must be | Only `id`, `order`, `createdAt`, `deletedAt`, `updatedAt` are accepted without a prefix, and prefixing one names a column the data source does not declare — write `createdAt`, never `data.createdAt`. | -| **Column** — every other column needs the `data.` prefix | Anything you added to the data source is an entry column, so it is written `data.` — `data.Name`, not `Name`. | -| **Column** — the name after `data.` is verbatim | It must reproduce the declared column exactly, **spaces included** — `data.Start Time UTC`. Never replace a space with a dot, camel-case it, or truncate at it. | -| **Direction** — write `'ASC'` or `'DESC'` | A convention, not a validated rule: any other value fails as a database error — still returned as a `400`, but carrying the database's message instead of the `Invalid order column name` validation error. Always write the direction as a literal in your code; never build it from user input. Omitting the direction defaults to `'ASC'`. | - -

    Flat arrays fail with a confusing error. A flat order: ['createdAt', 'DESC'] makes the API read the string one character at a time, so the response is Invalid order column name: c — a single letter that appears nowhere in your request. A one-character column name in the error almost always means the shape is flat and needs an extra pair of brackets. The remaining ways to produce one are rare: an unprefixed one-character column name such as order: [['N', 'ASC']], which is rejected for the missing data. prefix rather than for its shape, or a prefixed one-character name that is not a letter, digit, space, underscore or hyphen, such as order: [['data.@', 'ASC']].

    - -

    Never add the data. prefix to where keys or attributes entries. Both are applied inside the entry's data object, so they take entry column names unprefixed — where: { Name: 'John' } and attributes: ['Name', 'Email'] are correct, and adding data. to them breaks the query. Options that address the raw entry row instead do use the prefix, just like order: the keys and values of a join's on mapping (on: { 'data.CustomerEmail': 'data.Email' }) and the field paths in an aggregate pipeline ('$data.Department'). Omitting the prefix there raises no error — the join silently matches nothing (every record comes back with an empty joins entry, or drops out of the results entirely on a required join) and the aggregation reads undefined values.

    - -

    Confirm the column names against the data source's declared columns before sorting. On a data source that declares its columns, an entry column it does not declare fails with Column '<name>' does not exists, and a top-level column that is not declared (such as data.createdAt) fails the same way — createdAt belongs in the unprefixed list, not behind the prefix. On a data source with no declared columns the check is skipped, so an unknown column sorts silently on NULLs instead of erroring.

    - -Sort by creation date, newest first: - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -const recentUsers = await connection.find({ - order: [['createdAt', 'DESC']], // 'createdAt' is one of the five unprefixed columns - limit: 10 -}); -console.log('Most recent users:', recentUsers); -``` - -Sort by an entry column: - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -const usersByName = await connection.find({ - order: [['data.Name', 'ASC']] // 'Name' is an entry column, so it needs the 'data.' prefix -}); -console.log('Users sorted by name:', usersByName); -``` - -Sort by a column whose name contains spaces: - -```js -const connection = await Fliplet.DataSources.connectByName("Sessions"); -const sessions = await connection.find({ - // The data source declares a column called: Start Time UTC - order: [['data.Start Time UTC', 'DESC']] // spaces are kept exactly as declared -}); -console.log('Most recent sessions:', sessions); -``` - -Sort by multiple columns: - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -const sortedUsers = await connection.find({ - order: [ - ['data.Department', 'ASC'], // Sort by department first - ['data.Name', 'ASC'] // Then by name - ] -}); -console.log('Users sorted by department then name:', sortedUsers); - -// Available sort columns: -// - Fliplet columns: 'id', 'order', 'createdAt', 'deletedAt', 'updatedAt' -// - Entry columns: 'data.ColumnName' -// - Sort directions: 'ASC' (ascending), 'DESC' (descending) -``` - -### Common mistakes - -```js -// WRONG: flat array — rejected with "Invalid order column name: c" -order: ['createdAt', 'DESC'] -// RIGHT -order: [['createdAt', 'DESC']] - -// WRONG: entry column without the prefix — rejected with "Invalid order column name: Name" -order: [['Name', 'ASC']] -// RIGHT -order: [['data.Name', 'ASC']] - -// WRONG: space rewritten as a dot — the API reads the column as "Start" -order: [['data.Start.Time.UTC', 'DESC']] -// WRONG: space truncated — the API reads the column as "Start" -order: [['data.Start', 'DESC']] -// RIGHT: the declared column name, verbatim -order: [['data.Start Time UTC', 'DESC']] -``` - ---- - -## Subscribing to Data Source Changes - -**Purpose:** Keep a screen up to date when other people change the data -**Syntax:** `connection.subscribe(options, callback)` -**Returns:** `Subscription` -**When to use:** Screens that stay open while the data changes underneath them - -### How updates arrive - -Changes reach the screen within about 30 seconds rather than the instant they -happen. Fliplet checks the data source for you on a repeating timer and calls -your callback with whatever changed since the last check. - -Design around that delay. If something has to appear the moment it is created, -a subscription is not the right tool. - -### What a subscription costs your app - -Every screen that subscribes does its own checking, and keeps checking for as -long as it stays open: - -- **It adds up with people, not with data.** 200 people with the screen open - means the data source is read around 400 times a minute, whether or not - anything has changed. -- **An empty data source costs the same as a busy one.** The check still runs; - there is simply nothing to report. -- **It carries on in the background.** A screen left open in a background tab - keeps checking overnight, on every device that left it open. - -Those reads count toward your app's data source usage, so a subscription left -running unnecessarily is one of the easier ways to build up usage without -noticing it. - -The two rules below keep that under control. Follow both on any screen that -stays open. - -### 1. Pause while the screen is hidden - -A subscription keeps running when someone switches to another app or tab. -Cleaning up when the screen is destroyed is not enough on its own — people -move away from a screen far more often than they close it, so without this the -checks continue all night for everyone who left it open. - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); - -const subscription = connection.subscribe({ - events: ['insert', 'update', 'delete'] -}, (changes) => { - if (changes.inserted && changes.inserted.length) { - // Add the new rows to your UI - } - - if (changes.updated && changes.updated.length) { - // Refresh the rows that changed - } - - if (changes.deleted && changes.deleted.length) { - // Remove the rows that went away - } -}); - -function onVisibilityChange() { - if (document.hidden) { - if (subscription.status() === 'active') { - subscription.pause(); - } - } else if (subscription.status() === 'paused') { - subscription.resume(); - } -} - -document.addEventListener('visibilitychange', onVisibilityChange); - -// On teardown, remove the listener AND end the subscription. -document.removeEventListener('visibilitychange', onVisibilityChange); -subscription.unsubscribe(); -``` - -`pause()` stops the checking, so nothing is read while the screen is hidden. -`resume()` catches up straight away, so anything that changed in the meantime -still reaches the screen. Check `status()` before switching, as shown above: -calling `resume()` on a subscription that is already running has no effect. - -### 2. Never add a timer of your own on top - -Adding a `setInterval` that calls `find()` as a safety net, in case the -subscription misses something, is the most common mistake here. The -subscription already re-reads the data source on every check, so a second -timer repeats work that has just been done and doubles what the screen costs -without covering anything extra. - -If updates are not coming through, the cause is in your callback or in the -`events` you asked for. A second timer hides that rather than fixing it. - ---- - -## Utility Methods - -### Get Unique Values - -**Purpose:** Get distinct values from columns -**When to use:** Building filters, dropdowns, or analytics - -```js -// Complete example: Get unique office locations -const connection = await Fliplet.DataSources.connectByName("Users"); -const offices = await connection.getIndex('Office'); -console.log('Available offices:', offices); -// Returns: ['London', 'New York', 'Berlin', 'Paris'] - -// Complete example: Get unique values for multiple columns -const connection = await Fliplet.DataSources.connectByName("Users"); -const indexes = await connection.getIndexes(['Office', 'Department']); -console.log('Unique values:', indexes); -// Returns: { -// Office: ['London', 'New York', 'Berlin'], -// Department: ['Engineering', 'Design', 'Marketing'] -// } - -// Use for building dynamic filters -const { Office: availableOffices, Department: availableDepartments } = indexes; -console.log('Build office filter with:', availableOffices); -console.log('Build department filter with:', availableDepartments); -``` - -### Import Data from File - -**Purpose:** Import data from CSV or other file formats -**Syntax:** `connection.import(FormData)` -**Returns:** `Promise` -**When to use:** Bulk data import from files - -```js -// Complete example: Import users from CSV file -const connection = await Fliplet.DataSources.connectByName("Users"); - -// Assuming you have a file input element -const fileInput = document.getElementById('csvFileInput'); -const csvFile = fileInput.files[0]; - -if (csvFile) { - const formData = new FormData(); - formData.append('file', csvFile); - - try { - await connection.import(formData); - console.log('User data imported successfully from CSV'); - } catch (error) { - console.error('Import failed:', error); - } -} else { - console.log('No file selected'); -} -``` - ---- - -## Data Source Management - -### Get Available Data Sources - -**Purpose:** List data sources you can work with -**Syntax:** `Fliplet.DataSources.get(options?)` -**Returns:** `Promise` -**When to use:** Discovery, building dynamic interfaces - -```js -// Complete example: Get all data sources for organization -const dataSources = await Fliplet.DataSources.get({ - attributes: ['id', 'name', 'columns'] -}); -console.log('Available data sources:', dataSources); - -// Find the Users data source -const usersDataSource = dataSources.find(ds => ds.name === 'Users'); -if (usersDataSource) { - console.log('Users data source found:', usersDataSource); - console.log('Available columns:', usersDataSource.columns); -} - -// Complete example: Get data sources used by current app -const appDataSources = await Fliplet.DataSources.get({ - appId: Fliplet.Env.get('masterAppId'), - includeInUse: true -}); -console.log('App data sources:', appDataSources); - -// Complete example: Get specific data source details by ID -const dataSource = await Fliplet.DataSources.getById(123, { - attributes: ['name', 'hooks', 'columns'] -}); -console.log('Data source details:', dataSource); -``` - -### Create New Data Source - -**Purpose:** Programmatically create data sources -**Syntax:** `Fliplet.DataSources.create(options)` -**Returns:** `Promise` -**When to use:** Dynamic app setup, automated workflows - -```js -// Complete example: Create a new Users data source -const newDataSource = await Fliplet.DataSources.create({ - name: 'Users', - - // Attach to current app - appId: Fliplet.Env.get('appId'), - organizationId: Fliplet.Env.get('organizationId'), - - // Define structure - columns: ['Name', 'Email', 'Department', 'Office', 'Age', 'Status'], - - // Add initial test data - entries: [ - { - Name: 'John Smith', - Email: 'john.smith@company.com', - Department: 'Engineering', - Office: 'London', - Age: 30, - Status: 'Active' - }, - { - Name: 'Sarah Jones', - Email: 'sarah.jones@company.com', - Department: 'Design', - Office: 'New York', - Age: 28, - Status: 'Active' - } - ], - - // Public sample data only: grants every app user all four operations. - // Use a scoped policy from the security guide for private data. - accessRules: [ - { type: ['select', 'insert', 'update', 'delete'], allow: 'all' } - ] -}); - -console.log('Created Users data source:', newDataSource); - -// Now you can connect to it by name -const connection = await Fliplet.DataSources.connectByName("Users"); -console.log('Connected to new Users data source'); -``` - ---- - -## Advanced Features - -### Aggregation Queries - -**Purpose:** Run complex data analysis using MongoDB-style aggregation -**Syntax:** Use `aggregate` option in find method -**When to use:** Analytics, reporting, data transformation - -```js -// Complete example: Group users by department and calculate average age -const connection = await Fliplet.DataSources.connectByName("Users"); - -const departmentStats = await connection.find({ - aggregate: [ - { - $project: { - department: '$data.Department', - numericAge: { $convertToNumber: '$data.Age' } - } - }, - { - $group: { - _id: '$department', - avgAge: { $avg: '$numericAge' }, - userCount: { $sum: 1 } - } - } - ] -}); - -console.log('Department statistics:', departmentStats); -// Example output: [ -// { _id: 'Engineering', avgAge: 29.5, userCount: 4 }, -// { _id: 'Design', avgAge: 27.2, userCount: 3 } -// ] -``` - -**Note:** Use the custom `$convertToNumber` operator to convert strings to numbers before aggregation. - -### Joining Data from Other Data Sources - -When a screen shows records from one data source enriched with fields from another (orders with their customer's name/email, articles with their comments), use the **native server-side join** — pass a `join` option to `find()`. Keep the schema normalized: store a reference key on the child record (e.g. `CustomerEmail` on an order), not a duplicated copy of the other record's fields. - -```js -// Orders, each carrying its customer's record from a separate Customers data source -const orders = await Fliplet.DataSources.connect(611); -const result = await orders.find({ - join: { - Customer: { - dataSourceId: 610, // the related (Customers) data source - on: { 'data.CustomerEmail': 'data.Email' } // childKey: parentKey - } - } -}); -// each row now carries result[i].joins.Customer with the joined customer record -// (the joined data sits alongside result[i].data, not inside it) -``` - -This is one query the server resolves — **don't** fetch every record from the second data source and match them in JavaScript; that doesn't paginate or scale. For join types (left/inner), the `on` mapping, and filtering joined data, see the full [joins documentation](datasources/joins). - -### Data Source Views - -For filtering data sources with predefined views, see the [views documentation](datasources/views). - ---- - -## Common Usage Patterns - -### Pattern 1: Complete User Management System -```js -// Initialize user management with connection -const initUserManager = async () => { - const connection = await Fliplet.DataSources.connectByName("Users"); - console.log('UserManager initialized'); - return connection; -}; - -// Create new user -const createUser = async (connection, { name, email, department, office, age }) => { - const newUser = await connection.insert({ - Name: name, - Email: email, - Department: department, - Office: office, - Age: age, - Status: 'Active', - CreatedDate: new Date().toISOString().split('T')[0] - }); - - console.log(`Created user: ${newUser.data.Name}`); - return newUser; -}; - -// Get all active users -const getActiveUsers = async (connection) => { - const users = await connection.find({ - where: { Status: 'Active' }, - order: [['data.Name', 'ASC']] - }); - - console.log(`Found ${users.length} active users`); - return users; -}; - -// Update user information -const updateUser = async (connection, email, updates) => { - const users = await connection.find({ - where: { Email: email } - }); - - if (users.length > 0) { - const updatedUser = await connection.update(users[0].id, updates); - console.log(`Updated user: ${email}`); - return updatedUser; - } else { - throw new Error(`User with email ${email} not found`); - } -}; - -// Deactivate user (soft delete) -const deactivateUser = async (connection, email) => { - return updateUser(connection, email, { Status: 'Inactive' }); -}; - -// Usage example -const manageUsers = async () => { - const connection = await initUserManager(); - - // Create a new user - const newUser = await createUser(connection, { - name: 'Alice Johnson', - email: 'alice.johnson@company.com', - department: 'Marketing', - office: 'Berlin', - age: 26 - }); - - // Get all active users - const activeUsers = await getActiveUsers(connection); - - // Update user - await updateUser(connection, 'alice.johnson@company.com', { - Office: 'Remote', - Department: 'Digital Marketing' - }); - - // Deactivate user - await deactivateUser(connection, 'alice.johnson@company.com'); -}; - -// Run the user management example -manageUsers().catch(console.error); -``` - -### Pattern 2: Advanced Search and Filtering -```js -// Initialize search functionality -const initUserSearch = async () => { - const connection = await Fliplet.DataSources.connectByName("Users"); - console.log('User search initialized'); - return connection; -}; - -// Search users with multiple criteria -const searchUsers = async (connection, { - searchTerm = '', - departments = [], - offices = [], - minAge = 0, - maxAge = 100, - status = 'Active' -} = {}) => { - - const whereConditions = { - $and: [ - { Status: status }, - { Age: { $gte: minAge, $lte: maxAge } } - ] - }; - - // Add text search if provided - if (searchTerm) { - whereConditions.$and.push({ - $or: [ - { Name: { $iLike: searchTerm } }, - { Email: { $iLike: searchTerm } } - ] - }); - } - - // Add department filter if provided - if (departments.length > 0) { - whereConditions.$and.push({ - Department: { $in: departments } - }); - } - - // Add office filter if provided - if (offices.length > 0) { - whereConditions.$and.push({ - Office: { $in: offices } - }); - } - - const results = await connection.find({ - where: whereConditions, - order: [['data.Name', 'ASC']], - limit: 100 - }); - - console.log(`Search found ${results.length} users`); - return results; -}; - -// Get filter options for building UI -const getFilterOptions = async (connection) => { - const [departments, offices] = await Promise.all([ - connection.getIndex('Department'), - connection.getIndex('Office') - ]); - - return { departments, offices }; -}; - -// Advanced search using $filters for better performance -const searchUsersOptimized = async (connection, filters = {}) => { - const { - emailDomain = '@company.com', - department, - office, - ageRange = { min: 18, max: 65 } - } = filters; - - const results = await connection.find({ - where: { - $filters: [ - { - column: 'Status', - condition: '==', - value: 'Active' - }, - { - column: 'Email', - condition: 'contains', - value: emailDomain - }, - { - column: 'Age', - condition: 'between', - value: { from: ageRange.min, to: ageRange.max } - }, - ...(department ? [{ - column: 'Department', - condition: '==', - value: department - }] : []), - ...(office ? [{ - column: 'Office', - condition: '==', - value: office - }] : []) - ] - } - }); - - return results; -}; - -// Usage example -const demonstrateSearch = async () => { - const connection = await initUserSearch(); - - // Get filter options for UI - const { departments, offices } = await getFilterOptions(connection); - console.log('Available departments:', departments); - console.log('Available offices:', offices); - - // Search with multiple criteria - const searchResults = await searchUsers(connection, { - searchTerm: 'john', - departments: ['Engineering', 'Design'], - offices: ['London', 'Berlin'], - minAge: 25, - maxAge: 40 - }); - console.log('Search results:', searchResults); - - // Optimized search - const optimizedResults = await searchUsersOptimized(connection, { - department: 'Engineering', - office: 'London', - ageRange: { min: 25, max: 35 } - }); - console.log('Optimized search results:', optimizedResults); -}; - -// Run the search example -demonstrateSearch().catch(console.error); -``` - -### Pattern 3: Paginated User Directory with Real-time Updates -```js -// Initialize paginated directory -const initUserDirectory = async (pageSize = 10) => { - const connection = await Fliplet.DataSources.connectByName("Users"); - - const currentCursor = await connection.findWithCursor({ - where: { Status: 'Active' }, - limit: pageSize, - order: [['data.Name', 'ASC']] - }); - - console.log('User directory initialized'); - return { connection, currentCursor }; -}; - -// Display current page -const displayCurrentPage = (cursor) => { - const { currentPage, isFirstPage, isLastPage, length } = cursor; - - console.log(`\n=== User Directory - Page ${currentPage + 1} ===`); - console.log(`Showing ${length} users:`); - - cursor.forEach((user, index) => { - const { Name, Email, Department, Office } = user.data; - console.log(`${index + 1}. ${Name} (${Email}) - ${Department}, ${Office}`); - }); - - console.log(`\nNavigation: First Page: ${isFirstPage}, Last Page: ${isLastPage}`); -}; - -// Navigate to next page -const nextPage = async (cursor) => { - if (!cursor.isLastPage) { - const updatedCursor = await cursor.next().update(); - displayCurrentPage(updatedCursor); - return updatedCursor; - } else { - console.log('Already on last page'); - return cursor; - } -}; - -// Navigate to previous page -const previousPage = async (cursor) => { - if (!cursor.isFirstPage) { - const updatedCursor = await cursor.prev().update(); - displayCurrentPage(updatedCursor); - return updatedCursor; - } else { - console.log('Already on first page'); - return cursor; - } -}; - -// Go to specific page -const goToPage = async (cursor, pageNumber) => { - const updatedCursor = await cursor.setPage(pageNumber - 1).update(); - displayCurrentPage(updatedCursor); - return updatedCursor; -}; - -// Subscribe to updates. -// This keeps checking for as long as the screen is open — see "Subscribing to -// Data Source Changes" above. On a real screen, also pause it while the screen -// is hidden; that is left out here to keep the pagination example readable. -const subscribeToUpdates = (connection, cursor) => { - const subscription = connection.subscribe({ - events: ['insert', 'update', 'delete'] - }, (changes) => { - const { inserted, updated, deleted } = changes; - - if (inserted?.length) { - console.log(`\n🆕 ${inserted.length} new user(s) added:`); - inserted.forEach(user => { - console.log(` + ${user.data.Name} (${user.data.Email})`); - }); - } - - if (updated?.length) { - console.log(`\n✏️ ${updated.length} user(s) updated:`); - updated.forEach(user => { - console.log(` ~ ${user.data.Name} (${user.data.Email})`); - }); - } - - if (deleted?.length) { - console.log(`\n🗑️ ${deleted.length} user(s) deleted`); - } - - // Refresh current page to show changes - refreshCurrentPage(cursor); - }); - - return subscription; -}; - -// Refresh current page -const refreshCurrentPage = async (cursor) => { - const updatedCursor = await cursor.update(); - console.log('\n--- Refreshed current page ---'); - displayCurrentPage(updatedCursor); - return updatedCursor; -}; - -// Cleanup function -const cleanup = (subscription) => { - if (subscription) { - subscription.unsubscribe(); - console.log('Real-time updates stopped'); - } -}; - -// Usage example -const demonstrateDirectory = async () => { - const { connection, currentCursor } = await initUserDirectory(5); // 5 users per page - let cursor = currentCursor; - - // Display initial page - displayCurrentPage(cursor); - - // Subscribe to updates - const subscription = subscribeToUpdates(connection, cursor); - - // Simulate navigation after delays - setTimeout(async () => { - cursor = await nextPage(cursor); - }, 2000); - - setTimeout(async () => { - cursor = await previousPage(cursor); - }, 4000); - - setTimeout(async () => { - cursor = await goToPage(cursor, 3); - }, 6000); - - // Cleanup after 30 seconds - setTimeout(() => { - cleanup(subscription); - }, 30000); -}; - -// Run the directory example -demonstrateDirectory().catch(console.error); -``` - -### Pattern 4: Comprehensive Error Handling and Recovery -```js -// Utility function for delays -const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - -// Connect with retry logic -const connectWithRetry = async (maxRetries = 3, retryDelay = 1000) => { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await Fliplet.DataSources.connectByName("Users"); - } catch (error) { - console.warn(`Connection attempt ${attempt} failed:`, error.message); - - if (attempt === maxRetries) { - throw new Error(`Failed to connect after ${maxRetries} attempts`); - } - - await delay(retryDelay * attempt); - } - } -}; - -// Safe operation wrapper with error handling -const safeOperation = async (operation, operationName) => { - try { - const result = await operation(); - console.log(`✅ ${operationName} completed successfully`); - return result; - } catch (error) { - const { status, message } = error; - - console.error(`❌ ${operationName} failed:`, message); - - switch (status) { - case 404: - throw new Error(`${operationName} failed: Data source or record not found`); - case 403: - throw new Error(`${operationName} failed: Access denied. Check permissions.`); - case 429: - console.log('Rate limit hit, waiting before retry...'); - await delay(5000); - return safeOperation(operation, operationName); - case 500: - throw new Error(`${operationName} failed: Server error. Please try again later.`); - default: - throw new Error(`${operationName} failed: ${message}`); - } - } -}; - -// Initialize robust user service -const initRobustUserService = async () => { - try { - const connection = await connectWithRetry(); - console.log('✅ UserService initialized successfully'); - return connection; - } catch (error) { - console.error('❌ Failed to initialize UserService:', error.message); - throw error; - } -}; - -// Get user safely with error handling -const getUserSafely = async (connection, email) => { - return safeOperation(async () => { - const users = await connection.find({ - where: { Email: email } - }); - - if (users.length === 0) { - throw new Error(`User with email ${email} not found`); - } - - return users[0]; - }, `Get user ${email}`); -}; - -// Create user safely with validation -const createUserSafely = async (connection, userData) => { - return safeOperation(async () => { - // Validate required fields - const requiredFields = ['Name', 'Email', 'Department']; - const missingFields = requiredFields.filter(field => !userData[field]); - - if (missingFields.length > 0) { - throw new Error(`Missing required fields: ${missingFields.join(', ')}`); - } - - // Check if user already exists - const existingUsers = await connection.find({ - where: { Email: userData.Email } - }); - - if (existingUsers.length > 0) { - throw new Error(`User with email ${userData.Email} already exists`); - } - - // Create user with defaults - const userToCreate = { - Status: 'Active', - CreatedDate: new Date().toISOString().split('T')[0], - ...userData - }; - - return await connection.insert(userToCreate); - }, `Create user ${userData.Email}`); -}; - -// Update user safely with validation -const updateUserSafely = async (connection, email, updates) => { - return safeOperation(async () => { - const user = await getUserSafely(connection, email); - - // Prevent email changes for data integrity - if (updates.Email && updates.Email !== email) { - throw new Error('Email address cannot be changed'); - } - - const updatedUser = await connection.update(user.id, { - ...updates, - LastModified: new Date().toISOString().split('T')[0] - }); - - return updatedUser; - }, `Update user ${email}`); -}; - -// Get all users with fallback handling -const getAllUsersWithFallback = async (connection) => { - return safeOperation(async () => { - const users = await connection.find({ - where: { Status: 'Active' }, - order: [['data.Name', 'ASC']] - }); - - if (users.length === 0) { - console.warn('⚠️ No active users found'); - } - - return users; - }, 'Get all users'); -}; - -// Usage example with comprehensive error handling -const demonstrateRobustOperations = async () => { - try { - const connection = await initRobustUserService(); - - // Safe operations - const newUser = await createUserSafely(connection, { - Name: 'Jane Doe', - Email: 'jane.doe@company.com', - Department: 'Engineering', - Office: 'London', - Age: 29 - }); - - const user = await getUserSafely(connection, 'jane.doe@company.com'); - - const updatedUser = await updateUserSafely(connection, 'jane.doe@company.com', { - Office: 'Remote', - Age: 30 - }); - - const allUsers = await getAllUsersWithFallback(connection); - - console.log('All operations completed successfully'); - - } catch (error) { - console.error('Application error:', error.message); - // Handle error appropriately in your app - } -}; - -// Run the robust operations example -demonstrateRobustOperations().catch(console.error); -``` - ---- - -## Test Data Source Structure - -To test all the examples above, you can create a "Users" data source with the following structure: - -### Data Source: "Users" - -**Columns:** -- `Name` (Text) - Full name of the user -- `Email` (Text) - Email address (should be unique) -- `Department` (Text) - Department name -- `Office` (Text) - Office location -- `Age` (Number) - Age in years -- `Status` (Text) - Active/Inactive status - -**Sample Test Data:** -```json -[ - { - "Name": "John Smith", - "Email": "john.smith@company.com", - "Department": "Engineering", - "Office": "London", - "Age": 30, - "Status": "Active" - }, - { - "Name": "Sarah Johnson", - "Email": "sarah.johnson@company.com", - "Department": "Design", - "Office": "New York", - "Age": 28, - "Status": "Active" - }, - { - "Name": "Mike Wilson", - "Email": "mike.wilson@company.com", - "Department": "Engineering", - "Office": "London", - "Age": 32, - "Status": "Active" - }, - { - "Name": "Emily Davis", - "Email": "emily.davis@company.com", - "Department": "Marketing", - "Office": "Berlin", - "Age": 26, - "Status": "Active" - }, - { - "Name": "David Brown", - "Email": "david.brown@company.com", - "Department": "Sales", - "Office": "Paris", - "Age": 29, - "Status": "Active" - }, - { - "Name": "Lisa Garcia", - "Email": "lisa.garcia@company.com", - "Department": "Design", - "Office": "New York", - "Age": 31, - "Status": "Active" - }, - { - "Name": "Tom Anderson", - "Email": "tom.anderson@company.com", - "Department": "Engineering", - "Office": "Berlin", - "Age": 27, - "Status": "Inactive" - }, - { - "Name": "Anna Martinez", - "Email": "anna.martinez@company.com", - "Department": "HR", - "Office": "London", - "Age": 33, - "Status": "Active" - }, - { - "Name": "Chris Taylor", - "Email": "chris.taylor@company.com", - "Department": "Marketing", - "Office": "Remote", - "Age": 25, - "Status": "Active" - }, - { - "Name": "Jennifer Lee", - "Email": "jennifer.lee@company.com", - "Department": "Finance", - "Office": "New York", - "Age": 34, - "Status": "Active" - } -] -``` - -### Creating the Test Data Source - -You can create this data source programmatically using the API: - -```js -// Create the Users data source with test data -const testDataSource = await Fliplet.DataSources.create({ - name: 'Users', - appId: Fliplet.Env.get('appId'), - organizationId: Fliplet.Env.get('organizationId'), - columns: ['Name', 'Email', 'Department', 'Office', 'Age', 'Status'], - entries: [ - // ... paste the sample test data above - ], - accessRules: [ - { type: ['select', 'insert', 'update', 'delete'], allow: 'all' } - ] -}); - -console.log('Test Users data source created:', testDataSource); -``` - -This test data provides: -- **Variety**: Multiple departments, offices, and age ranges -- **Real scenarios**: Active/inactive users, different locations -- **Search testing**: Names and emails for text search testing -- **Filter testing**: Multiple values for department and office filters -- **Pagination testing**: 10 records for testing pagination features -- **Age ranges**: Different ages for numeric filtering tests - -All examples in this documentation use this data structure and will work with this test data. - ---- - -## Offline database (native only) - -`Fliplet.DataSources.Database` is the offline-first database API for native Fliplet apps. It loads bundled JSON files from device storage on native (or fetches them via the REST bundle endpoint on web during development), then exposes a synchronous collection handle for CRUD and diff operations against the in-memory data. - -

    This API is primarily for native apps. On web it falls back to fetching the bundle, which is suitable for dev/test only.

    - -**Important notes:** - -- **Module-level cache.** Once a bundle is loaded the parsed object is reused for the process lifetime — subsequent calls to `Fliplet.DataSources.Database` with the same identifier do not re-read the file. -- **No write batching.** Every mutating collection method (`insert`, `update`, `remove`, `applyDiff`) writes the full JSON file to disk via `Fliplet.Native.Maintenance.writeFile` within the same Promise chain. -- **`update()` deep-merges.** Pass only the fields you want changed; existing fields not present in the second argument are left intact. -- **`applyDiff({ fullRefresh: true })`** replaces the entire local collection with the server payload. Without `fullRefresh`, the diff removes entries by id and then appends the updated set. - -### Opening the database - -`Fliplet.DataSources.Database(options)` → `Promise` - -| Option | Type | Required | Description | -|---|---|---|---| -| `id` | Number \| String | Yes (unless `isName`) | Data source ID, or name when `isName: true` | -| `isName` | Boolean | No | Set to `true` when `id` holds the data source name | -| `name` | String | No | Alternative way to supply the name (used together with `isName: true`) | -| `appId` | Number | No | Defaults to `Fliplet.Env.get('appId')` | - -```js -// Open by data source ID -const db = await Fliplet.DataSources.Database({ id: 42 }); - -// Open by data source name -const db = await Fliplet.DataSources.Database({ id: 'Products', isName: true }); -``` - -### Selecting a data source - -`database.dataSource(id, opts?)` → `CollectionHandle` (synchronous — returns immediately, no Promise) - -```js -const db = await Fliplet.DataSources.Database({ id: 42 }); - -// Select by numeric ID -const products = db.dataSource(42); - -// Select by name using the opts object -const inventory = db.dataSource(null, { name: 'Inventory' }); -``` - -### Reading data - -#### `find(where, options?)` → `Promise` - -Runs a [sift.js](https://github.com/crcn/sift.js) MongoDB-style query against `entry.data`. Supports data-source views (row-level filters) when `options.views` is provided. - -| Option | Type | Description | -|---|---|---| -| `limit` | Number | Maximum number of entries to return | -| `views` | Array\ | Names of data-source views to apply as row-level filters | - -```js -const db = await Fliplet.DataSources.Database({ id: 42 }); -const products = db.dataSource(42); - -// Return all entries -const all = await products.find({}); - -// Filter by category -const electronics = await products.find({ Category: 'Electronics' }); - -// MongoDB-style operators -const affordable = await products.find( - { Price: { $lte: 99.99 }, InStock: true }, - { limit: 20 } -); - -// Apply a pre-configured view called "PublicCatalogue" -const publicItems = await products.find({}, { views: ['PublicCatalogue'] }); -``` - -#### `findById(id)` → `Promise` - -```js -const product = await products.findById(101); - -if (product) { - console.log(product.data.Name, product.data.Price); -} -``` - -### Writing data - -All write methods mutate the in-memory collection first, then persist the full JSON bundle to disk. - -#### `insert(entry)` → `Promise` - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); - -const newItem = await inventory.insert({ - id: 9001, - data: { - SKU: 'WH-001', - Name: 'Wireless Headphones', - Price: 79.99, - InStock: true, - Category: 'Electronics' - } -}); -``` - -#### `update(existingEntry, entry)` → `Promise` - -Deep-merges `entry` into `existingEntry` in-place. Pass only the fields you want changed. - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); - -const headphones = await inventory.findById(9001); - -// Update price and stock status only — all other fields are preserved -await inventory.update(headphones, { - data: { Price: 69.99, InStock: false } -}); -``` - -#### `remove(entry)` → `Promise` - -Accepts an entry object (matched by `id`) or a predicate function. - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); - -// Remove a specific entry by reference -const item = await inventory.findById(9001); -await inventory.remove(item); - -// Remove all out-of-stock entries using a predicate -await inventory.remove(entry => entry.data.InStock === false); -``` - -### Applying server diffs - -`applyDiff(diff)` → `Promise` - -Used internally by the Fliplet native update pipeline to apply incremental server changes to the local bundle. - -| Field | Type | Description | -|---|---|---| -| `updated` | Array | Entries to upsert (replace or append) | -| `deleted` | Array | Entries to remove by id | -| `fullRefresh` | Boolean | When `true`, replaces the entire local collection with `updated` | - -```js -const db = await Fliplet.DataSources.Database({ id: 42 }); -const products = db.dataSource(42); - -// Incremental update from server -await products.applyDiff({ - updated: [ - { id: 101, data: { Name: 'Laptop Pro', Price: 1299.99, InStock: true } }, - { id: 102, data: { Name: 'USB-C Hub', Price: 49.99, InStock: true } } - ], - deleted: [ - { id: 88 } - ] -}); - -// Full refresh — replace the entire local collection -await products.applyDiff({ - fullRefresh: true, - updated: [ - { id: 201, data: { Name: 'Monitor 4K', Price: 499.99, InStock: true } } - ], - deleted: [] -}); -``` - -### Indexes - -#### `indexes(columnsArray)` → `Promise` - -Returns unique values per column across all entries in the collection. If the bundle already contains a pre-computed `indexes` map, that is used directly; otherwise unique values are derived by scanning entries. - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); - -const uniqueValues = await inventory.indexes(['Category', 'InStock']); -// { Category: ['Electronics', 'Accessories'], InStock: [true, false] } -``` - -#### `index(columnName)` → `Promise` - -Returns unique values for a single column. - -```js -const categories = await inventory.index('Category'); -// ['Electronics', 'Accessories', 'Peripherals'] -``` - ---- - -[Back to API documentation](../API-Documentation) -{: .buttons} +[Native Database handles and collection methods](datasources/offline-database.md). --- @@ -42308,7 +41633,7 @@ Every Fliplet JS API available to V3 apps, grouped by capability category. Each ## Data - [`Fliplet.Cache`](https://developers.fliplet.com/API/core/cache.html) **(preloaded)** — Run async operations once and memoize their results, with optional expiry and background refresh. -- [`Fliplet.DataSources`](https://developers.fliplet.com/API/fliplet-datasources.html) — Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +- [`Fliplet.DataSources`](https://developers.fliplet.com/API/fliplet-datasources.html) — Connect to Fliplet data sources and find references for queries, writes, subscriptions, native storage and source management. - [`Fliplet.DataSources.Encryption`](https://developers.fliplet.com/API/fliplet-encryption.html) — Automatically encrypt and decrypt selected Data Source columns on-device by registering a private key and column list. - [`Fliplet.Storage and Fliplet.App.Storage`](https://developers.fliplet.com/API/core/storage.html) **(preloaded)** — Persist JSON-serializable values to device or browser storage, scoped globally or to the current app. diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index d2d3366a..b405f2c1 100644 --- a/docs/.well-known/llms-v3-libraries.json +++ b/docs/.well-known/llms-v3-libraries.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-06T16:53:50.629Z", + "generatedAt": "2026-10-08T14:54:45.963Z", "libraries": [ { "package": "fliplet-analytics-spa", @@ -133,7 +133,7 @@ "package": "fliplet-datasources", "namespace": "Fliplet.DataSources", "title": "Fliplet.DataSources", - "description": "Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based.", + "description": "Connect to Fliplet data sources and find references for queries, writes, subscriptions, native storage and source management.", "docUrl": "https://developers.fliplet.com/API/fliplet-datasources.md", "preloaded": false, "capabilities": [ diff --git a/docs/.well-known/llms.txt b/docs/.well-known/llms.txt index b964d52d..7dde4eb5 100644 --- a/docs/.well-known/llms.txt +++ b/docs/.well-known/llms.txt @@ -81,12 +81,18 @@ ## Data Sources -- [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. +- [Data Source file attachments](https://developers.fliplet.com/API/datasources/file-attachments.md): Attach files to Data Source entries with insert or update, handle upload constraints and distinguish file uploads from dataset imports. +- [Data Source joins](https://developers.fliplet.com/API/datasources/joins.md): Fetch related rows with named join configuration and read them from each record’s joins property. +- [Managing data sources](https://developers.fliplet.com/API/datasources/managing-data-sources.md): Source metadata, creation, configuration, deletion and Studio user roles, separate from app record access. +- [Offline Data Source database](https://developers.fliplet.com/API/datasources/offline-database.md): Read and modify native bundled Data Sources with Database handles, local collection methods, views, indexes, and explicit persistence boundaries. - [Data Sources query operators](https://developers.fliplet.com/API/datasources/query-operators.md): Filter Data Source queries with MongoDB/Sift operators ($eq, $gt, $in, $regex, $and, $or) inside connection.find() where clauses. +- [Reading Data Source records](https://developers.fliplet.com/API/datasources/reading-data.md): Read records, filter and sort queries, paginate with cursors, and retrieve distinct column values with Fliplet.DataSources. - [Data source security examples](https://developers.fliplet.com/API/datasources/security-examples.md): Pair public, shared, owner-filtered read, profile and role-based examples with data shapes and allowed and denied SDK operations. - [Data source security rule reference](https://developers.fliplet.com/API/datasources/security-rules.md): Access rule evaluation, trusted identity, requirement targets, column restrictions and custom script behavior. +- [Data Source subscriptions](https://developers.fliplet.com/API/datasources/subscriptions.md): Subscribe to Data Source changes, handle entry payloads, and stop monitoring when a screen is hidden or removed. - [Testing data source security in preview](https://developers.fliplet.com/API/datasources/testing-security.md): Verify allowed and denied data source operations as authenticated app users with Studio preview security enforcement enabled. -- [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. +- [Data Source views](https://developers.fliplet.com/API/datasources/views.md): Request named filters that bind to the current login session. Views filter query results; access rules must enforce privacy independently. +- [Writing Data Source records](https://developers.fliplet.com/API/datasources/writing-data.md): Insert, update, delete and commit Data Source records with the correct input shapes, options, write outcomes and security limits. ## Integrations @@ -115,7 +121,7 @@ - [Fliplet.Content()](https://developers.fliplet.com/API/fliplet-content.md): Create, query, update, and delete shared content records (bookmarks, likes, saved searches) backed by a data source via Fliplet.Content. - [Fliplet.CSV](https://developers.fliplet.com/API/fliplet-csv.md): Encode and decode CSV in Fliplet apps via the fliplet-csv package. - [Fliplet.Database](https://developers.fliplet.com/API/fliplet-database.md): Low-level helper for reading and querying JSON data from a local file as a database; used internally by Fliplet.DataSources. -- [Fliplet.DataSources](https://developers.fliplet.com/API/fliplet-datasources.md): Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +- [Fliplet.DataSources](https://developers.fliplet.com/API/fliplet-datasources.md): Connect to Fliplet data sources and find references for queries, writes, subscriptions, native storage and source management. - [Fliplet.DataSources.Encryption](https://developers.fliplet.com/API/fliplet-encryption.md): Automatically encrypt and decrypt selected Data Source columns on-device by registering a private key and column list. - [fliplet-error-tracking](https://developers.fliplet.com/API/fliplet-error-tracking.md): Auto-loading runtime that captures JavaScript exceptions and unhandled promise rejections and batches them to Fliplet.App.Logs. - [Fliplet.Gamify](https://developers.fliplet.com/API/fliplet-gamify.md): Configure gamification with logs, variables, and achievements via Fliplet.Gamify; track points, milestones, and badges backed by a data source per user. diff --git a/docs/API/datasources/file-attachments.md b/docs/API/datasources/file-attachments.md new file mode 100644 index 00000000..649d7081 --- /dev/null +++ b/docs/API/datasources/file-attachments.md @@ -0,0 +1,141 @@ +--- +title: Data Source file attachments +description: "Attach files to Data Source entries with insert or update, handle upload constraints and distinguish file uploads from dataset imports." +type: api-reference +tags: [js-api, datasources, files] +v3_relevant: true +deprecated: false +--- +# Data Source file attachments + +Single-record `insert` and `update` accept file attachments alongside column values. Uploaded fields become stored file URLs with associated media IDs; dataset import is a separate capability. + +## Before you start + +Load the [Data Sources library](../fliplet-datasources.md), configure app-user [write permissions](../../Data-source-security.md) and use a source with an attachment column. The examples use **Equipment requests** with `Item` and `Receipt` columns. The caller also needs the appropriate app/media context and available storage. + +The example requires a selected browser `File` from an ``. Upload only after the user chooses the intended file. Use an online connection and `{ offline: false }` for `insert` when the flow must receive a server record; multipart input cannot be queued after a failed send. + +## Attachment inputs + +| Input | Example | Behavior | +|---|---|---| +| `FormData` | `form.append('Receipt', file, file.name)` | Sent as multipart. Include ordinary fields in the same form. | +| Plain insert object containing `File` or `Blob` | `{ Item: 'Headset', Receipt: file }` | For `insert`, the SDK converts detected files to multipart. For `update`, pass a single File/Blob using explicit FormData: a nonarray object field is JSON-serialized rather than preserved. A File supplies its filename; a Blob needs a usable MIME type or filename. | +| Plain object containing a base64 data URL | `{ Receipt: 'data:image/png;base64,...' }` | Detected as file input and converted to multipart. Use a complete data URL, not an arbitrary base64 string. | +| Array of file inputs | `{ Receipt: [fileA, fileB] }` | File array fields are sent using the `Receipt[]` name and return URL/ID arrays. | + +In FormData, ordinary scalar values are strings. Serialize nested nonfile values intentionally rather than relying on browser object coercion. The upload response and read-back are the authoritative stored shapes. + +## Insert an entry with a file + +This function receives a browser File. It checks that the write returned a server entry, then reads that ID to verify the stored attachment field. The read-back requires read permission. + +```js +async function saveReceipt(file) { + if (!(file instanceof File)) { + throw new Error('Choose a receipt file first.'); + } + + const connection = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false + }); + const form = new FormData(); + form.append('Item', 'Headset'); + form.append('Receipt', file, file.name); + + const created = await connection.insert(form, { offline: false }); + if (!created || !created.id || !created.data) { + throw new Error('The upload did not return a saved record.'); + } + + const saved = await connection.findById(created.id); + console.log('Stored receipt URL:', saved.data.Receipt); + return saved; +} +``` + +Call it from your existing file-input handler with `event.target.files[0]`, checking that a file exists. Catch errors in that handler and display the returned message; do not immediately repeat the upload because a failed response can follow a saved entry or file. + +## Update an existing attachment + +`connection.update(id, formData, options?)` uploads files onto an existing row. Obtain `id` from a current query/read; use a File selected by the user. This example uses the same source and field as the insertion example: + +```js +async function replaceReceipt(entryId, file) { + if (!entryId || !(file instanceof File)) { + throw new Error('Choose an existing request and a receipt file.'); + } + const connection = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false + }); + const form = new FormData(); + form.append('Receipt', file, file.name); + await connection.update(entryId, form); // allowOffline remains false + const saved = await connection.findById(entryId); + console.log('Stored receipt:', saved.data.Receipt); + return saved; +} +``` + +Existing array-valued attachment fields can have new file URLs appended rather than replacing the array. Do not assume uploading a replacement deletes earlier media files. Media lifecycle and file access are covered by the [Media API](../fliplet-media.md). + +## Options and returned fields + +The [individual-write reference](writing-data.md#individual-writes) owns all insert/update options. `folderId` selects a media folder for attachments; it does not grant access to that folder. `ack` concerns local reconciliation, not upload authorization. `public` requests shared-content behavior for the entry and is not a file-access permission switch. + +The returned record uses a `{ id, data, ... }` envelope. For a file field named `Receipt`, stored `data.Receipt` contains its URL, or an array of URLs for a multi-file field. `data.ReceiptMediaFileId` contains the associated media ID or ID array. Excluded fields might be absent, and protected hashes are redacted. + +An entry response does not mean every server hook, malware scan or downstream action completed. Check file access using the intended app identity; visibility in Studio is not sufficient. + +### Per-field file schema + +The optional `_flSchema` input is an object keyed by attachment field name. In FormData it is supplied as a JSON string. It configures file handling rather than enforcing a Data Source column type. + +| `_flSchema.Receipt` property | Type | Behavior | +|---|---|---| +| `append` | Boolean | `false` discards existing attachment arrays in favor of the requested files/values. Omitted values allow retaining existing arrays for array uploads. This does not delete the earlier media objects. | +| `name` | String | Overrides the stored uploaded filename. | +| `mediaFolderId` | Number | Overrides the request's `folderId` for this attachment field. | +| `encryptionKey` | String | Supplies a private file-encryption key to the configured media upload path. Use only as part of an established encrypted-media integration; supplying a key is not an access-rule grant. | + +For an intentionally replaced multi-file field, include the schema and file array in the same request: + +```js +function receiptForm(files) { + if (!Array.isArray(files) || !files.length || !files.every(file => file instanceof File)) { + throw new Error('Choose at least one receipt file.'); + } + const form = new FormData(); + form.append('_flSchema', JSON.stringify({ Receipt: { append: false } })); + files.forEach(file => form.append('Receipt[]', file, file.name)); + return form; // pass to connection.update(existingEntryId, form) +} +``` + +## Upload constraints and failures + +The current server accepts a maximum of **500 MiB per attached file** (524,288,000 bytes). A multipart request envelope above **510 MiB** can be rejected before individual file processing. Plan lower app limits when several files share a request; hosting limits and available organization storage can further restrict uploads. + +Files need a recognizable filename extension or a supported MIME type from which an extension can be determined. Unrecognized formats and over-limit files reject with HTTP 422. Executable, script, binary and archive extensions rejected by the current validator are: + +```text +exe msi dll bat cmd sh bash jar bin sys +js rb py php ts pl cgi +so drv vxd +zip tar 7z rar gz iso +``` + +Use normal lowercase filename extensions and a MIME type matching the file. Do not treat an extension list or client-side validation as an upload security guarantee. Server storage, access and scanning controls still apply. + +`insert` can expose permanent HTTP 401/422 failures as `{ code: 2, message }`. A multipart send failure can reject `{ code: 1, message }`; the message can contain a nested error object. Update normally rejects the request error. Enabling `allowOffline` for update can instead resolve an offline-shaped value after failure, so leave it disabled when confirming an upload. See [write outcomes and recovery](writing-data.md#failures-and-recovery). + +## Dataset imports + +`connection.import(data)` exists as a transport wrapper: it applies the client `insert` hook and posts its input without automatic processing or a content-type declaration. That signature does **not** establish CSV-file parsing support. The inspected destination consumes an `entries` batch, and a FormData file alone does not establish that an import occurred. No CSV `FormData` example is recommended here. + +For data you have already parsed and validated into flat column objects, use [append](writing-data.md#appending-and-replacing-a-batch) to add rows or `replaceWith` for an intentional complete replacement. This does not provide a CSV parser. Follow the supported Studio import workflow for file-based dataset import, or supply your own verified parser before calling a batch method. + +`append`, `replaceWith` and `commit` are not single-record file upload methods. Do not pass File/Blob values to their JSON batch payloads and assume they will upload attachments. + +[Back to Data Sources](../fliplet-datasources.md) diff --git a/docs/API/datasources/joins.md b/docs/API/datasources/joins.md index 0ca29610..83b008f6 100644 --- a/docs/API/datasources/joins.md +++ b/docs/API/datasources/joins.md @@ -1,6 +1,6 @@ --- title: Data Source joins -description: "Fetch related rows from multiple data sources in a single query using named joins, like SQL joins." +description: "Fetch related rows with named join configuration and read them from each record’s joins property." type: api-reference tags: [js-api, datasources, joins] v3_relevant: true @@ -8,7 +8,7 @@ deprecated: false --- # Data Source joins -Fetch related rows from multiple data sources in a single query using named joins, like SQL joins. +Fetch related rows with named join configuration and read them from each record’s joins property. Joins are defined by a unique name and their configuration options; any number of joins can be defined when fetching data from one data source: @@ -20,10 +20,10 @@ const connection = await Fliplet.DataSources.connect(123); const result = await connection.find({ join: { // ... with their comments - Comments: { options }, + Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them - Users: { options } + Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }); console.log(result); @@ -37,16 +37,16 @@ Fliplet.DataSources.connect(123).then(function (connection) { return connection.find({ join: { // ... with their comments - Comments: { options }, + Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them - Users: { options } + Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }) }).then(console.log) ``` -Before we dive into complete examples, let's start with the three types of joins we support. +The following sections describe join configurations and result shapes. ## Types of joins @@ -63,6 +63,8 @@ Consider **an example** where two dataSources are created as follows: #### Articles +Here `ID` is the record metadata `id`, and `ArticleID` is a data column in Comments. + | ID | Title | |----|-------------------------| | 1 | A great blog post | @@ -83,9 +85,9 @@ We can simply reference the entries between the two dataSources as follows: const result = await connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -98,9 +100,9 @@ const result = await connection.find({ connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -119,9 +121,9 @@ Inner joins are defined like left joins but with the `required` attribute set to const result = await connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, required: true } @@ -135,9 +137,9 @@ const result = await connection.find({ connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, required: true } @@ -200,9 +202,9 @@ Example input: const result = await connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -215,9 +217,9 @@ const result = await connection.find({ connection.find({ join: { Comments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' } } } @@ -230,18 +232,18 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { Comments: [ { id: 3, - dataSourceId: 123, + dataSourceId: 456, data: { ArticleID: 1, 'Comment text': 'Thanks! This was worth reading.', Likes: 5 } }, { id: 4, - dataSourceId: 123, + dataSourceId: 456, data: { ArticleID: 1, 'Comment text': 'Loved it, would read it again.', Likes: 2 } } ] @@ -262,9 +264,9 @@ Example input: const result = await connection.find({ join: { HasComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, has: true } @@ -278,9 +280,9 @@ const result = await connection.find({ connection.find({ join: { HasComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, has: true } @@ -294,7 +296,7 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { HasComments: true @@ -302,7 +304,7 @@ Example of the returned data: }, { id: 2, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'Something worth reading' }, joins: { HasComments: false @@ -323,9 +325,9 @@ Example input: const result = await connection.find({ join: { NumberOfComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, count: true } @@ -339,9 +341,9 @@ const result = await connection.find({ connection.find({ join: { NumberOfComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, count: true } @@ -355,7 +357,7 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { NumberOfComments: 2 @@ -363,7 +365,7 @@ Example of the returned data: }, { id: 2, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'Something worth reading' }, joins: { NumberOfComments: 0 @@ -384,9 +386,9 @@ Example input: const result = await connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, sum: 'Likes' } @@ -400,9 +402,9 @@ const result = await connection.find({ connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, sum: 'Likes' } @@ -416,7 +418,7 @@ Example of the returned data: [ { id: 1, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'A great blog post' }, joins: { LikesForComments: 7 @@ -424,7 +426,7 @@ Example of the returned data: }, { id: 2, - dataSourceId: 456, + dataSourceId: 123, data: { Title: 'Something worth reading' }, joins: { LikesForComments: 0 @@ -445,9 +447,9 @@ Use the `where` parameter to define a filtering query for the data to be selecte const result = await connection.find({ join: { LikesForPopularComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, where: { // only fetch a comment when it has more than 10 likes @@ -464,9 +466,9 @@ const result = await connection.find({ connection.find({ join: { LikesForPopularComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, where: { // only fetch a comment when it has more than 10 likes @@ -487,9 +489,9 @@ Use the `attributes` parameter to define which fields should only be returned fr const result = await connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -504,9 +506,9 @@ const result = await connection.find({ connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -525,9 +527,9 @@ Use the `limit` parameter to define how many entries should be returned at most const result = await connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch up to 5 comments at most limit: 5 @@ -542,9 +544,9 @@ const result = await connection.find({ connection.find({ join: { LikesForComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch up to 5 comments at most limit: 5 @@ -569,9 +571,9 @@ Use the `order` parameter to define the order at which entries are returned for const result = await connection.find({ join: { MostRecentComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the 5 most recent comments, combining order and limit // note: inside a join, order is a single flat [column, direction] pair @@ -588,9 +590,9 @@ const result = await connection.find({ connection.find({ join: { MostRecentComments: { - dataSourceId: 123, + dataSourceId: 456, on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the 5 most recent comments, combining order and limit // note: inside a join, order is a single flat [column, direction] pair @@ -613,7 +615,7 @@ const result = await connection.find({ LikesForComments: { dataSourceName: 'User comments', on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -630,7 +632,7 @@ connection.find({ LikesForComments: { dataSourceName: 'User comments', on: { - 'data.ID': 'data.ArticleID' + 'id': 'data.ArticleID' }, // only fetch the comment text attributes: ['Comment text'] @@ -642,4 +644,12 @@ connection.find({ --- [Back to DataSources general documentation](../fliplet-datasources) -{: .buttons} \ No newline at end of file +{: .buttons} + +## Combinations and result counts + +When reduction options are combined, `count` takes precedence over `has`, which takes precedence over `sum`. Reductions use the matched rows before `limit` and `attributes`; those options apply to array results. `sum` parses the named data column as a number and treats unparseable values as zero. Prefer one reduction option per join. + +Online parent pagination is applied before required-join filtering. Therefore `pagination.total` can include parents removed by a required join, and a returned page can contain fewer rows than its limit. Joins use an SDK/API composition, not a promise of a database-native SQL JOIN or a universal performance improvement. Native local join behavior differs; verify the target platform rather than assuming all online combinations behave identically. + +[Reading records and pagination](reading-data) diff --git a/docs/API/datasources/managing-data-sources.md b/docs/API/datasources/managing-data-sources.md new file mode 100644 index 00000000..87ebf34c --- /dev/null +++ b/docs/API/datasources/managing-data-sources.md @@ -0,0 +1,154 @@ +--- +title: Managing data sources +description: "Source metadata, creation, configuration, deletion and Studio user roles, separate from app record access." +type: api-reference +tags: [js-api, datasources] +v3_relevant: true +deprecated: false +--- +# Managing data sources + +Namespace methods manage source metadata and settings. They differ from connection methods that read and write records, and require a caller authorized for the relevant management operation. + +## Before you start + +Use these methods in an authorized Studio or management integration context. Ordinary app-user access rules do not grant source administration. Available sources and metadata depend on the current app, organization and caller permissions. To implement app record CRUD, use [reading data](reading-data.md) and [writing data](writing-data.md). + +Source columns are field names, not a typed schema. `accessRules` configure app operations; source-user roles configure Studio users' source permissions. [App authentication](../v3/auth.md) owns `connection.sendValidation(data)` and `connection.validate(data)`, including their inputs and responses. `validate` refreshes the cached user session after successful validation. + +## List sources + +`Fliplet.DataSources.get(data?, options?)` returns `Promise`. Omitted arguments default to empty objects. + +| `data` property | Type and behavior | +|---|---| +| `appId` | App identifier; the caller must have the necessary app access. | +| `attributes` | Array of names or comma-separated string selecting source metadata fields. | +| `include` | Comma-separated associated metadata names supported by the listing endpoint. | +| `type` | Source type; an explicitly empty value requests ordinary sources. | +| `excludeTypes` | Comma-separated source types to exclude; system-source exclusions also apply. | +| `order`, `direction` | Metadata sort field and direction; default `name`, `ASC`. | +| `roles` | Comma-separated Studio app role names, applied within the caller's listing context. These are not source `crudq` permission letters or app-login `Roles` fields. | +| `definition` | Object filter on source definitions in the app context. The SDK serializes it and disables request caching. This is not an entry `where` query. | +| `includeInUse` | String `'true'` requests in-use information in supported listing contexts. | +| `includeCount` | Presence requests counts in supported listing contexts. | + +`options.cache` defaults to `true`; explicit `false` bypasses SDK request caching. Listing rejects on access, request or filter failures. Results are source objects such as `{ id, name, columns, definition }`, with fields determined by projection and permissions. + +```js +async function listSourceNames() { + const sources = await Fliplet.DataSources.get({ attributes: ['id', 'name'] }, { + cache: false + }); + return sources.map(source => ({ id: source.id, name: source.name })); +} +``` + +## Read source metadata + +`Fliplet.DataSources.getById(id, options?)` returns `Promise` and rejects for missing/inaccessible sources or request failures. + +| Option | Type and behavior | +|---|---| +| `attributes` | Array or comma-separated string of metadata fields. | +| `cache` | Request caching; default `true`. | +| `params` | Query-parameter object. `includeEntriesCount` requests an entry count when present. `include: 'associatedPages'` requests page associations when an app context exists. | + +```js +async function inspectSource(sourceId) { + return Fliplet.DataSources.getById(sourceId, { + attributes: ['id', 'name', 'columns'], + cache: false, + params: { includeEntriesCount: true } + }); +} +``` + +Reading source metadata does not prove permission to read every entry or bypass app rules. + +## Create a source + +`Fliplet.DataSources.create(options)` returns `Promise`. Use a nonempty `name` and supply the authorized app/organization scope. The SDK does not automatically add `appId` or `organizationId`; the request needs one of these scopes. + +| Option | Type and consequence | +|---|---| +| `name` | Required nonempty String. | +| `appId` or `organizationId` | Required management scope identifier; supply the app or organization the caller can administer. | +| `columns` | Array of column names. | +| `entries` | Optional seed-entry array handled during source creation; returned value is the source, not a list of inserted rows. | +| `accessRules` | Rule array. If omitted/falsy, the SDK supplies `[]`, denying app operations until an applicable grant is configured. | +| `definition` | Source definition Object; login, view and index settings need their owning references. | +| `hooks` | Server-hook configuration; see [Data Source Hooks](../../Data-Source-Hooks.md). | +| `bundle` | Bundling setting for native apps. | +| `encrypted`, `type`, `appCapabilities` | Source configuration fields for the relevant management workflow; do not infer app-user permissions from them. | + +This setup example creates an empty source with app access denied. Run it once in a disposable, authorized management context; repeated calls create additional sources rather than guaranteeing name-based reuse. + +```js +async function createDisposableSource(appId) { + if (!appId) throw new Error('Supply the disposable test app ID.'); + const source = await Fliplet.DataSources.create({ + appId, + name: 'Workshop Test Records', + columns: ['Title', 'Status'], + accessRules: [] + }); + console.log('Created source', source.id); + return source; +} +``` + +Invalid configuration, missing scope, feature limits or permission failures reject. Configure and verify [access rules](../../Data-source-security.md) before app users use the new source. + +## Update source settings + +`Fliplet.DataSources.update(id, data)` and `Fliplet.DataSources.update({ id, ...data })` return `Promise`. Missing ID throws synchronously in the online SDK path. The object overload removes `id` from the supplied object; pass a fresh object if you need to retain it. + +The source must allow the caller to update settings. Supported settings include `name`, `columns`, `definition`, `hooks`, `bundle`, `accessRules` and `appCapabilities`. Each field has its own setter/validation behavior; do not assume recursive merging or that this is a record update. A successful update clears SDK request caches. Invalid names/settings, denied permission or request failures reject. + +```js +async function renameSource(sourceId) { + const source = await Fliplet.DataSources.update(sourceId, { + name: 'Workshop Archive' + }); + return source.name; +} +``` + +`connection.update(id, data, options?)` updates one entry instead. Its separate signature and outcomes are in [writing data](writing-data.md). + +## Delete a source + +`Fliplet.DataSources.delete(id)` returns a Promise resolving the deletion response. It deletes the source rather than an individual entry; do not expect an entry array. Use only a disposable source or a deliberate management action. App-token callers are rejected by the source-deletion endpoint. Permission and request failures reject. + +Delete one entry with `connection.removeById(id)` instead. Do not implement source deletion as app-user record cleanup. + +## Studio user roles + +These connection methods manage source permissions for Studio users. They do not create app login records, set a login record's `Roles` array or prove app-user authorization. + +| Method | Arguments and result | +|---|---| +| `connection.getUsers()` | Returns `Promise` of `{ fullName, firstName, lastName, email, dataSourceRole }`. Email is masked and `dataSourceRole.userId` is opaque, not a numeric account ID. | +| `connection.addUserRole(data)` | `data.userId` is a numeric Studio account ID; `permissions` defaults to `'crudq'` and accepts only those permission letters; optional `definition` holds role configuration. Returns the created role object. | +| `connection.removeUserRole(userId)` | Uses the source-role identifier expected by the management endpoint, which can be the opaque identifier from the listing. Returns the deletion response, not the removed user. | + +`dataSourceRole` includes `userId`, `permissions`, `definition`, `createdAt`, `updatedAt` and `dataSourceId`. Do not feed a masked email or opaque listing identifier into `addUserRole` as the numeric account ID. Invalid permission letters or a nonnumeric creation ID reject with a validation error; missing role/access and request failures reject. + +## Mapped-row helper + +`Fliplet.DataSources.fetchWithOptions(options)` is a convenience wrapper. Prefer `connection.find` when entry IDs/metadata or pagination envelopes are needed. + +| Option | Contract | +|---|---| +| `dataSourceId` | Required source identifier. Missing/falsy value rejects. | +| `query` | Query object passed to `find`. Keep normal array results; pagination/object envelopes are not compatible with its row mapping. | +| `columns` | Array of field names or single-key maps, e.g. `['Name', { label: 'Title' }]`. Omitted/empty selects row data directly. | +| `compact` | Default `true`; excludes mapped null/undefined/blank strings. `false` retains these mapped values. | +| `cache` | Converted to Boolean; default `false` for the entry request. | + +The helper connects online and normally resolves `{ dataSource, dataSourceEntries }`. `dataSourceEntries` contains flat column objects, not `{ id, data }` entries. `dataSource.columns` is recalculated from available mapped columns. Empty mapped rows are removed. A metadata failure is tolerated and can leave metadata incomplete; an entry query failure still rejects. When `query.aggregate` is supplied, it returns the raw aggregation result instead of this wrapper. + +## Client helpers and internal exports + +[The main reference](../fliplet-datasources.md#sdk-helper-boundaries) classifies client hooks, temporary IDs and connection lookup helpers. They do not enforce server authorization. Underscore helpers and core queue plumbing are not recommended app interfaces. Native `applyDiff` is described only as an update-pipeline compatibility surface in [offline storage](offline-database.md). diff --git a/docs/API/datasources/offline-database.md b/docs/API/datasources/offline-database.md new file mode 100644 index 00000000..98af5532 --- /dev/null +++ b/docs/API/datasources/offline-database.md @@ -0,0 +1,202 @@ +--- +title: Offline Data Source database +description: "Read and modify native bundled Data Sources with Database handles, local collection methods, views, indexes, and explicit persistence boundaries." +type: api-reference +tags: [js-api, datasources, offline, native] +v3_relevant: true +deprecated: false +--- +# Offline Data Source database + +`Fliplet.DataSources.Database` opens bundled Data Source data and returns local collection handles. Native collection writes change the local bundle; they do not make an online Data Source write or establish server authorization. + +## Choose the correct API + +Use [Data Source connections](../fliplet-datasources) for application reads and writes. Native connections integrate bundled reads and local acknowledgments with online operations. The lower-level `Database` API is for code that intentionally manages local bundle data. + +A native bundle and `Fliplet.Native.Maintenance` must be available. Opening a database on web fetches bundle data, but local write methods still call native file APIs. A successful web read does not establish native filesystem or synchronization behavior. Native applications must verify file persistence, incremental delivery and recovery on their supported devices. + +Offline data and cached-session views are not substitutes for [security-enabled online tests](testing-security). + +## Open a bundled Data Source + +`Fliplet.DataSources.Database(options)` returns `Promise`. + +| Option | Type | Required | Behavior | +|---|---|---|---| +| `id` | Number or String | Yes when not supplying `name` | Data Source ID; a string name requires `isName: true`. | +| `isName` | Boolean | No | With a truthy value, resolves `name || id` from the app's bundled-source metadata. Use `true` for names. | +| `name` | String | No | Alternative name when `isName: true`; it takes precedence over `id`. | +| `appId` | Number | No | Defaults to the current app ID. Selects the native app data directory. | + +The following native example assumes `Products` is bundled in the app. It reads without changing the bundle. + +```js +const database = await Fliplet.DataSources.Database({ + id: 'Products', + isName: true +}); +const products = database.dataSource(null, { name: 'Products' }); +const entries = await products.find({}, {}); +for (const entry of entries) { + console.log(entry.id, entry.data.Name); +} +``` + +An ID-based connection uses matching IDs at both stages: + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const entry = await products.findById(101); +if (entry) console.log(entry.data.Name); +``` + +The filename-string overload, such as `Database('dataSources.db')`, supports older multi-source bundles. It is a compatibility form used by the connection API. Prefer the ID/name options for dedicated bundles; do not assume a dedicated bundle includes other Data Sources. + +There is no public close/dispose method on a database handle. Do not depend on repeated native opens avoiding file reads; use the handle you already opened for related operations. + +## Select a collection + +`database.dataSource(id, options?)` returns a collection handle synchronously. + +| Argument | Type | Behavior | +|---|---|---| +| `id` | Number or numeric String | Selects a Data Source present in the loaded bundle. | +| `options.name` | String | Resolves the name within the loaded bundle's source descriptors instead of using the supplied ID. | + +Selecting an unavailable source can throw synchronously. Choose a source actually present in the opened bundle and wrap selection as well as awaited operations in error handling. + +Collection entries have metadata and a nested data object: + +```js +const exampleEntry = { id: 101, data: { Name: 'USB-C hub', Category: 'Accessories', Price: 49.99 } }; +``` + +## Read local entries + +### `find(where, options)` + +Returns `Promise>`. Both arguments should be supplied: use `{}` for an empty filter and `{}` for no options. Omitting `options` can throw synchronously. + +`where` filters the entry's `data` fields using the local Sift matcher. This API does not inherit every online query option. It does not provide pagination counts, joins, online authorization or server-side sorting. + +| Option | Type | Default | Behavior | +|---|---|---|---| +| `limit` | Number | No limit | A truthy value limits the matched entries. `0` does not limit results. | +| `views` | Array of strings | None | Applies matching named views before the filter. Multiple resolved view filters are combined with OR. | + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const affordableAccessories = await products.find({ + Category: 'Accessories', + Price: { $lte: 50 } +}, { limit: 20 }); +console.log(affordableAccessories.map(entry => entry.data.Name)); +``` + +Views resolve values from cached Data Source session data and, when available, the device UUID. Unknown view names or missing view definitions can leave results unfiltered. Treat views as local display filtering, not as an access-control boundary. See [Data Source views](views). + +### `findById(id)` + +Returns `Promise`. The filter addresses the entry's metadata `id`, not a column named `ID`. + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const product = await products.findById(101); +if (product) console.log(product.data.Name); +else console.log('Product is not in the local bundle.'); +``` + +## Write the local bundle + +Each method below changes in-memory data before writing the full JSON bundle to native storage. A file-write failure rejects the promise; it does not undo the earlier memory change. Local writes do not send server updates, validate server rules, or generate server IDs. Use [online writes](writing-data) when the server must persist the change. + +| Method | Arguments | Resolved value | +|---|---|---| +| `insert(entry)` | Complete entry object, including its ID and `data` | The supplied entry object. | +| `update(existingEntry, patch)` | A fetched collection entry and a partial entry object | The supplied patch, not the complete merged entry. | +| `remove(entryOrPredicate)` | Entry object matched by `id`, or a function returning true for entries to remove | The supplied object or predicate function, not a list of removed entries. | + +`update` deep-merges the patch into the existing entry in place. These operations are local bookkeeping; an ID supplied to `insert` does not reserve that ID on the server. + +This example deliberately changes a local copy of an existing product. Verify device persistence before using this behavior in an application workflow. + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const product = await products.findById(101); +if (!product) throw new Error('Product 101 is missing from the local bundle.'); + +const patch = { data: { Price: 44.99 } }; +const result = await products.update(product, patch); +console.log(result.data.Price); // 44.99: result is the patch +console.log(product.data.Name); // Existing fields remain on the merged entry +``` + +For predicate removal, inspect the affected rows first: + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const discontinued = await products.find({ Status: 'Discontinued' }, {}); +console.log('Local entries selected for removal:', discontinued.map(entry => entry.id)); +// Run only when the application intends to remove these local entries: +// await products.remove(entry => entry.data.Status === 'Discontinued'); +``` + +## Apply a server diff + +`collection.applyDiff(diff)` returns `Promise`. This method supports the native update pipeline; it is not an online write or a custom synchronization service. + +| Field | Type | Behavior | +|---|---|---| +| `updated` | Array of entries | Required in incremental mode. Removes existing entries with the same IDs, then appends the supplied replacements. | +| `deleted` | Array of objects with `id` | Required in incremental mode. Removes matching IDs. | +| `fullRefresh` | Boolean | When truthy, replaces the collection with `updated` or an empty array if `updated` is absent. | + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +// Example diff for a disposable native fixture, not an online server write. +await products.applyDiff({ + updated: [{ id: 101, data: { Name: 'USB-C hub', Price: 49.99 } }], + deleted: [{ id: 103 }] +}); +``` + +A full refresh is destructive to local entries absent from the supplied array. Incremental replacements do not deep-merge old entry data. + +## Read local indexes + +`indexes(columns)` returns `Promise` mapping each requested column to unique values. `index(column)` returns `Promise` for one column. + +Precomputed bundle indexes are used when present. Otherwise values are computed from the collection: array values are flattened, and falsy values such as `false`, `0`, `''`, `null` and `undefined` are omitted. A computed index is not a complete enumeration of all boolean or numeric values. + +```js +const database = await Fliplet.DataSources.Database({ id: 42 }); +const products = database.dataSource(42); +const categories = await products.index('Category'); +console.log(categories); // Example: ['Accessories', 'Lighting'] +const values = await products.indexes(['Category', 'Status']); +console.log(values.Category); +``` + +Empty collections, missing indexes and unavailable collection data can reject. A missing single-column index is not guaranteed to resolve to `[]`. Precomputed indexes are not recomputed by the mutation methods described above; do not assume they reflect a subsequent local edit. + +## Errors and recovery + +| Operation | Failure behavior | +|---|---| +| Open by name | Rejects when the name cannot be resolved from the app's bundled-source metadata. | +| Open native file | Rejects for missing data or invalid JSON, and propagates native read failures. | +| Select a collection | Can throw immediately if the source is absent from the loaded bundle. | +| Read/write invalid collection | Rejects when collection entries are unavailable. `find` can also throw when options are omitted. | +| Persist a local mutation | Rejects native write failures after the memory change. | +| Read an index | Rejects unavailable or empty index results as described above. | + +Error messages are localized; do not match their English text for application control flow. When a local write fails, report the failure and reload authoritative data through the application's established update flow rather than treating the in-memory change as durable. + +[Data Sources API](../fliplet-datasources) · [Subscriptions](subscriptions) · [Testing security](testing-security) diff --git a/docs/API/datasources/query-operators.md b/docs/API/datasources/query-operators.md index 4c888602..102ed006 100644 --- a/docs/API/datasources/query-operators.md +++ b/docs/API/datasources/query-operators.md @@ -20,10 +20,10 @@ Fliplet Data Sources uses [Sift.js](https://github.com/Fliplet/sift.js) which pr |----------|-------------|---------|-------| | `$eq` | Equal to | `{ age: { $eq: 25 } }` | Can be simplified to `{ age: 25 }` | | `$ne` | Not equal to | `{ status: { $ne: 'inactive' } }` | | -| `$gt` | Greater than | `{ score: { $gt: 80 } }` | Optimized for performance | -| `$gte` | Greater than or equal | `{ score: { $gte: 80 } }` | Optimized for performance | -| `$lt` | Less than | `{ age: { $lt: 30 } }` | Optimized for performance | -| `$lte` | Less than or equal | `{ age: { $lte: 30 } }` | Optimized for performance | +| `$gt` | Greater than | `{ score: { $gt: 80 } }` | | +| `$gte` | Greater than or equal | `{ score: { $gte: 80 } }` | | +| `$lt` | Less than | `{ age: { $lt: 30 } }` | | +| `$lte` | Less than or equal | `{ age: { $lte: 30 } }` | | ```js // Examples @@ -43,7 +43,7 @@ const highScores = await connection.find({ | Operator | Description | Example | Notes | |----------|-------------|---------|-------| -| `$in` | Value in array | `{ category: { $in: ['tech', 'science'] } }` | Optimized for performance | +| `$in` | Value in array | `{ category: { $in: ['tech', 'science'] } }` | | | `$nin` | Value not in array | `{ status: { $nin: ['banned', 'suspended'] } }` | | | `$all` | Array contains all values | `{ tags: { $all: ['urgent', 'review'] } }` | For array fields | | `$size` | Array size equals | `{ items: { $size: 3 } }` | For array fields | @@ -66,8 +66,8 @@ const completeTasks = await connection.find({ | Operator | Description | Example | Notes | |----------|-------------|---------|-------| -| `$and` | Logical AND | `{ $and: [{ age: { $gte: 18 } }, { status: 'active' }] }` | Optimized for performance | -| `$or` | Logical OR | `{ $or: [{ role: 'admin' }, { role: 'moderator' }] }` | Optimized for performance | +| `$and` | Logical AND | `{ $and: [{ age: { $gte: 18 } }, { status: 'active' }] }` | | +| `$or` | Logical OR | `{ $or: [{ role: 'admin' }, { role: 'moderator' }] }` | | | `$nor` | Logical NOR | `{ $nor: [{ status: 'banned' }, { status: 'suspended' }] }` | None of the conditions | | `$not` | Logical NOT | `{ age: { $not: { $lt: 18 } } }` | Negates the condition | @@ -98,8 +98,8 @@ const activeUsers = await connection.find({ | Operator | Description | Example | Notes | |----------|-------------|---------|-------| -| `$regex` | Regular expression | `{ email: { $regex: /@company\.com$/i } }` | Case-insensitive with `i` flag | -| `$iLike` | Case-insensitive partial match | `{ name: { $iLike: 'john' } }` | Fliplet-specific, optimized | +| `$regex` | Regular expression | `{ email: { $regex: '@company\\.com$', $options: 'i' } }` | Case-insensitive with `i` flag | +| `$iLike` | Case-insensitive partial match | `{ name: { $iLike: 'john' } }` | Fliplet-specific | ```js // Example @@ -111,10 +111,12 @@ const johnUsers = await connection.find({ ### Existence and Type Operators +Online queries use JSON. A JavaScript `RegExp` object becomes `{}` during serialization; use a string `$regex` and `$options` instead. The bundled `$type` implementation expects constructors, which are not JSON-safe, rather than strings such as `number`. + | Operator | Description | Example | Notes | |----------|-------------|---------|-------| | `$exists` | Field exists | `{ phone: { $exists: true } }` | Checks if field is present | -| `$type` | Field type check | `{ score: { $type: 'number' } }` | Types: string, number, boolean, array, object | +| `$type` | Not supported as an online JSON type-name filter | — | Do not pass string type names or constructors in online queries. | ```js // Examples @@ -122,9 +124,7 @@ const usersWithPhone = await connection.find({ where: { phone: { $exists: true } } }); -const numericScores = await connection.find({ - where: { score: { $type: 'number' } } -}); +// Validate field types in application code; $type is not a JSON type-name filter. ``` ### Mathematical Operators @@ -134,7 +134,7 @@ const numericScores = await connection.find({ | `$mod` | Modulo operation | `{ id: { $mod: [2, 0] } }` | `[divisor, remainder]` | ```js -// Examples - find even IDs +// Find even values in the data column named id (not record metadata IDs) const evenIds = await connection.find({ where: { id: { $mod: [2, 0] } } }); @@ -164,7 +164,7 @@ const studentsWithGoodGrades = await connection.find({ ## Fliplet Custom $filters Operator -Fliplet provides a custom `$filters` operator that offers optimized performance and additional conditions not available in standard MongoDB operators. +Fliplet provides a custom `$filters` operator for named filter conditions. Filters are combined with AND. Online database optimization depends on the expression and input values; nested filters may be evaluated in memory. ### Syntax @@ -194,6 +194,10 @@ Fliplet provides a custom `$filters` operator that offers optimized performance | `<` | Less than | Number | `{ column: 'Price', condition: '<', value: 100 }` | | `<=` | Less than or equal | Number | `{ column: 'Quantity', condition: '<=', value: 50 }` | | `contains` | Case-insensitive partial match | String | `{ column: 'Email', condition: 'contains', value: '@company.com' }` | +| `notcontain`, `notcontains` | Excludes matching text; missing values do not count as a negative match | String | `{ column: 'Email', condition: 'notcontains', value: '@example.com' }` | +| `regex` | Regular-expression string | String | `{ column: 'Email', condition: 'regex', value: '@example[.]com$' }` | +| `notoneof`, `notin` | Excludes values from a list | Array/String | `{ column: 'Status', condition: 'notin', value: ['Archived'] }` | +| `none` | Inactive filter; matches all rows | None | `{ column: 'Status', condition: 'none' }` | | `empty` | Field is empty | None | `{ column: 'Notes', condition: 'empty' }` | | `notempty` | Field is not empty | None | `{ column: 'Description', condition: 'notempty' }` | | `between` | Numeric range (inclusive) | Object | `{ column: 'Age', condition: 'between', value: { from: 18, to: 65 } }` | @@ -213,7 +217,7 @@ Fliplet provides a custom `$filters` operator that offers optimized performance For date conditions, you can optionally specify a unit of comparison: ```js -{ +const birthdayFilter = { column: 'Birthday', condition: 'dateis', value: '1990-01-01', @@ -287,12 +291,12 @@ const getFilteredUsers = async (filters = {}) => { }; // Usage with destructuring -const { length: userCount, ...users } = await getFilteredUsers({ +const users = await getFilteredUsers({ minAge: 25, departments: ['Engineering', 'Design'] }); -console.log(`Found ${userCount} users matching criteria`); +console.log(`Found ${users.length} users matching criteria`); ``` --- @@ -301,43 +305,11 @@ console.log(`Found ${userCount} users matching criteria`); ### Optimized Operators -The following operators are optimized for better performance with Fliplet's database: - -**MongoDB-style (optimized):** -- `$or`, `$and`, `$gte`, `$lte`, `$gt`, `$lt`, `$eq`, `$in` - -**Fliplet $filters (optimized):** -- `==`, `contains` (especially optimized) - -**Optimized Value Types:** -- Strings and numbers perform better than complex objects +Some simple `$or`, `$and`, comparison and `$in` expressions can be evaluated by the online database. Supported `$filters` conditions can also be promoted when their structure and values permit. Other expressions use in-memory filtering. Operator choice alone does not guarantee a particular execution plan or faster query. ### Best Practices -1. **Use $filters for complex conditions** - Better performance than equivalent MongoDB operators -2. **Prefer optimized operators** - Use `$gte` instead of `$not: { $lt: value }` -3. **Index-friendly queries** - Simple equality and range queries perform best -4. **Combine efficiently** - Use `$and` for multiple conditions on different fields - -```js -// Good - optimized query -const optimized = await connection.find({ - where: { - $filters: [ - { column: 'Status', condition: '==', value: 'Active' }, - { column: 'Score', condition: '>=', value: 80 } - ] - } -}); - -// Also good - using optimized MongoDB operators -const mongoOptimized = await connection.find({ - where: { - Status: 'Active', - Score: { $gte: 80 } - } -}); -``` +Use the operator that expresses the required result. Add a positive `limit` for bounded reads and verify representative data and result counts. Combining filters does not turn a caller-controlled query into an access rule. --- @@ -387,34 +359,26 @@ const userData = await getComplexUserData({ ## Using operators in custom security rules -When writing [custom security rules](/Data-source-security#custom-security-rules), you can query other Data Sources using the `DataSources` server-side library. The `find` and `findOne` methods support the same query operators listed above. +The `DataSources(idOrName)` library inside [custom security scripts](security-rules#custom-scripts) has a separate lookup contract. Its `find(options?)` returns flat data objects, not SDK `{ id, data }` records; `findOne(options?)` returns one flat object or `undefined`. + +| Property | Type / default | Behavior | +|---|---|---| +| `where` | Object; omitted | Filters data fields. Scalar operator operands are restricted to `$or`, `$and`, `$gt`, `$gte`, `$lt`, `$lte`, `$ne`, `$not`, `$like`, `$iLike`, `$notLike`, `$notILike`, `$eq`, `$contains`. Do not assume the app Sift operators or `$filters` work here. | +| `limit` | Number; at most `100` | Maximum lookup records; `findOne` sets it to `1`. | +| `offset` | Number; omitted | Skip records. | ```js -// Custom security rule: grant access if user is a manager in the same office -if (type === 'select') { - var entry = await DataSources(123).findOne({ - where: { - Office: user.Office, - Managers: { $in: [user.Email] }, - Status: { $ne: 'Inactive' } - } +// Security script: user is a flat login record and may be absent. +if (user && user.Office && user.Email) { + const manager = await DataSources(123).findOne({ + where: { Office: user.Office, Email: user.Email, Status: 'Active' } }); - - if (entry) { - return { granted: true }; - } + if (manager) { return { granted: true }; } } +return { granted: false }; ``` -Both `find` and `findOne` accept: - -| Property | Type | Default | Description | -|---|---|---|---| -| `where` | Object | — | Query filter using any operators from this page | -| `limit` | Number | `100` | Maximum number of records to return | -| `offset` | Number | `0` | Number of records to skip | - -

    The operators on this page are for querying data. The require property in security rules uses a different set of requirement types (equals, notequals, contains) to validate incoming queries — see data requirements and query validation.

    +Array operands and complex nested expressions must be checked against this lookup contract independently; the whitelist is not a promise of full MongoDB compatibility. A missing or inaccessible source can throw. The rule `require` property uses requirement types such as `equals`, `notequals` and `contains`, described in [security rules](security-rules); it does not use this query operator table. [Back to Data Sources Documentation](../fliplet-datasources) -{: .buttons} \ No newline at end of file +{: .buttons} diff --git a/docs/API/datasources/reading-data.md b/docs/API/datasources/reading-data.md new file mode 100644 index 00000000..87283ec5 --- /dev/null +++ b/docs/API/datasources/reading-data.md @@ -0,0 +1,163 @@ +--- +title: Reading Data Source records +description: "Read records, filter and sort queries, paginate with cursors, and retrieve distinct column values with Fliplet.DataSources." +type: api-reference +tags: [js-api, datasources, query] +v3_relevant: true +deprecated: false +--- +# Reading Data Source records + +A Data Source connection reads records with `find`, `query`, `findOne` and `findById`. Query options control filtering and result shape; source permissions and [security rules](security-rules) control access. + +The examples use an existing source with numeric ID `123`. Replace this ID with the source attached to the app. Online calls can reject for inaccessible sources, invalid input, access rules or transport failures. Studio sample data is not evidence that app-user access works; verify in [preview with security enabled](testing-security). + +## Record shape + +Normal query results are arrays of records with `id` and `data`. Metadata such as `order`, `createdAt` and `updatedAt` may also be present. Aggregation can change this shape. + +```js +// Example record, not an insert payload +const record = { id: 41, data: { Name: 'Mina', Department: 'Design', Score: 86 } }; +console.log(record.id, record.data.Name); +``` + +## find and query + +`connection.find(query?, options?)` returns a Promise. `connection.query(query?, options?)` is an alias with the same arguments. Both default their arguments to `{}`. For online query updates and deletion, see [writing records](writing-data). + +| Query property | Type / default | Behavior | +|---|---|---| +| `where` | Object; omitted | Filters fields within `record.data`; see [query operators](query-operators). An omitted filter selects all accessible records. | +| `id`, `createdAt`, `updatedAt` | Number/date or query object; omitted | Online filters on record metadata, outside `where`. For incremental reads, `updatedAt` can also return deleted-entry metadata in an object response. Not applied by native local reads. | +| `dataSourceEntryId` | Number; omitted | Online filter on the record's metadata ID. `where: { id: ... }` instead filters a data column named `id`. | +| `type` | String; omitted | Omitted or `select` reads records. Online `update` and `delete` are writes; see [writing records](writing-data). Native local reads do not implement the online query-update path. | +| `data` | Object; omitted | Patch for online `type: 'update'`. Do not supply this for ordinary reads. | +| `attributes` | String[]; omitted | Returns the listed data fields, subject to security field restrictions. Does not change metadata ID. | +| `limit` | Number; omitted | Maximum records. Supply a positive integer; `0` is treated as omitted rather than an empty page. Online negative or nonnumeric values reject; fractions are rounded down. | +| `offset` | Number; omitted | Skip records. Use a nonnegative integer; online negative or nonnumeric values reject. | +| `order` | Array of pairs; online default `[['order', 'ASC'], ['id', 'DESC']]` | See [sorting](#sorting-and-ordering). Native local queries do not apply the same online ordering pipeline. | +| `includePagination` | Boolean; omitted | Online literal `true` returns the root response with `entries` and `pagination`. Native local reads still return an array. | +| `distinct` | String; omitted | Online retains the first row for each value of the named data column, after pagination. Other fields come from that row. Not applied by native local reads. | +| `views` | String[]; omitted | Requests named [views](views); use this plural array form. Views do not replace security rules. | +| `join` | Object; omitted | Named [joins](joins). Online and native implementations differ; verify the target platform. | +| `aggregate` | Array; omitted | Mingo aggregation pipeline applied to records. Stage paths refer to record properties such as `data.Score`. Native execution requires Mingo to be loaded. | + +| Request option | Type / default | Behavior | +|---|---|---| +| `cache` | API cache option; omitted | Forwarded to the online API request; use `false` when fresh verification is needed. Native reads use the loaded local database. | +| `authenticate` | Boolean; true when `Fliplet.Page` exists | Applies SDK entry authentication in supported paths. This is not a switch for server security enforcement. Delete queries turn it off. | +| `returnAsObject` | Boolean; omitted | Online returns `{ entries, deletedEntries }`, taking precedence over `includePagination`. Native reads still return an array. | +| `ack` | Boolean; omitted | Relevant to query deletion and native reconciliation; see [writing records](writing-data). | + +```js +const connection = await Fliplet.DataSources.connect(123); +const records = await connection.find({ + where: { Department: 'Design', Score: { $gte: 80 } }, + attributes: ['Name', 'Score'], + limit: 20, + order: [['data.Score', 'DESC']] +}, { cache: false }); +records.forEach(function(record) { console.log(record.id, record.data.Name); }); +``` + +### Pagination results + +Online `includePagination: true` returns `{ entries, dataSourceId, count, source, pagination, indexes, deletedEntries }`. Some optional properties may be absent. `count` is the returned array length. `pagination` contains `total`, `limit` and `offset`; omitted limits/offsets may be absent. `total` is calculated before distinct and required-join filtering, so it can exceed the final returned record count. + +```js +const page = await connection.find({ limit: 20, offset: 0, includePagination: true }); +console.log(page.entries.length, page.pagination.total); +``` + +Do not combine this envelope with `findOne` or `findWithCursor`, which expect array-producing queries. `returnAsObject` produces a different envelope without pagination metadata. + +## findOne + +`connection.findOne(query?, options?)` sets `query.limit` to `1` and resolves the first record or `undefined`. It modifies the supplied query object. With online `returnAsObject: true`, it resolves `{ entries: recordOrUndefined, deletedEntries }`; `entries` is a single record here, not an array. Avoid combining `returnAsObject` with native local reads. + +```js +const record = await connection.findOne({ where: { Email: 'mina@example.com' } }); +if (record) { console.log(record.id, record.data.Email); } +``` + +## findById + +`connection.findById(id)` resolves a single record. Online missing records reject with a 404 response and invalid IDs reject with a 400 response. Native local lookup can resolve `undefined`. The authenticated `session` identifier is reserved for session lookup; use numeric record IDs for ordinary reads. This method has no second request-options argument. + +```js +try { + const record = await connection.findById(41); + if (record) { console.log(record.data.Name); } +} catch (error) { + // Handle missing, denied or failed online requests in the app UI. + console.error(error); +} +``` + +## Sorting and ordering + +Top-level `order` is an array of `[column, direction]` pairs. Metadata columns are `id`, `order`, `createdAt`, `deletedAt` and `updatedAt`. Data columns use a `data.` prefix and must exist on the source. Use `ASC` or `DESC`; online data-column directions are normalized to uppercase and reject other values. Data-column numeric-looking values are sorted numerically, while other values use text ordering. A join's `order` instead takes one flat pair. + +```js +const records = await connection.find({ + order: [['data.Department', 'ASC'], ['data.Score', 'DESC']] +}); +``` + +## findWithCursor + +`connection.findWithCursor(query?, options?)` resolves an array with cursor properties and methods after fetching its first page. Supply a positive integer `limit` and an array-producing query. Cursor movement changes the query synchronously; `update()` performs the next read. + +| Cursor member | Behavior | +|---|---| +| `query` | Mutable query; pagination is `cursor.query.limit` / `cursor.query.offset`, not `cursor.limit` / `cursor.offset`. | +| `currentPage` | Zero-based page index, initially `0`. | +| `isFirstPage`, `isLastPage` | Flags updated during movement/fetch. Last-page detection fetches one extra record. | +| `firstRecordInNextPage` | Extra fetched record when another page exists; otherwise undefined. | +| `idSet`, `containsId(id)` | Set of fetched IDs and membership check. | +| `next()`, `prev()`, `setPage(page)` | Mutate page/offset and return the cursor. They do not fetch. Negative page/offset is clamped to zero. | +| `update({ keepExisting? })` | Promise resolving the cursor. Default replaces its rows and ID set. `keepExisting: true` appends rows without deduplicating overlapping pages. | + +```js +const cursor = await connection.findWithCursor({ + limit: 20, order: [['id', 'ASC']] +}); +console.log(cursor.length, cursor.query.offset); +if (!cursor.isLastPage) { + await cursor.next().update(); + console.log(cursor.currentPage, cursor.map(function(record) { return record.id; })); +} +``` + +## Column indexes + +`connection.getIndex(column)` resolves an array of distinct values. `connection.getIndexes(columns)` resolves an object mapping each requested column to its values. These source-wide lookups do not accept the `where`, views, pagination or request options of `find`; do not use them to infer the values visible to a filtered user. Verify returned values separately when configuring security. + +| Argument | Type | Required | +|---|---|---| +| `column` | String | Yes for `getIndex`. | +| `columns` | String[] | Yes for `getIndexes`; use a nonempty array. | + +Online values are extracted as text, with missing/null values represented as `null`; for example numeric `12` can return `'12'`. When the source declares columns, requesting an undeclared column rejects with a 400 response. Native indexes may use precomputed values or scan local rows; computed native indexes skip falsy values, including `false`, `0` and `''`. See [offline database](offline-database). + +```js +const departments = await connection.getIndex('Department'); +const indexes = await connection.getIndexes(['Department', 'Status']); +console.log(departments, indexes.Department, indexes.Status); +``` + +## Aggregation + +Aggregation operates on records, so field paths include `data.`. The result follows the supplied pipeline rather than guaranteeing `{ id, data }` records. Invalid pipelines can reject the query. + +```js +const summary = await connection.find({ + aggregate: [ + { $group: { _id: '$data.Department', count: { $sum: 1 } } } + ] +}); +console.log(summary); // [{ _id: 'Design', count: ... }, ...] +``` + +[Data Sources API](../fliplet-datasources) diff --git a/docs/API/datasources/subscriptions.md b/docs/API/datasources/subscriptions.md new file mode 100644 index 00000000..116d2412 --- /dev/null +++ b/docs/API/datasources/subscriptions.md @@ -0,0 +1,203 @@ +--- +title: Data Source subscriptions +description: "Subscribe to Data Source changes, handle entry payloads, and stop monitoring when a screen is hidden or removed." +type: api-reference +tags: [js-api, datasources, subscriptions] +v3_relevant: true +deprecated: false +--- +# Data Source subscriptions + +`connection.subscribe()` delivers changed entries to a callback. Online connections poll for changes; native connections with a working offline database receive incremental-update events. + +## Before subscribing + +The app must support incremental updates. `subscribe()` throws synchronously when incremental updates are disabled or the callback is missing. Establish a [Data Source connection](../fliplet-datasources) first. + +A subscription reports changes after registration, not the initial dataset. Read the initial rows separately. For [authorization tests](testing-security), use an online connection and direct reads or writes as the signed-in actor; a subscription callback is not proof of server authorization. + +## Register and clean up + +Both signatures return a subscription handle immediately: + +```js +connection.subscribe(callback); +connection.subscribe(options, callback); +``` + +This example logs changes and returns a cleanup function. The app must have incremental updates enabled and access to a Data Source named `Products`. + +```js +async function watchProducts() { + if (!Fliplet.App.supportsIncrementalUpdates()) { + throw new Error('Enable incremental updates before subscribing.'); + } + + const connection = await Fliplet.DataSources.connectByName('Products', { + offline: false + }); + + const subscription = connection.subscribe((changes) => { + for (const entry of changes.inserted || []) { + console.log('Inserted product:', entry.id, entry.data.Name); + } + for (const entry of changes.updated || []) { + console.log('Updated product:', entry.id, entry.data.Name); + } + for (const entry of changes.deleted || []) { + console.log('Deleted product:', entry.id); + } + }); + + function onVisibilityChange() { + if (document.hidden && subscription.status() === 'active') { + subscription.pause(); + } else if (!document.hidden && subscription.status() === 'paused') { + subscription.resume(); + } + } + + document.addEventListener('visibilitychange', onVisibilityChange); + onVisibilityChange(); + + return function stopWatching() { + document.removeEventListener('visibilitychange', onVisibilityChange); + subscription.unsubscribe(); + }; +} + +const stopWatchingProducts = await watchProducts(); +// Call stopWatchingProducts() when the screen is removed. +``` + +Change a product from another session to observe a callback. Call the returned cleanup function when leaving the screen; placing that call immediately after registration would end the subscription before it can deliver changes. + +## Options and callback results + +| Option | Type | Default | Behavior | +|---|---|---|---| +| `events` | Array of strings | `['insert', 'update', 'delete']` | Selects insert and update notifications. Deletion entries can still be delivered with an event subset; callbacks must handle or ignore `deleted` explicitly. | +| `cursor` | Cursor handle | None | Limits delivered entries to IDs contained in that cursor. See [reading data](reading-data). | +| `filter` | Function | None | Called with an entry object. Returning a falsy value omits the entry from the callback. | + +For ID or field matching, use a filter function. Object-valued `filter` is not applied to callback delivery. The SDK also recognizes an `id` option, but cleanup of ID-scoped subscriptions is unreliable; use the function form instead. + +```js +const connection = await Fliplet.DataSources.connectByName('Products', { + offline: false +}); +const subscription = connection.subscribe({ + filter: entry => entry.id === 101 || (entry.data && entry.data.Category === 'Accessories') +}, changes => console.log(changes)); +// Call subscription.unsubscribe() on screen teardown. +``` + +The callback receives an object with any of these nonempty arrays: + +```js +const exampleChanges = { + inserted: [{ id: 101, data: { Name: 'USB-C hub' }, createdAt: '...', updatedAt: '...' }], + updated: [{ id: 102, data: { Name: 'Desk lamp' }, createdAt: '...', updatedAt: '...' }], + deleted: [{ id: 103 }] +}; +``` + +Keys with no matching entries are omitted. An empty change set does not trigger the callback. An updated entry is classified as an insert when its `createdAt` and `updatedAt` values are equal; otherwise it is classified as an update. Deleted entries may contain less data than other entries, so ID-based removal does not require `entry.data`. + +## Subscription handle + +These methods are synchronous and do not return promises. + +| Method | Result | Behavior | +|---|---|---| +| `status()` | `'active'` or `'paused'` | Reports the handle's current monitoring status. | +| `pause()` | `undefined` | Stops delivery to this subscription. Monitoring stops when no active subscriptions remain on its connection. | +| `resume()` | `undefined` | Makes the subscription active. When it restarts online monitoring, a check is scheduled immediately. | +| `unsubscribe()` | `undefined` | Removes the subscription. Monitoring stops when no active subscriptions remain. | +| `updateOptions(options)` | `undefined` | Replaces the options object; it does not merge options. | + +`unsubscribe()` does not change `status()` to an “unsubscribed” value. Treat disposal as final and create a new subscription to subscribe again. The exposed `isInScope(entry)` helper has different matching behavior from callback delivery; use the options above for application filtering rather than treating that helper as a delivery guarantee. + +```js +subscription.updateOptions({ + events: ['update'], + filter: entry => !!entry.data && entry.data.Category === 'Accessories' +}); +``` + +This replaces earlier options, including an earlier cursor or filter. The callback remains the one supplied at registration. + +## Delivery timing and usage + +Online monitoring waits 30 seconds between completed checks. Network time, failures and browser timer scheduling add to that interval. There is no public subscription option for changing the interval, and no guarantee of instant notification. + +Subscriptions on a reused connection share monitoring. Pausing one subscription does not stop requests needed by another active subscription on that connection. Resuming while shared monitoring is already active does not force an immediate check or guarantee delivery of every change from the paused period. + +Native connections with a working offline database use incremental-update events instead of the online polling timer. If the local database cannot be loaded, the connection falls back to online monitoring. Pausing and resuming does not make the native update mechanism fetch on demand. + +Keep visibility cleanup even when the UI framework unmounts screens correctly. Avoid adding a second polling timer unless the application has a separately established need; it creates additional reads. Polling failures are logged and monitoring schedules another check; the subscription API has no error-callback parameter. + +## Framework lifecycle integration + +### Vue + +Use the plain JavaScript `watchProducts()` function above. Handle the case where setup finishes after the component has already unmounted. + +```js +import { onMounted, onUnmounted } from 'vue'; + +let stopWatching; +let disposed = false; + +onMounted(async () => { + try { + const stop = await watchProducts(); + if (disposed) stop(); + else stopWatching = stop; + } catch (error) { + console.error('Cannot subscribe to products:', error); + } +}); + +onUnmounted(() => { + disposed = true; + if (stopWatching) stopWatching(); +}); +``` + +### React + +Use the same plain JavaScript setup function inside an effect. + +```js +import { useEffect } from 'react'; + +function useProductSubscription() { + useEffect(() => { + let disposed = false; + let stopWatching; + + watchProducts().then(stop => { + if (disposed) stop(); + else stopWatching = stop; + }).catch(error => console.error('Cannot subscribe to products:', error)); + + return () => { + disposed = true; + if (stopWatching) stopWatching(); + }; + }, []); +} +``` + +## Troubleshooting + +| Symptom | Check | +|---|---| +| Registration throws | Confirm incremental updates are enabled and supply a callback function. | +| No initial rows appear | Load the initial dataset with a read; registration does not return current rows. | +| No callback arrives | Confirm access, active status and filter/cursor scope. Allow for online polling or native incremental delivery. | +| Checks continue after hiding a screen | Another active subscription may share the connection. Dispose each screen's handle and visibility listener. | +| A deletion appears despite `events: ['update']` | Ignore unwanted `deleted` entries in the callback; the subset does not suppress deletion delivery. | + +[Data Sources API](../fliplet-datasources) · [Offline database](offline-database) diff --git a/docs/API/datasources/views.md b/docs/API/datasources/views.md index 318595ca..94345b79 100644 --- a/docs/API/datasources/views.md +++ b/docs/API/datasources/views.md @@ -1,6 +1,6 @@ --- title: Data Source views -description: "Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them." +description: "Request named filters that bind to the current login session. Views filter query results; access rules must enforce privacy independently." type: api-reference tags: [js-api, datasources, views] v3_relevant: true @@ -8,7 +8,7 @@ deprecated: false --- # Data Source views -Define named, session-aware filters on a data source so each user or group sees only the rows that apply to them. +Request named filters that bind to the current login session. Views filter query results; access rules must enforce privacy independently. Example use cases: @@ -22,7 +22,7 @@ A view must be defined in the **data source definition JSON**, which can be edit Each view must define a `name` and the `filter`. You can also specify whether the view must be bundled to the app (defaults to `false`). - `name`: *(required)* the name of view. -- `filter`: *(required)* an object which is passed through the **Sift.js** query engine to filter the data source. The value of each attribute can be a literal value or a string to read from a target property in the user's connected session, e.g. `session.foo`, which most likely reads from a data source entry depending on how your app's login is set up. +- `filter`: *(required)* an object which is passed through the **Sift.js** query engine to filter the data source. Primitive filter values are context property paths, such as `session.EmailAddress`, resolved from the connected login session. They are not literal constants: `"Yes"` does not mean a literal Yes comparison. Use an ordinary `where` filter for static conditions. Missing session fields resolve to undefined; require and test the expected login session. - `bundle` *(optional)* a boolean defaulting to `false` defining whether the view should be bundled for offline use in your apps. ```json @@ -32,7 +32,6 @@ Each view must define a `name` and the `filter`. You can also specify whether th "name": "userBookmarks", "bundle": true, "filter": { - "IsBookmark": "Yes", "UserEmail": "session.EmailAddress" } } @@ -42,13 +41,22 @@ Each view must define a `name` and the `filter`. You can also specify whether th ## Querying data for a specific view -Both DataSources **JS APIs** and **REST APIs** allow you to request data for a specific view by defining its name through the `view` property. You can also provide an **array** of `views` if you prefer to extract more at once. +Request one or more view names with the plural `views` array. Use this form rather than the singular `view` property. Multiple resolved views are combined with OR, so a row matching any requested view can be returned. Unknown names are omitted during lookup; they do not establish a security boundary. ```js Fliplet.DataSources.connect(123).then(function (connection) { // Only extract bookmarks for the current user return connection.find({ - view: 'userBookmarks' + views: ['userBookmarks'], + where: { IsBookmark: 'Yes' } }) }).then(console.log) -``` \ No newline at end of file +``` + +## Security and offline use + +Views are explicitly requested query filters. An app can omit the request or change it; configure [security rules](security-rules) for enforced read restrictions and test both filtered and unfiltered calls. A successful view query is not proof that another user's rows are inaccessible. + +`bundle: true` requests bundling for native offline use; it does not authorize server access or revoke already downloaded rows. Verify session bindings and the actual bundled data on the target platform. + +[Reading records](reading-data) · [Testing security](testing-security) diff --git a/docs/API/datasources/writing-data.md b/docs/API/datasources/writing-data.md new file mode 100644 index 00000000..0dc7fd82 --- /dev/null +++ b/docs/API/datasources/writing-data.md @@ -0,0 +1,229 @@ +--- +title: Writing Data Source records +description: "Insert, update, delete and commit Data Source records with the correct input shapes, options, write outcomes and security limits." +type: api-reference +tags: [js-api, datasources, writes] +v3_relevant: true +deprecated: false +--- +# Writing Data Source records + +Data Source connections write individual records or batches. This reference describes request shapes, results and the difference between a server response, a queued submission and a native local change. + +## Before you start + +Load the [Data Sources library](../fliplet-datasources.md) and configure [access rules](../../Data-source-security.md) for the intended app identity. Use a disposable source for replacement and deletion examples. An administrative request or native cached result does not establish app-user authorization. + +The examples use a source named **Equipment requests** with `Item`, `Quantity` and `Status` columns. Column names do not impose a typed schema. Read IDs from actual returned records rather than guessing them. + +For a write that must receive an online server response, open the connection with `{ offline: false }`. Set `insert`'s separate `{ offline: false }` option to prevent a failed submission from being queued. Leave `update`'s `allowOffline` option false. A rejected request can still follow a server-side effect, so inspect persisted state before retrying a write. + +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); + +try { + const created = await requests.insert({ + Item: 'Portable monitor', + Quantity: 1, + Status: 'Requested' + }, { offline: false }); + + if (!created || !created.id || !created.data) { + throw new Error('The submission did not return a server record.'); + } + + const saved = await requests.findById(created.id); + console.log('Persisted request:', saved.id, saved.data); +} catch (error) { + console.error('Check the response and persisted state before retrying:', error); +} +``` + +The read-back requires read permission. A successful write can still be followed by a denied read; record those outcomes separately. For files, use the [attachment reference](file-attachments.md). + +## Individual writes + +### insert(data, options?) + +`connection.insert(data, options?)` submits a new record. `data` is a flat object of column values or a `FormData` object for attachments. It is not a `{ data: ... }` record envelope. Missing/falsy `data` throws synchronously; validation and network failures occur asynchronously. + +| Option | Type | Default | Behavior | +|---|---|---|---| +| `offline` | Boolean | Unspecified | `false` prevents queueing a failed submission. Other values allow the insertion queue where the input can be stored. This differs from the connection's native database option. | +| `ack` | Boolean | Unspecified | Attempts native local reconciliation after a server result. Native database connections also attempt reconciliation automatically. Single-write reconciliation requires a working native database connection; `ack: true` does not enable it on an `offline: false` connection. It does not establish server persistence. | +| `folderId` | Number | Unspecified | Target media folder for uploaded attachments. | +| `flUserToken` | String | Unspecified | Existing Fliplet user-token context forwarded with the request. It is not an app role or arbitrary identity claim. | +| `public` | Boolean | Unspecified | `true` requests a shared-content slug. Requires an app context and the appropriate shared-content configuration. It does not grant Data Source permissions. | +| `source` | String | Unspecified | Context forwarded to server hooks. It is caller-supplied, not trusted attribution. | + +Possible resolved outcomes: + +| Outcome | Shape or observation | Meaning | +|---|---|---| +| Online server record | `{ id, dataSourceId, data, ... }` | The server returned the saved entry. Fields can be transformed, excluded or redacted. | +| Native pending record | `{ id: 'temp_...', data, _pending: true, _tempId: 'temp_...', ... }` | A local optimistic entry awaits a server result and ID reconciliation. | +| Stored queued submission | Result of local queue storage; not guaranteed to contain `id` or `data` | The input was accepted locally after a failed send. It is not a saved server entry. | + +Development sample mode returns the submitted flat data rather than a server entry. Do not use that mode to verify persistence or security. + +### update(id, data, options?) + +`connection.update(id, data, options?)` updates an existing record. Online single-record updates shallowly merge the submitted column values into stored `data`; omitted fields remain and `null` is a value. Nested objects are replaced as values rather than deeply merged. + +| Argument or option | Type | Default | Behavior | +|---|---|---|---| +| `id` | Number or `'session'` | Required | Existing entry ID. `'session'` targets the authenticated row for this source when that login exists. | +| `data` | Object or FormData | Required | Flat column values or attachment input. | +| `allowOffline` | Boolean | False when omitted | Requests required-request handling and, on request failure, resolves an `{ id, dataSourceId, data, offline: true }` result instead of rejecting. It does not prove the server accepted the update. | +| `ack` | Boolean | Unspecified | Attempts local reconciliation only when native database support is active on the connection; also automatic for those connections. `ack: true` does not override `offline: false`. | +| `folderId` | Number | Unspecified | Media folder for attachments. | +| `public` | Boolean | Unspecified | `true` requests shared-content slug creation where supported. | +| `source` | String | Unspecified | Caller-supplied server-hook context. | + +An ordinary online success resolves the server entry. Native reconciliation can return a local update result rather than the same complete envelope. Development sample mode resolves without a record. Read the saved entry separately when the complete persisted state matters. + +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const pending = await requests.findOne({ where: { Status: 'Requested' } }); + +if (pending) { + await requests.update(pending.id, { Status: 'Collected' }); + const saved = await requests.findById(pending.id); + console.log('Stored status:', saved.data.Status); +} +``` + +Object update requirements evaluate stored rows. They do not validate every proposed transition. Read [operation-specific security](security-rules.md#requirements-by-operation) before using a status field as an approval workflow. + +### removeById(id, options?) + +`connection.removeById(id, { ack? })` deletes one entry. A missing/falsy ID throws synchronously. An online missing entry rejects with HTTP 404; authorization and source permission failures can reject with HTTP 400. + +Online success resolves `{}`. With native reconciliation enabled by `ack` or the connection, the result can instead be the removed local entry. Development sample mode resolves without a value. No `connection.remove()` method is provided by the online connection; the [native collection API](offline-database.md) is separate. + +## Writes selected by a query + +`connection.query(data, options?)` is an alias for `find`. For writes, `data.type` selects `update` or `delete`. These calls require query admission and permission for the requested write operation; they are distinct from single-record writes. + +| Field | Type | Behavior | +|---|---|---| +| `type` | String | `'update'` or `'delete'`. | +| `where` | Object | Filters data-column values. An omitted or broad filter can affect the whole source. | +| `data` | Object | Required for update; flat patch shallowly merged into each matched row. | +| `options.returnAsObject` | Boolean | Returns `{ entries, deletedEntries }`; `deletedEntries` is not the list of IDs deleted by this request. | +| `options.ack` | Boolean | For query deletion, attempts removal of returned deleted IDs from a supported local database. | + +An online update normally resolves an array of updated entries. Online query deletion normally resolves `[]`, not a count or removed-row list. Do not infer the deleted count from that empty array. `returnAsObject` changes the SDK envelope but does not expose the endpoint's `deleted` ID list. With query deletion and `ack: true`, a local reconciliation failure can resolve `{ entries, deletedEntries }` even when `returnAsObject` is omitted. Successful reconciliation returns the entries array instead of retaining an `includePagination` root response. Handle these branches or omit `ack` when consuming a predictable online response. Native local `find` routing is not an online query-update implementation; use an online connection for query writes. + +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const updated = await requests.query({ + type: 'update', + where: { Status: 'Collected' }, + data: { Status: 'Archived' } +}); +console.log('Updated entries:', updated.length); +``` + +Query write authorization, returned fields and hook behavior must be tested independently from `update(id, ...)`. See [testing security](testing-security.md). + +## Appending and replacing a batch + +Both methods accept an array of **flat column objects**, not record envelopes. A top-level input `id` is treated as an entry ID rather than a data-column value. Prefer omitting it for new rows. + +| Method | Arguments | Result and side effects | +|---|---|---| +| `append(entries?, options?)` | Array; `{ runHooks?: Boolean }` | Adds rows and preserves existing rows. Omitted entries defaults to `[]`. Online result is an array of created entry envelopes. Server `runHooks` defaults to `true`. | +| `replaceWith(entries?)` | Array | Removes existing rows and imports the supplied dataset. Omitted entries defaults to `[]`, which clears the source when authorized. Online result is an array of created entries. No options argument is implemented. | + +These operations can expand declared column names. They do not enforce column value types. Replacement requires deletion permission in addition to insertion permission for nonempty rows. Neither method supplies an atomic rollback guarantee. A failure can follow deletion or partial mutation. + +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const created = await requests.append([ + { Item: 'USB-C adapter', Quantity: 2, Status: 'Requested' }, + { Item: 'Headset', Quantity: 1, Status: 'Requested' } +]); +console.log('Created records:', created.map(entry => entry.id)); +``` + +For replacement, back up the source and verify the complete desired dataset before calling `replaceWith`. Development sample mode has different results and does not establish the online contract. + +## commit(data, columns?) + +`connection.commit(data, columns?)` sends several changes in **one request**. It is not an atomic transaction: deletion, insertion, updating and hook work do not share a universal rollback boundary. Never retry the entire commit automatically after an ambiguous failure. + +The entry shape differs from append/replace: commit receives `{ data: { ... } }` for new rows and `{ id, data: { ... } }` for updates. An array shorthand becomes `{ entries: data, columns: columns || [] }`. The second `columns` argument applies only to that shorthand. + +**Deletion defaults are consequential.** Without `append: true` or an explicit `delete` array, rows omitted from `entries` can be removed. An empty/default commit can clear the source. Set `append: true` for an insert/update-only commit, or explicitly provide the IDs to delete. + +| Field | Type | Default | Behavior | +|---|---|---|---| +| `entries` | Array | `[]` when omitted | Wrapped new/updated entries. A supplied nonarray rejects HTTP 400. Provide `entries` or `delete`. | +| `entries[].id` | Number | Omitted | Existing entry ID for updates. Without an ID, `data` creates a new entry. | +| `entries[].data` | Object | No default | Column values. Prefer complete values unless using `extend`. | +| `entries[].order` | Number | Unspecified | Entry order for a supplied row. | +| `entries[].clientId` | String | Unspecified | Caller correlation value for a new entry; returned alongside the assigned server ID. | +| `append` | Boolean | False when omitted | `true` skips removal when no explicit `delete` array is present. | +| `delete` | Array of IDs | Unspecified | Deletes the specified IDs instead of treating omitted rows as removed. An empty array explicitly requests no deletions. | +| `extend` | Boolean | False when omitted | Shallowly merges update `data` with stored data. With update hooks enabled the handler also merges values, so use `extend: true` explicitly for patch semantics. | +| `runHooks` | Array of strings | `[]` | Operation names such as `['insert', 'update']`. It is not append's Boolean option. | +| `returnEntries` | Boolean | True unless explicitly false | `false` omits the returned dataset. It does not grant access or enforce privacy. | +| `columns` | Array of strings | Inferred/merged when omitted | Explicit list replaces the declared column list and is augmented from row data. These names are not a typed schema. | +| `source` | String | Unspecified | Caller-supplied context for server hooks. | +| `normalizeOrder` | Object | Unspecified | `{ gap: positiveSafeInteger }` renumbers existing order values. A no-entry normalization requires `append: true` or an explicit `delete` array to prevent unintended removal. Excessive gaps reject. | + +Use a disposable source with read/write permission for this example: + +```js +const requests = await Fliplet.DataSources.connectByName('Equipment requests', { + offline: false +}); +const result = await requests.commit({ + entries: [ + { clientId: 'monitor-request', data: { + Item: 'Portable monitor', Quantity: 1, Status: 'Requested' + } } + ], + append: true, // preserve rows not supplied in this request + runHooks: ['insert'], // server hook operation names, not a Boolean + returnEntries: false // request only the correlation response +}); +const mapping = (result.clientIds || []).find(row => row.clientId === 'monitor-request'); +if (mapping) { + const saved = await requests.findById(mapping.id); + console.log('Persisted new row:', saved.id, saved.data); +} +``` + +The online result is `{ entries?, clientIds: [{ id, clientId }, ...] }`. By default, `entries` can contain the source dataset, not only changed rows. Hashed fields are redacted, but query-read filters and projections must not be assumed to protect this write response. Test returned rows and columns as each actor before enabling commits on private data. A cooperative caller's `returnEntries: false` is not authorization enforcement. See [write-response tests](testing-security.md#check-write-responses). + +## Hooks and completion + +Client hooks and [server hooks](../../Data-Source-Hooks.md) have separate ownership: + +| Method | Client hook | Server behavior relevant to completion | +|---|---|---| +| `insert` / `update` | `insert` / `update` | Server entry hooks can run in the background after saving. A record response does not prove downstream actions completed. | +| `append` | `append` | Server operation hooks requested by Boolean `runHooks`, default true. Setting false still runs the client hook. | +| `replaceWith` | No explicit package hook call | No implemented options argument for selecting hooks. | +| `commit` | `commit` | Selected server operations from `runHooks` array. Update-hook work is not a guarantee that every downstream action completed before response. | +| `query` writes / `removeById` | No equivalent insert/update client hook call | Do not assume the single-record hook pipeline is reused. | + +Hooks can transform values; client hooks and caller-supplied `source` cannot establish trusted identity or universal authorization. Verify each write path the app exposes. + +## Failures and recovery + +Failures are not uniformly HTTP 403. Source permissions, malformed batches and query failures can reject HTTP 400; missing IDs can reject 404; file validation can reject 422; throttling can reject 429. `insert` can wrap permanent 401/422 responses as `{ code: 2, message }` and multipart send failures as `{ code: 1, message }`. Preserve the actual error shape rather than assuming a status property always exists. + +Show pending/queued writes as pending. Inspect server records before retrying a write after a timeout, network failure or non-atomic batch error. Prevent duplicate submissions in the UI and use application-level duplicate detection where needed. Do not use an unbounded retry loop. Keep local state, server response and persisted verification as separate observations. + +[Back to Data Sources](../fliplet-datasources.md) diff --git a/docs/API/fliplet-datasources.md b/docs/API/fliplet-datasources.md index 461ab362..7f2eeba8 100644 --- a/docs/API/fliplet-datasources.md +++ b/docs/API/fliplet-datasources.md @@ -1,6 +1,6 @@ --- title: Fliplet.DataSources -description: "Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based." +description: "Connect to Fliplet data sources and find references for queries, writes, subscriptions, native storage and source management." type: api-reference tags: [js-api, datasources] v3_relevant: true @@ -10,1934 +10,197 @@ capabilities: [data source, datasource, query, insert, update, delete, crud, rec --- # `Fliplet.DataSources` -Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +Fliplet.DataSources connects app code to data sources. The references below describe supported arguments, result shapes, errors and the differences between online operations, native storage and source management. -## Security and verification - -Configure [data source access rules](../Data-source-security.md) before exposing private data. Use the [rule reference](datasources/security-rules.md), [complete policy examples](datasources/security-examples.md) and [authenticated preview tests](datasources/testing-security.md) alongside this method reference. - -Query reads (`find`/`findOne`) and record reads (`findById`) evaluate different requirement targets. Object update requirements check stored rows, not proposed values. Single and bulk writes have different column restrictions; a successful administrative request or cached/offline result does not establish app-user authorization. - -## ⚠️ Important: All API Calls Are Asynchronous - -**All Fliplet Data Sources API methods return Promises and must be chained using `.then()` or used with `async/await`.** - -```js -// Using async/await (recommended) -async function example() { - const connection = await Fliplet.DataSources.connect(dataSourceId); - const records = await connection.find(); - return records; -} - -// Using .then() chaining -Fliplet.DataSources.connect(dataSourceId) - .then(connection => connection.find()) - .then(records => { - // Process records - }) - .catch(error => { - // Handle errors - }); -``` - ---- - -## Core Workflow: Connect First - -### Connect to a Data Source by Name - -**Purpose:** Connect using the data source name instead of ID (recommended for portability) -**Syntax:** `Fliplet.DataSources.connectByName(name)` -**Returns:** `Promise` -**When to use:** Preferred method - data source names remain consistent when copying apps between environments - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -``` - -**Why use connectByName:** Data source names typically remain the same when copying apps between environments, while IDs change. This makes your code more portable and maintainable. - -### Connect to a Data Source by ID - -**Purpose:** Establish a connection to work with a specific data source by ID -**Syntax:** `Fliplet.DataSources.connect(dataSourceId, options?)` -**Returns:** `Promise` -**When to use:** Only when you specifically need to target a data source by ID (less portable) - -```js -// Basic connection by ID (not recommended for portability) -const connection = await Fliplet.DataSources.connect(123); - -// Advanced connection with options -const connection = await Fliplet.DataSources.connect(123, { - offline: false // Force online-only queries (mobile apps default to offline) -}); -``` - -**Note:** Fliplet apps on mobile devices attempt to connect to the **offline bundled data sources by default**. You can optionally prevent a data source from being bundled by editing its settings in Fliplet Studio, or by using the `offline: false` parameter to connect only to the live online data source. - ---- - -## Essential Connection Methods - -### 1. Find Records (Query Data) - -**Purpose:** Retrieve records from the data source -**Syntax:** `connection.find(options?)` -**Returns:** `Promise` -**When to use:** Reading/querying data (most common operation) - -#### Basic Usage -```js -// Complete example: Get all user records -const connection = await Fliplet.DataSources.connectByName("Users"); -const allUsers = await connection.find(); -console.log(`Found ${allUsers.length} users:`, allUsers); - -// Complete example: Get users from London office -const connection = await Fliplet.DataSources.connectByName("Users"); -const londonUsers = await connection.find({ - where: { Office: 'London' } -}); -console.log('London users:', londonUsers); - -// Complete example: Get specific user data columns only -const connection = await Fliplet.DataSources.connectByName("Users"); -const userNames = await connection.find({ - attributes: ['Name', 'Email', 'Department'] -}); -console.log('User names and emails:', userNames); -``` - -#### Advanced Querying -```js -// Complete example: Complex query with multiple conditions -const connection = await Fliplet.DataSources.connectByName("Users"); -const seniorEngineers = await connection.find({ - where: { - Office: 'London', - Age: { $gte: 25 }, - Department: { $in: ['Engineering', 'Design'] }, - Status: 'Active' - }, - order: [['data.Name', 'ASC']], - limit: 50 -}); -console.log('Senior engineers in London:', seniorEngineers); - -// Complete example: Using Fliplet's custom $filters (optimized for performance) -const connection = await Fliplet.DataSources.connectByName("Users"); -const activeUsers = await connection.find({ - where: { - $filters: [ - { - column: 'Email', - condition: 'contains', - value: '@company.com' - }, - { - column: 'Status', - condition: '==', - value: 'Active' - } - ] - } -}); -console.log('Active company users:', activeUsers); -``` - -> **Combining data from another data source?** Don't fetch a second data source and match records in JavaScript. Pass a `join` option to `find()` and the server returns the related records in a single query — see [Joining data from other data sources](#joining-data-from-other-data-sources) below and the full [joins documentation](datasources/joins). - -#### Query Operators Reference - -**MongoDB-style Operators (Sift.js)** -```js -// Comparison operators -{ field: { $gt: 10 } } // Greater than -{ field: { $gte: 10 } } // Greater than or equal -{ field: { $lt: 10 } } // Less than -{ field: { $lte: 10 } } // Less than or equal -{ field: { $eq: 'value' } } // Equal to -{ field: { $ne: 'value' } } // Not equal to - -// Array operators -{ field: { $in: ['a', 'b'] } } // Value in array -{ field: { $nin: ['a', 'b'] } } // Value not in array - -// Text operators -{ field: { $iLike: 'john' } } // Case-insensitive partial match -{ field: { $regex: /pattern/i } } // Regular expression - -// Logical operators -{ $and: [{ field1: 'a' }, { field2: 'b' }] } -{ $or: [{ field1: 'a' }, { field2: 'b' }] } -{ $nor: [{ field1: 'a' }, { field2: 'b' }] } // None of the conditions -{ field: { $not: { $lt: 18 } } } // Logical NOT - -// Other operators -{ field: { $exists: true } } // Field exists -{ field: { $mod: [4, 0] } } // Modulo operation -{ field: { $all: ['a', 'b'] } } // Array contains all values -{ field: { $size: 3 } } // Array size -{ field: { $type: 'string' } } // Type check -{ field: { $elemMatch: { $gte: 80 } } } // Array element matching -``` - -**Fliplet Custom $filters Operator** - -The `$filters` operator provides optimized performance and additional conditions: - -```js -{ - where: { - $filters: [ - // Exact match (case-insensitive) - { - column: 'Email', - condition: '==', - value: 'user@email.com' - }, - // Not equal - { - column: 'Status', - condition: '!=', - value: 'Inactive' - }, - // Numeric comparisons - { - column: 'Age', - condition: '>', // '>', '>=', '<', '<=' - value: 25 - }, - // Contains (case-insensitive partial match) - { - column: 'Name', - condition: 'contains', - value: 'John' - }, - // Empty/not empty checks - { - column: 'Notes', - condition: 'empty' // or 'notempty' - }, - // Range check - { - column: 'Score', - condition: 'between', - value: { from: 80, to: 100 } - }, - // One of multiple values - { - column: 'Department', - condition: 'oneof', - value: ['Engineering', 'Design', 'Marketing'] - }, - // Date comparisons - { - column: 'Birthday', - condition: 'dateis', // 'datebefore', 'dateafter', 'datebetween' - value: '1990-01-01' - } - ] - } -} -``` - -**📖 Complete Operators Reference:** [View detailed query operators documentation](datasources/query-operators) - -### 2. Insert Records (Add Data) - -**Purpose:** Add new records to the data source -**Syntax:** `connection.insert(data, options?)` -**Returns:** `Promise` -**When to use:** Creating new entries - -```js -// Complete example: Insert a single user record -const connection = await Fliplet.DataSources.connectByName("Users"); -const newUser = await connection.insert({ - Name: 'John Doe', - Email: 'john.doe@company.com', - Department: 'Engineering', - Office: 'London', - Age: 28, - Status: 'Active' -}); -console.log('Created new user:', newUser); - -// Complete example: Insert with acknowledgment for immediate local update -const connection = await Fliplet.DataSources.connectByName("Users"); -const newUser = await connection.insert({ - Name: 'Jane Smith', - Email: 'jane.smith@company.com', - Department: 'Design', - Office: 'New York', - Age: 26, - Status: 'Active' -}, { - ack: true // Ensure local database updates immediately -}); -console.log('Created user with immediate sync:', newUser); - -// Complete example: Insert with file upload (FormData) -const connection = await Fliplet.DataSources.connectByName("Users"); -const formData = new FormData(); -formData.append('Name', 'Bob Johnson'); -formData.append('Email', 'bob.johnson@company.com'); -formData.append('Department', 'Marketing'); -formData.append('Avatar', fileInput.files[0]); // File from input element - -const newUserWithFile = await connection.insert(formData, { - folderId: 123 // Specify media folder for file uploads -}); -console.log('Created user with avatar:', newUserWithFile); -``` - -**Options:** -- `folderId` (Number): MediaFolder ID where uploaded files should be stored -- `ack` (Boolean): If true, ensures the local offline database gets updated immediately - -### 3. Update Records (Modify Data) - -**Purpose:** Modify existing records -**Syntax:** `connection.update(recordId, data, options?)` -**Returns:** `Promise` -**When to use:** Changing existing entries - -```js -// Complete example: Update a user record by ID -const connection = await Fliplet.DataSources.connectByName("Users"); - -// First find the user to get their ID -const users = await connection.find({ - where: { Email: 'john.doe@company.com' } -}); - -if (users.length > 0) { - const updatedUser = await connection.update(users[0].id, { - Office: 'Berlin', - Department: 'Product Engineering' - }); - console.log('Updated user:', updatedUser); -} - -// Complete example: Update user with file upload -const connection = await Fliplet.DataSources.connectByName("Users"); -const formData = new FormData(); -formData.append('Office', 'San Francisco'); -formData.append('Avatar', newAvatarFile); - -const updatedUser = await connection.update(456, formData, { - folderId: 789 -}); -console.log('Updated user with new avatar:', updatedUser); -``` - -### 4. Find Single Record - -**Purpose:** Get one specific record -**Syntax:** `connection.findOne(options)` or `connection.findById(id)` -**Returns:** `Promise` -**When to use:** Looking for a specific entry - -```js -// Complete example: Find one user by email -const connection = await Fliplet.DataSources.connectByName("Users"); -const user = await connection.findOne({ - where: { Email: 'john.doe@company.com' } -}); - -if (user) { - console.log('Found user:', user); -} else { - console.log('User not found'); -} - -// Complete example: Find user by ID (if you know the ID) -const connection = await Fliplet.DataSources.connectByName("Users"); -const user = await connection.findById(123); - -if (user) { - console.log('Found user by ID:', user); -} else { - console.log('User with ID 123 not found'); -} -``` - -### 5. Remove Records (Delete Data) - -**Purpose:** Delete records from the data source -**Syntax:** `connection.removeById(id)` or `connection.query({ type: 'delete', where: {...} })` -**Returns:** `Promise` -**When to use:** Removing unwanted entries - -```js -// Complete example: Remove user by ID -const connection = await Fliplet.DataSources.connectByName("Users"); - -// First find the user to get their ID -const users = await connection.find({ - where: { Email: 'obsolete.user@company.com' } -}); - -if (users.length > 0) { - await connection.removeById(users[0].id); - console.log('User removed successfully'); -} - -// Complete example: Remove multiple users matching criteria -const connection = await Fliplet.DataSources.connectByName("Users"); -const deletedEntries = await connection.query({ - type: 'delete', - where: { Status: 'Inactive' } -}); -console.log('Removed inactive users:', deletedEntries); -``` - ---- - -## Bulk Operations - -### Insert Multiple Records - -**Purpose:** Add many records at once (more efficient than individual inserts) -**Syntax:** `connection.append(recordsArray, options?)` -**Returns:** `Promise` -**When to use:** Bulk data import - -```js -// Complete example: Add multiple users at once -const connection = await Fliplet.DataSources.connectByName("Users"); - -const newUsers = [ - { - Name: 'Alice Cooper', - Email: 'alice.cooper@company.com', - Department: 'Engineering', - Office: 'London', - Age: 29, - Status: 'Active' - }, - { - Name: 'Bob Wilson', - Email: 'bob.wilson@company.com', - Department: 'Design', - Office: 'New York', - Age: 31, - Status: 'Active' - }, - { - Name: 'Charlie Brown', - Email: 'charlie.brown@company.com', - Department: 'Marketing', - Office: 'Berlin', - Age: 27, - Status: 'Active' - } -]; - -await connection.append(newUsers); -console.log(`Added ${newUsers.length} new users`); - -// Complete example: Bulk insert with disabled hooks for better performance -const connection = await Fliplet.DataSources.connectByName("Users"); -await connection.append(newUsers, { runHooks: false }); -console.log('Bulk insert completed (hooks disabled for performance)'); -``` - -### Replace All Data - -**Purpose:** Replace entire data source contents with new records -**Syntax:** `connection.replaceWith(recordsArray)` -**Returns:** `Promise` -**When to use:** Complete data refresh - -```js -// Complete example: Replace all users with new dataset -const connection = await Fliplet.DataSources.connectByName("Users"); - -const newUserDataset = [ - { - Name: 'Admin User', - Email: 'admin@company.com', - Department: 'IT', - Office: 'London', - Age: 35, - Status: 'Active' - }, - { - Name: 'Test User', - Email: 'test@company.com', - Department: 'QA', - Office: 'London', - Age: 28, - Status: 'Active' - } -]; - -await connection.replaceWith(newUserDataset); -console.log('Replaced all user data with new dataset'); -``` - -### Commit Multiple Changes - -**Purpose:** Insert, update, and delete in a single operation -**Syntax:** `connection.commit(options)` -**Returns:** `Promise` -**When to use:** Complex bulk operations - -```js -// Complete example: Multiple operations in one transaction -const connection = await Fliplet.DataSources.connectByName("Users"); - -await connection.commit({ - entries: [ - // Insert new user - { - data: { - Name: 'New Employee', - Email: 'new.employee@company.com', - Department: 'Sales', - Office: 'Paris', - Age: 24, - Status: 'Active' - } - }, - - // Update existing user (assuming ID 123 exists) - { - id: 123, - data: { - Office: 'Remote', - Department: 'Engineering - Remote' - } - } - ], - - // Delete users by ID (assuming these IDs exist) - delete: [456, 789], - - // Keep existing entries not mentioned - append: true, - - // Merge with existing data instead of replacing - extend: true, - - // Don't return all entries (faster) - returnEntries: false -}); - -console.log('Bulk operations completed successfully'); -``` - ---- - -## Pagination and Performance - -### Basic Pagination - -```js -// Complete example: Paginated user retrieval -const connection = await Fliplet.DataSources.connectByName("Users"); - -// Get first 10 users -const page1 = await connection.find({ - limit: 10, - offset: 0, - order: [['data.Name', 'ASC']] -}); -console.log('Page 1 users:', page1); - -// Get next 10 users -const page2 = await connection.find({ - limit: 10, - offset: 10, - order: [['data.Name', 'ASC']] -}); -console.log('Page 2 users:', page2); - -// Complete example: Get pagination info -const connection = await Fliplet.DataSources.connectByName("Users"); -const result = await connection.find({ - limit: 10, - offset: 0, - includePagination: true, - order: [['data.Name', 'ASC']] -}); - -console.log(`Users: ${result.entries.length}`); -console.log(`Total users: ${result.pagination.total}`); -console.log(`Page: ${(result.pagination.offset / result.pagination.limit) + 1}`); -``` - -### Advanced Pagination with Cursor - -**Purpose:** Navigate through large datasets efficiently -**Syntax:** `connection.findWithCursor(options)` -**Returns:** `Promise` -**When to use:** Building paginated interfaces - -```js -// Complete example: Using cursor for pagination -const connection = await Fliplet.DataSources.connectByName("Users"); - -const cursor = await connection.findWithCursor({ - where: { Status: 'Active' }, - limit: 5, - order: [['data.Name', 'ASC']] -}); - -console.log(`Page ${cursor.currentPage + 1} users:`, cursor); -console.log(`Total users on page: ${cursor.length}`); -console.log(`Is first page: ${cursor.isFirstPage}`); -console.log(`Is last page: ${cursor.isLastPage}`); - -// Navigate to next page -if (!cursor.isLastPage) { - const nextPage = await cursor.next().update(); - console.log('Next page users:', nextPage); -} - -// Navigate to previous page -if (!cursor.isFirstPage) { - const prevPage = await cursor.prev().update(); - console.log('Previous page users:', prevPage); -} - -// Jump to specific page -const page3 = await cursor.setPage(2).update(); // 0-indexed, so 2 = page 3 -console.log('Page 3 users:', page3); -``` - -**Cursor Properties:** -- `limit`: The page size (number of records per page) -- `offset`: Current offset from first page -- `currentPage`: Current page number (0-based) -- `isFirstPage`: Whether current page is first -- `isLastPage`: Whether current page is last -- `query`: Options used for the cursor - -**Cursor Methods:** -- `next()`: Move to next page -- `prev()`: Move to previous page -- `setPage(n)`: Go to specific page -- `update()`: Refresh current page data - ---- - -## Sorting and Ordering - -**Purpose:** Control the order of returned records -**Syntax:** Use `order` array in find options -**When to use:** When you need specific sorting - -### The `order` contract - -`order` is validated by the API before the query runs. A value that breaks the -shape or column rules below is rejected with `400 Bad Request` — the query does -not fall back to the default sorting. - -| Rule | What it means | -|---|---| -| **Shape** — `order` is an array of arrays | Each sort is its own `[column, direction]` pair, so the value is `order: [['data.Name', 'ASC']]`. A flat `order: ['data.Name', 'ASC']` is rejected. | -| **Column** — five columns may appear unprefixed, and must be | Only `id`, `order`, `createdAt`, `deletedAt`, `updatedAt` are accepted without a prefix, and prefixing one names a column the data source does not declare — write `createdAt`, never `data.createdAt`. | -| **Column** — every other column needs the `data.` prefix | Anything you added to the data source is an entry column, so it is written `data.` — `data.Name`, not `Name`. | -| **Column** — the name after `data.` is verbatim | It must reproduce the declared column exactly, **spaces included** — `data.Start Time UTC`. Never replace a space with a dot, camel-case it, or truncate at it. | -| **Direction** — write `'ASC'` or `'DESC'` | A convention, not a validated rule: any other value fails as a database error — still returned as a `400`, but carrying the database's message instead of the `Invalid order column name` validation error. Always write the direction as a literal in your code; never build it from user input. Omitting the direction defaults to `'ASC'`. | - -

    Flat arrays fail with a confusing error. A flat order: ['createdAt', 'DESC'] makes the API read the string one character at a time, so the response is Invalid order column name: c — a single letter that appears nowhere in your request. A one-character column name in the error almost always means the shape is flat and needs an extra pair of brackets. The remaining ways to produce one are rare: an unprefixed one-character column name such as order: [['N', 'ASC']], which is rejected for the missing data. prefix rather than for its shape, or a prefixed one-character name that is not a letter, digit, space, underscore or hyphen, such as order: [['data.@', 'ASC']].

    - -

    Never add the data. prefix to where keys or attributes entries. Both are applied inside the entry's data object, so they take entry column names unprefixed — where: { Name: 'John' } and attributes: ['Name', 'Email'] are correct, and adding data. to them breaks the query. Options that address the raw entry row instead do use the prefix, just like order: the keys and values of a join's on mapping (on: { 'data.CustomerEmail': 'data.Email' }) and the field paths in an aggregate pipeline ('$data.Department'). Omitting the prefix there raises no error — the join silently matches nothing (every record comes back with an empty joins entry, or drops out of the results entirely on a required join) and the aggregation reads undefined values.

    - -

    Confirm the column names against the data source's declared columns before sorting. On a data source that declares its columns, an entry column it does not declare fails with Column '<name>' does not exists, and a top-level column that is not declared (such as data.createdAt) fails the same way — createdAt belongs in the unprefixed list, not behind the prefix. On a data source with no declared columns the check is skipped, so an unknown column sorts silently on NULLs instead of erroring.

    - -Sort by creation date, newest first: - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -const recentUsers = await connection.find({ - order: [['createdAt', 'DESC']], // 'createdAt' is one of the five unprefixed columns - limit: 10 -}); -console.log('Most recent users:', recentUsers); -``` - -Sort by an entry column: - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -const usersByName = await connection.find({ - order: [['data.Name', 'ASC']] // 'Name' is an entry column, so it needs the 'data.' prefix -}); -console.log('Users sorted by name:', usersByName); -``` - -Sort by a column whose name contains spaces: - -```js -const connection = await Fliplet.DataSources.connectByName("Sessions"); -const sessions = await connection.find({ - // The data source declares a column called: Start Time UTC - order: [['data.Start Time UTC', 'DESC']] // spaces are kept exactly as declared -}); -console.log('Most recent sessions:', sessions); -``` - -Sort by multiple columns: - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); -const sortedUsers = await connection.find({ - order: [ - ['data.Department', 'ASC'], // Sort by department first - ['data.Name', 'ASC'] // Then by name - ] -}); -console.log('Users sorted by department then name:', sortedUsers); - -// Available sort columns: -// - Fliplet columns: 'id', 'order', 'createdAt', 'deletedAt', 'updatedAt' -// - Entry columns: 'data.ColumnName' -// - Sort directions: 'ASC' (ascending), 'DESC' (descending) -``` - -### Common mistakes - -```js -// WRONG: flat array — rejected with "Invalid order column name: c" -order: ['createdAt', 'DESC'] -// RIGHT -order: [['createdAt', 'DESC']] - -// WRONG: entry column without the prefix — rejected with "Invalid order column name: Name" -order: [['Name', 'ASC']] -// RIGHT -order: [['data.Name', 'ASC']] - -// WRONG: space rewritten as a dot — the API reads the column as "Start" -order: [['data.Start.Time.UTC', 'DESC']] -// WRONG: space truncated — the API reads the column as "Start" -order: [['data.Start', 'DESC']] -// RIGHT: the declared column name, verbatim -order: [['data.Start Time UTC', 'DESC']] -``` - ---- - -## Subscribing to Data Source Changes - -**Purpose:** Keep a screen up to date when other people change the data -**Syntax:** `connection.subscribe(options, callback)` -**Returns:** `Subscription` -**When to use:** Screens that stay open while the data changes underneath them - -### How updates arrive - -Changes reach the screen within about 30 seconds rather than the instant they -happen. Fliplet checks the data source for you on a repeating timer and calls -your callback with whatever changed since the last check. - -Design around that delay. If something has to appear the moment it is created, -a subscription is not the right tool. - -### What a subscription costs your app - -Every screen that subscribes does its own checking, and keeps checking for as -long as it stays open: - -- **It adds up with people, not with data.** 200 people with the screen open - means the data source is read around 400 times a minute, whether or not - anything has changed. -- **An empty data source costs the same as a busy one.** The check still runs; - there is simply nothing to report. -- **It carries on in the background.** A screen left open in a background tab - keeps checking overnight, on every device that left it open. - -Those reads count toward your app's data source usage, so a subscription left -running unnecessarily is one of the easier ways to build up usage without -noticing it. - -The two rules below keep that under control. Follow both on any screen that -stays open. - -### 1. Pause while the screen is hidden - -A subscription keeps running when someone switches to another app or tab. -Cleaning up when the screen is destroyed is not enough on its own — people -move away from a screen far more often than they close it, so without this the -checks continue all night for everyone who left it open. - -```js -const connection = await Fliplet.DataSources.connectByName("Users"); - -const subscription = connection.subscribe({ - events: ['insert', 'update', 'delete'] -}, (changes) => { - if (changes.inserted && changes.inserted.length) { - // Add the new rows to your UI - } - - if (changes.updated && changes.updated.length) { - // Refresh the rows that changed - } - - if (changes.deleted && changes.deleted.length) { - // Remove the rows that went away - } -}); - -function onVisibilityChange() { - if (document.hidden) { - if (subscription.status() === 'active') { - subscription.pause(); - } - } else if (subscription.status() === 'paused') { - subscription.resume(); - } -} - -document.addEventListener('visibilitychange', onVisibilityChange); - -// On teardown, remove the listener AND end the subscription. -document.removeEventListener('visibilitychange', onVisibilityChange); -subscription.unsubscribe(); -``` - -`pause()` stops the checking, so nothing is read while the screen is hidden. -`resume()` catches up straight away, so anything that changed in the meantime -still reaches the screen. Check `status()` before switching, as shown above: -calling `resume()` on a subscription that is already running has no effect. - -### 2. Never add a timer of your own on top - -Adding a `setInterval` that calls `find()` as a safety net, in case the -subscription misses something, is the most common mistake here. The -subscription already re-reads the data source on every check, so a second -timer repeats work that has just been done and doubles what the screen costs -without covering anything extra. - -If updates are not coming through, the cause is in your callback or in the -`events` you asked for. A second timer hides that rather than fixing it. - ---- - -## Utility Methods - -### Get Unique Values - -**Purpose:** Get distinct values from columns -**When to use:** Building filters, dropdowns, or analytics - -```js -// Complete example: Get unique office locations -const connection = await Fliplet.DataSources.connectByName("Users"); -const offices = await connection.getIndex('Office'); -console.log('Available offices:', offices); -// Returns: ['London', 'New York', 'Berlin', 'Paris'] +## Before you start -// Complete example: Get unique values for multiple columns -const connection = await Fliplet.DataSources.connectByName("Users"); -const indexes = await connection.getIndexes(['Office', 'Department']); -console.log('Unique values:', indexes); -// Returns: { -// Office: ['London', 'New York', 'Berlin'], -// Department: ['Engineering', 'Design', 'Marketing'] -// } +Load the `fliplet-datasources` library in your app and create a data source in Studio. Configure [access rules](../Data-source-security.md) for the intended app users before exposing private data. Calls use the current app/page context and identity; a connection does not supply an independent login. -// Use for building dynamic filters -const { Office: availableOffices, Department: availableDepartments } = indexes; -console.log('Build office filter with:', availableOffices); -console.log('Build department filter with:', availableDepartments); -``` +Connection and network methods return Promises. Subscription registration and cursor navigation return synchronous handles; their references identify which calls need `await`. Use the plural namespace `Fliplet.DataSources`. -### Import Data from File +## Read records -**Purpose:** Import data from CSV or other file formats -**Syntax:** `connection.import(FormData)` -**Returns:** `Promise` -**When to use:** Bulk data import from files +This example needs a source named `Directory` with `Name` and `Department` columns and permission to query it. It uses an online connection and handles failed requests. ```js -// Complete example: Import users from CSV file -const connection = await Fliplet.DataSources.connectByName("Users"); - -// Assuming you have a file input element -const fileInput = document.getElementById('csvFileInput'); -const csvFile = fileInput.files[0]; - -if (csvFile) { - const formData = new FormData(); - formData.append('file', csvFile); - +async function readDirectory() { try { - await connection.import(formData); - console.log('User data imported successfully from CSV'); - } catch (error) { - console.error('Import failed:', error); - } -} else { - console.log('No file selected'); -} -``` - ---- - -## Data Source Management - -### Get Available Data Sources - -**Purpose:** List data sources you can work with -**Syntax:** `Fliplet.DataSources.get(options?)` -**Returns:** `Promise` -**When to use:** Discovery, building dynamic interfaces - -```js -// Complete example: Get all data sources for organization -const dataSources = await Fliplet.DataSources.get({ - attributes: ['id', 'name', 'columns'] -}); -console.log('Available data sources:', dataSources); - -// Find the Users data source -const usersDataSource = dataSources.find(ds => ds.name === 'Users'); -if (usersDataSource) { - console.log('Users data source found:', usersDataSource); - console.log('Available columns:', usersDataSource.columns); -} - -// Complete example: Get data sources used by current app -const appDataSources = await Fliplet.DataSources.get({ - appId: Fliplet.Env.get('masterAppId'), - includeInUse: true -}); -console.log('App data sources:', appDataSources); - -// Complete example: Get specific data source details by ID -const dataSource = await Fliplet.DataSources.getById(123, { - attributes: ['name', 'hooks', 'columns'] -}); -console.log('Data source details:', dataSource); -``` - -### Create New Data Source - -**Purpose:** Programmatically create data sources -**Syntax:** `Fliplet.DataSources.create(options)` -**Returns:** `Promise` -**When to use:** Dynamic app setup, automated workflows - -```js -// Complete example: Create a new Users data source -const newDataSource = await Fliplet.DataSources.create({ - name: 'Users', - - // Attach to current app - appId: Fliplet.Env.get('appId'), - organizationId: Fliplet.Env.get('organizationId'), - - // Define structure - columns: ['Name', 'Email', 'Department', 'Office', 'Age', 'Status'], - - // Add initial test data - entries: [ - { - Name: 'John Smith', - Email: 'john.smith@company.com', - Department: 'Engineering', - Office: 'London', - Age: 30, - Status: 'Active' - }, - { - Name: 'Sarah Jones', - Email: 'sarah.jones@company.com', - Department: 'Design', - Office: 'New York', - Age: 28, - Status: 'Active' - } - ], - - // Public sample data only: grants every app user all four operations. - // Use a scoped policy from the security guide for private data. - accessRules: [ - { type: ['select', 'insert', 'update', 'delete'], allow: 'all' } - ] -}); - -console.log('Created Users data source:', newDataSource); - -// Now you can connect to it by name -const connection = await Fliplet.DataSources.connectByName("Users"); -console.log('Connected to new Users data source'); -``` - ---- - -## Advanced Features - -### Aggregation Queries - -**Purpose:** Run complex data analysis using MongoDB-style aggregation -**Syntax:** Use `aggregate` option in find method -**When to use:** Analytics, reporting, data transformation - -```js -// Complete example: Group users by department and calculate average age -const connection = await Fliplet.DataSources.connectByName("Users"); - -const departmentStats = await connection.find({ - aggregate: [ - { - $project: { - department: '$data.Department', - numericAge: { $convertToNumber: '$data.Age' } - } - }, - { - $group: { - _id: '$department', - avgAge: { $avg: '$numericAge' }, - userCount: { $sum: 1 } - } - } - ] -}); - -console.log('Department statistics:', departmentStats); -// Example output: [ -// { _id: 'Engineering', avgAge: 29.5, userCount: 4 }, -// { _id: 'Design', avgAge: 27.2, userCount: 3 } -// ] -``` - -**Note:** Use the custom `$convertToNumber` operator to convert strings to numbers before aggregation. - -### Joining Data from Other Data Sources - -When a screen shows records from one data source enriched with fields from another (orders with their customer's name/email, articles with their comments), use the **native server-side join** — pass a `join` option to `find()`. Keep the schema normalized: store a reference key on the child record (e.g. `CustomerEmail` on an order), not a duplicated copy of the other record's fields. - -```js -// Orders, each carrying its customer's record from a separate Customers data source -const orders = await Fliplet.DataSources.connect(611); -const result = await orders.find({ - join: { - Customer: { - dataSourceId: 610, // the related (Customers) data source - on: { 'data.CustomerEmail': 'data.Email' } // childKey: parentKey - } - } -}); -// each row now carries result[i].joins.Customer with the joined customer record -// (the joined data sits alongside result[i].data, not inside it) -``` - -This is one query the server resolves — **don't** fetch every record from the second data source and match them in JavaScript; that doesn't paginate or scale. For join types (left/inner), the `on` mapping, and filtering joined data, see the full [joins documentation](datasources/joins). - -### Data Source Views - -For filtering data sources with predefined views, see the [views documentation](datasources/views). - ---- - -## Common Usage Patterns - -### Pattern 1: Complete User Management System -```js -// Initialize user management with connection -const initUserManager = async () => { - const connection = await Fliplet.DataSources.connectByName("Users"); - console.log('UserManager initialized'); - return connection; -}; - -// Create new user -const createUser = async (connection, { name, email, department, office, age }) => { - const newUser = await connection.insert({ - Name: name, - Email: email, - Department: department, - Office: office, - Age: age, - Status: 'Active', - CreatedDate: new Date().toISOString().split('T')[0] - }); - - console.log(`Created user: ${newUser.data.Name}`); - return newUser; -}; - -// Get all active users -const getActiveUsers = async (connection) => { - const users = await connection.find({ - where: { Status: 'Active' }, - order: [['data.Name', 'ASC']] - }); - - console.log(`Found ${users.length} active users`); - return users; -}; - -// Update user information -const updateUser = async (connection, email, updates) => { - const users = await connection.find({ - where: { Email: email } - }); - - if (users.length > 0) { - const updatedUser = await connection.update(users[0].id, updates); - console.log(`Updated user: ${email}`); - return updatedUser; - } else { - throw new Error(`User with email ${email} not found`); - } -}; - -// Deactivate user (soft delete) -const deactivateUser = async (connection, email) => { - return updateUser(connection, email, { Status: 'Inactive' }); -}; - -// Usage example -const manageUsers = async () => { - const connection = await initUserManager(); - - // Create a new user - const newUser = await createUser(connection, { - name: 'Alice Johnson', - email: 'alice.johnson@company.com', - department: 'Marketing', - office: 'Berlin', - age: 26 - }); - - // Get all active users - const activeUsers = await getActiveUsers(connection); - - // Update user - await updateUser(connection, 'alice.johnson@company.com', { - Office: 'Remote', - Department: 'Digital Marketing' - }); - - // Deactivate user - await deactivateUser(connection, 'alice.johnson@company.com'); -}; - -// Run the user management example -manageUsers().catch(console.error); -``` - -### Pattern 2: Advanced Search and Filtering -```js -// Initialize search functionality -const initUserSearch = async () => { - const connection = await Fliplet.DataSources.connectByName("Users"); - console.log('User search initialized'); - return connection; -}; - -// Search users with multiple criteria -const searchUsers = async (connection, { - searchTerm = '', - departments = [], - offices = [], - minAge = 0, - maxAge = 100, - status = 'Active' -} = {}) => { - - const whereConditions = { - $and: [ - { Status: status }, - { Age: { $gte: minAge, $lte: maxAge } } - ] - }; - - // Add text search if provided - if (searchTerm) { - whereConditions.$and.push({ - $or: [ - { Name: { $iLike: searchTerm } }, - { Email: { $iLike: searchTerm } } - ] - }); - } - - // Add department filter if provided - if (departments.length > 0) { - whereConditions.$and.push({ - Department: { $in: departments } - }); - } - - // Add office filter if provided - if (offices.length > 0) { - whereConditions.$and.push({ - Office: { $in: offices } + const connection = await Fliplet.DataSources.connectByName('Directory', { + offline: false }); - } - - const results = await connection.find({ - where: whereConditions, - order: [['data.Name', 'ASC']], - limit: 100 - }); - - console.log(`Search found ${results.length} users`); - return results; -}; - -// Get filter options for building UI -const getFilterOptions = async (connection) => { - const [departments, offices] = await Promise.all([ - connection.getIndex('Department'), - connection.getIndex('Office') - ]); - - return { departments, offices }; -}; - -// Advanced search using $filters for better performance -const searchUsersOptimized = async (connection, filters = {}) => { - const { - emailDomain = '@company.com', - department, - office, - ageRange = { min: 18, max: 65 } - } = filters; - - const results = await connection.find({ - where: { - $filters: [ - { - column: 'Status', - condition: '==', - value: 'Active' - }, - { - column: 'Email', - condition: 'contains', - value: emailDomain - }, - { - column: 'Age', - condition: 'between', - value: { from: ageRange.min, to: ageRange.max } - }, - ...(department ? [{ - column: 'Department', - condition: '==', - value: department - }] : []), - ...(office ? [{ - column: 'Office', - condition: '==', - value: office - }] : []) - ] - } - }); - - return results; -}; - -// Usage example -const demonstrateSearch = async () => { - const connection = await initUserSearch(); - - // Get filter options for UI - const { departments, offices } = await getFilterOptions(connection); - console.log('Available departments:', departments); - console.log('Available offices:', offices); - - // Search with multiple criteria - const searchResults = await searchUsers(connection, { - searchTerm: 'john', - departments: ['Engineering', 'Design'], - offices: ['London', 'Berlin'], - minAge: 25, - maxAge: 40 - }); - console.log('Search results:', searchResults); - - // Optimized search - const optimizedResults = await searchUsersOptimized(connection, { - department: 'Engineering', - office: 'London', - ageRange: { min: 25, max: 35 } - }); - console.log('Optimized search results:', optimizedResults); -}; - -// Run the search example -demonstrateSearch().catch(console.error); -``` - -### Pattern 3: Paginated User Directory with Real-time Updates -```js -// Initialize paginated directory -const initUserDirectory = async (pageSize = 10) => { - const connection = await Fliplet.DataSources.connectByName("Users"); - - const currentCursor = await connection.findWithCursor({ - where: { Status: 'Active' }, - limit: pageSize, - order: [['data.Name', 'ASC']] - }); - - console.log('User directory initialized'); - return { connection, currentCursor }; -}; - -// Display current page -const displayCurrentPage = (cursor) => { - const { currentPage, isFirstPage, isLastPage, length } = cursor; - - console.log(`\n=== User Directory - Page ${currentPage + 1} ===`); - console.log(`Showing ${length} users:`); - - cursor.forEach((user, index) => { - const { Name, Email, Department, Office } = user.data; - console.log(`${index + 1}. ${Name} (${Email}) - ${Department}, ${Office}`); - }); - - console.log(`\nNavigation: First Page: ${isFirstPage}, Last Page: ${isLastPage}`); -}; - -// Navigate to next page -const nextPage = async (cursor) => { - if (!cursor.isLastPage) { - const updatedCursor = await cursor.next().update(); - displayCurrentPage(updatedCursor); - return updatedCursor; - } else { - console.log('Already on last page'); - return cursor; - } -}; - -// Navigate to previous page -const previousPage = async (cursor) => { - if (!cursor.isFirstPage) { - const updatedCursor = await cursor.prev().update(); - displayCurrentPage(updatedCursor); - return updatedCursor; - } else { - console.log('Already on first page'); - return cursor; - } -}; - -// Go to specific page -const goToPage = async (cursor, pageNumber) => { - const updatedCursor = await cursor.setPage(pageNumber - 1).update(); - displayCurrentPage(updatedCursor); - return updatedCursor; -}; - -// Subscribe to updates. -// This keeps checking for as long as the screen is open — see "Subscribing to -// Data Source Changes" above. On a real screen, also pause it while the screen -// is hidden; that is left out here to keep the pagination example readable. -const subscribeToUpdates = (connection, cursor) => { - const subscription = connection.subscribe({ - events: ['insert', 'update', 'delete'] - }, (changes) => { - const { inserted, updated, deleted } = changes; - - if (inserted?.length) { - console.log(`\n🆕 ${inserted.length} new user(s) added:`); - inserted.forEach(user => { - console.log(` + ${user.data.Name} (${user.data.Email})`); - }); - } - - if (updated?.length) { - console.log(`\n✏️ ${updated.length} user(s) updated:`); - updated.forEach(user => { - console.log(` ~ ${user.data.Name} (${user.data.Email})`); - }); - } - - if (deleted?.length) { - console.log(`\n🗑️ ${deleted.length} user(s) deleted`); - } - - // Refresh current page to show changes - refreshCurrentPage(cursor); - }); - - return subscription; -}; - -// Refresh current page -const refreshCurrentPage = async (cursor) => { - const updatedCursor = await cursor.update(); - console.log('\n--- Refreshed current page ---'); - displayCurrentPage(updatedCursor); - return updatedCursor; -}; - -// Cleanup function -const cleanup = (subscription) => { - if (subscription) { - subscription.unsubscribe(); - console.log('Real-time updates stopped'); - } -}; - -// Usage example -const demonstrateDirectory = async () => { - const { connection, currentCursor } = await initUserDirectory(5); // 5 users per page - let cursor = currentCursor; - - // Display initial page - displayCurrentPage(cursor); - - // Subscribe to updates - const subscription = subscribeToUpdates(connection, cursor); - - // Simulate navigation after delays - setTimeout(async () => { - cursor = await nextPage(cursor); - }, 2000); - - setTimeout(async () => { - cursor = await previousPage(cursor); - }, 4000); - - setTimeout(async () => { - cursor = await goToPage(cursor, 3); - }, 6000); - - // Cleanup after 30 seconds - setTimeout(() => { - cleanup(subscription); - }, 30000); -}; - -// Run the directory example -demonstrateDirectory().catch(console.error); -``` - -### Pattern 4: Comprehensive Error Handling and Recovery -```js -// Utility function for delays -const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - -// Connect with retry logic -const connectWithRetry = async (maxRetries = 3, retryDelay = 1000) => { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await Fliplet.DataSources.connectByName("Users"); - } catch (error) { - console.warn(`Connection attempt ${attempt} failed:`, error.message); - - if (attempt === maxRetries) { - throw new Error(`Failed to connect after ${maxRetries} attempts`); - } - - await delay(retryDelay * attempt); - } - } -}; - -// Safe operation wrapper with error handling -const safeOperation = async (operation, operationName) => { - try { - const result = await operation(); - console.log(`✅ ${operationName} completed successfully`); - return result; - } catch (error) { - const { status, message } = error; - - console.error(`❌ ${operationName} failed:`, message); - - switch (status) { - case 404: - throw new Error(`${operationName} failed: Data source or record not found`); - case 403: - throw new Error(`${operationName} failed: Access denied. Check permissions.`); - case 429: - console.log('Rate limit hit, waiting before retry...'); - await delay(5000); - return safeOperation(operation, operationName); - case 500: - throw new Error(`${operationName} failed: Server error. Please try again later.`); - default: - throw new Error(`${operationName} failed: ${message}`); - } - } -}; - -// Initialize robust user service -const initRobustUserService = async () => { - try { - const connection = await connectWithRetry(); - console.log('✅ UserService initialized successfully'); - return connection; + const entries = await connection.find({ + where: { Department: 'Operations' }, + attributes: ['Name', 'Department'], + order: [['data.Name', 'ASC']], + limit: 25 + }, { cache: false }); + return entries.map(entry => ({ id: entry.id, name: entry.data.Name })); } catch (error) { - console.error('❌ Failed to initialize UserService:', error.message); + console.error('Directory query failed', error); throw error; } -}; - -// Get user safely with error handling -const getUserSafely = async (connection, email) => { - return safeOperation(async () => { - const users = await connection.find({ - where: { Email: email } - }); - - if (users.length === 0) { - throw new Error(`User with email ${email} not found`); - } - - return users[0]; - }, `Get user ${email}`); -}; - -// Create user safely with validation -const createUserSafely = async (connection, userData) => { - return safeOperation(async () => { - // Validate required fields - const requiredFields = ['Name', 'Email', 'Department']; - const missingFields = requiredFields.filter(field => !userData[field]); - - if (missingFields.length > 0) { - throw new Error(`Missing required fields: ${missingFields.join(', ')}`); - } - - // Check if user already exists - const existingUsers = await connection.find({ - where: { Email: userData.Email } - }); - - if (existingUsers.length > 0) { - throw new Error(`User with email ${userData.Email} already exists`); - } - - // Create user with defaults - const userToCreate = { - Status: 'Active', - CreatedDate: new Date().toISOString().split('T')[0], - ...userData - }; - - return await connection.insert(userToCreate); - }, `Create user ${userData.Email}`); -}; - -// Update user safely with validation -const updateUserSafely = async (connection, email, updates) => { - return safeOperation(async () => { - const user = await getUserSafely(connection, email); - - // Prevent email changes for data integrity - if (updates.Email && updates.Email !== email) { - throw new Error('Email address cannot be changed'); - } - - const updatedUser = await connection.update(user.id, { - ...updates, - LastModified: new Date().toISOString().split('T')[0] - }); - - return updatedUser; - }, `Update user ${email}`); -}; - -// Get all users with fallback handling -const getAllUsersWithFallback = async (connection) => { - return safeOperation(async () => { - const users = await connection.find({ - where: { Status: 'Active' }, - order: [['data.Name', 'ASC']] - }); - - if (users.length === 0) { - console.warn('⚠️ No active users found'); - } - - return users; - }, 'Get all users'); -}; - -// Usage example with comprehensive error handling -const demonstrateRobustOperations = async () => { - try { - const connection = await initRobustUserService(); - - // Safe operations - const newUser = await createUserSafely(connection, { - Name: 'Jane Doe', - Email: 'jane.doe@company.com', - Department: 'Engineering', - Office: 'London', - Age: 29 - }); - - const user = await getUserSafely(connection, 'jane.doe@company.com'); - - const updatedUser = await updateUserSafely(connection, 'jane.doe@company.com', { - Office: 'Remote', - Age: 30 - }); - - const allUsers = await getAllUsersWithFallback(connection); - - console.log('All operations completed successfully'); - - } catch (error) { - console.error('Application error:', error.message); - // Handle error appropriately in your app - } -}; - -// Run the robust operations example -demonstrateRobustOperations().catch(console.error); +} ``` ---- - -## Test Data Source Structure - -To test all the examples above, you can create a "Users" data source with the following structure: +The default read result is an array of entries. Each entry has metadata such as `id`, `createdAt` and `updatedAt`, with column values under `data`: -### Data Source: "Users" - -**Columns:** -- `Name` (Text) - Full name of the user -- `Email` (Text) - Email address (should be unique) -- `Department` (Text) - Department name -- `Office` (Text) - Office location -- `Age` (Number) - Age in years -- `Status` (Text) - Active/Inactive status - -**Sample Test Data:** ```json -[ - { - "Name": "John Smith", - "Email": "john.smith@company.com", - "Department": "Engineering", - "Office": "London", - "Age": 30, - "Status": "Active" - }, - { - "Name": "Sarah Johnson", - "Email": "sarah.johnson@company.com", - "Department": "Design", - "Office": "New York", - "Age": 28, - "Status": "Active" - }, - { - "Name": "Mike Wilson", - "Email": "mike.wilson@company.com", - "Department": "Engineering", - "Office": "London", - "Age": 32, - "Status": "Active" - }, - { - "Name": "Emily Davis", - "Email": "emily.davis@company.com", - "Department": "Marketing", - "Office": "Berlin", - "Age": 26, - "Status": "Active" - }, - { - "Name": "David Brown", - "Email": "david.brown@company.com", - "Department": "Sales", - "Office": "Paris", - "Age": 29, - "Status": "Active" - }, - { - "Name": "Lisa Garcia", - "Email": "lisa.garcia@company.com", - "Department": "Design", - "Office": "New York", - "Age": 31, - "Status": "Active" - }, - { - "Name": "Tom Anderson", - "Email": "tom.anderson@company.com", - "Department": "Engineering", - "Office": "Berlin", - "Age": 27, - "Status": "Inactive" - }, - { - "Name": "Anna Martinez", - "Email": "anna.martinez@company.com", - "Department": "HR", - "Office": "London", - "Age": 33, - "Status": "Active" - }, - { - "Name": "Chris Taylor", - "Email": "chris.taylor@company.com", - "Department": "Marketing", - "Office": "Remote", - "Age": 25, - "Status": "Active" - }, - { - "Name": "Jennifer Lee", - "Email": "jennifer.lee@company.com", - "Department": "Finance", - "Office": "New York", - "Age": 34, - "Status": "Active" - } -] -``` - -### Creating the Test Data Source - -You can create this data source programmatically using the API: - -```js -// Create the Users data source with test data -const testDataSource = await Fliplet.DataSources.create({ - name: 'Users', - appId: Fliplet.Env.get('appId'), - organizationId: Fliplet.Env.get('organizationId'), - columns: ['Name', 'Email', 'Department', 'Office', 'Age', 'Status'], - entries: [ - // ... paste the sample test data above - ], - accessRules: [ - { type: ['select', 'insert', 'update', 'delete'], allow: 'all' } - ] -}); - -console.log('Test Users data source created:', testDataSource); -``` - -This test data provides: -- **Variety**: Multiple departments, offices, and age ranges -- **Real scenarios**: Active/inactive users, different locations -- **Search testing**: Names and emails for text search testing -- **Filter testing**: Multiple values for department and office filters -- **Pagination testing**: 10 records for testing pagination features -- **Age ranges**: Different ages for numeric filtering tests - -All examples in this documentation use this data structure and will work with this test data. - ---- - -## Offline database (native only) - -`Fliplet.DataSources.Database` is the offline-first database API for native Fliplet apps. It loads bundled JSON files from device storage on native (or fetches them via the REST bundle endpoint on web during development), then exposes a synchronous collection handle for CRUD and diff operations against the in-memory data. - -

    This API is primarily for native apps. On web it falls back to fetching the bundle, which is suitable for dev/test only.

    - -**Important notes:** - -- **Module-level cache.** Once a bundle is loaded the parsed object is reused for the process lifetime — subsequent calls to `Fliplet.DataSources.Database` with the same identifier do not re-read the file. -- **No write batching.** Every mutating collection method (`insert`, `update`, `remove`, `applyDiff`) writes the full JSON file to disk via `Fliplet.Native.Maintenance.writeFile` within the same Promise chain. -- **`update()` deep-merges.** Pass only the fields you want changed; existing fields not present in the second argument are left intact. -- **`applyDiff({ fullRefresh: true })`** replaces the entire local collection with the server payload. Without `fullRefresh`, the diff removes entries by id and then appends the updated set. - -### Opening the database - -`Fliplet.DataSources.Database(options)` → `Promise` - -| Option | Type | Required | Description | -|---|---|---|---| -| `id` | Number \| String | Yes (unless `isName`) | Data source ID, or name when `isName: true` | -| `isName` | Boolean | No | Set to `true` when `id` holds the data source name | -| `name` | String | No | Alternative way to supply the name (used together with `isName: true`) | -| `appId` | Number | No | Defaults to `Fliplet.Env.get('appId')` | - -```js -// Open by data source ID -const db = await Fliplet.DataSources.Database({ id: 42 }); - -// Open by data source name -const db = await Fliplet.DataSources.Database({ id: 'Products', isName: true }); -``` - -### Selecting a data source - -`database.dataSource(id, opts?)` → `CollectionHandle` (synchronous — returns immediately, no Promise) - -```js -const db = await Fliplet.DataSources.Database({ id: 42 }); - -// Select by numeric ID -const products = db.dataSource(42); - -// Select by name using the opts object -const inventory = db.dataSource(null, { name: 'Inventory' }); -``` - -### Reading data - -#### `find(where, options?)` → `Promise` - -Runs a [sift.js](https://github.com/crcn/sift.js) MongoDB-style query against `entry.data`. Supports data-source views (row-level filters) when `options.views` is provided. - -| Option | Type | Description | -|---|---|---| -| `limit` | Number | Maximum number of entries to return | -| `views` | Array\ | Names of data-source views to apply as row-level filters | - -```js -const db = await Fliplet.DataSources.Database({ id: 42 }); -const products = db.dataSource(42); - -// Return all entries -const all = await products.find({}); - -// Filter by category -const electronics = await products.find({ Category: 'Electronics' }); - -// MongoDB-style operators -const affordable = await products.find( - { Price: { $lte: 99.99 }, InStock: true }, - { limit: 20 } -); - -// Apply a pre-configured view called "PublicCatalogue" -const publicItems = await products.find({}, { views: ['PublicCatalogue'] }); -``` - -#### `findById(id)` → `Promise` - -```js -const product = await products.findById(101); - -if (product) { - console.log(product.data.Name, product.data.Price); +{ + "id": 101, + "data": { "Name": "Ada Patel", "Department": "Operations" }, + "createdAt": "2026-01-10T09:00:00.000Z", + "updatedAt": "2026-01-10T09:00:00.000Z" } ``` -### Writing data - -All write methods mutate the in-memory collection first, then persist the full JSON bundle to disk. - -#### `insert(entry)` → `Promise` - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); - -const newItem = await inventory.insert({ - id: 9001, - data: { - SKU: 'WH-001', - Name: 'Wireless Headphones', - Price: 79.99, - InStock: true, - Category: 'Electronics' - } -}); -``` - -#### `update(existingEntry, entry)` → `Promise` - -Deep-merges `entry` into `existingEntry` in-place. Pass only the fields you want changed. +A query's `where` filters column values under `data`; entry metadata filters use top-level query parameters. Pagination, aggregation and request options can change the result shape. See [reading data](datasources/reading-data.md) before combining them. -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); +## API navigation -const headphones = await inventory.findById(9001); - -// Update price and stock status only — all other fields are preserved -await inventory.update(headphones, { - data: { Price: 69.99, InStock: false } -}); -``` - -#### `remove(entry)` → `Promise` - -Accepts an entry object (matched by `id`) or a predicate function. - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); - -// Remove a specific entry by reference -const item = await inventory.findById(9001); -await inventory.remove(item); - -// Remove all out-of-stock entries using a predicate -await inventory.remove(entry => entry.data.InStock === false); -``` - -### Applying server diffs - -`applyDiff(diff)` → `Promise` - -Used internally by the Fliplet native update pipeline to apply incremental server changes to the local bundle. - -| Field | Type | Description | -|---|---|---| -| `updated` | Array | Entries to upsert (replace or append) | -| `deleted` | Array | Entries to remove by id | -| `fullRefresh` | Boolean | When `true`, replaces the entire local collection with `updated` | +| Task | Reference and methods | +|---|---| +| Read, filter and paginate | [Reading data](datasources/reading-data.md): `find`, `query`, `findOne`, `findById`, `findWithCursor`, cursor methods, `getIndex`, `getIndexes` and aggregation | +| Query operators | [Query operators](datasources/query-operators.md): JSON-safe filters and separate server rule lookup semantics | +| Related records | [Joins](datasources/joins.md): named joins, reductions and pagination limits | +| Session-bound filters | [Views](datasources/views.md): definitions, requested views and security boundaries | +| Write records | [Writing data](datasources/writing-data.md): `insert`, `update`, `removeById`, query writes, `append`, `replaceWith` and `commit` | +| Attach files or import | [File attachments](datasources/file-attachments.md): attachment inputs, limits and `import` capability boundaries | +| Listen for changes | [Subscriptions](datasources/subscriptions.md): `subscribe` and subscription handle methods | +| Native local storage | [Offline database](datasources/offline-database.md): `Database`, handles and collections | +| Manage source settings | [Managing data sources](datasources/managing-data-sources.md): `get`, `getById`, `create`, namespace `update`, `delete` and source roles | +| Authenticate app users | [App authentication](v3/auth.md): `sendValidation` and `validate` | +| Secure and verify access | [Security entry](../Data-source-security.md), [rule reference](datasources/security-rules.md), [policy examples](datasources/security-examples.md) and [preview testing](datasources/testing-security.md) | +| Server hooks | [Data source hooks](../Data-Source-Hooks.md) | + +## Connect to a source + +`Fliplet.DataSources.connect(id, connectionOptions?)` and `Fliplet.DataSources.connectByName(name, connectionOptions?)` return `Promise`. The options-object overload `connect({ name, offline })` also selects a named source. + +| Argument | Type and behavior | +|---|---| +| `id` | Required source identifier for the ID form. A missing/falsy identifier rejects. | +| `name` | Required source name for the named form. Name lookup uses the current request context; it does not grant cross-app access. | +| `connectionOptions.offline` | Optional Boolean. `false` forces online operations. When omitted, native apps can use bundled storage if the Database API is available. `true` does not enable native storage in a browser. | ```js -const db = await Fliplet.DataSources.Database({ id: 42 }); -const products = db.dataSource(42); - -// Incremental update from server -await products.applyDiff({ - updated: [ - { id: 101, data: { Name: 'Laptop Pro', Price: 1299.99, InStock: true } }, - { id: 102, data: { Name: 'USB-C Hub', Price: 49.99, InStock: true } } - ], - deleted: [ - { id: 88 } - ] -}); - -// Full refresh — replace the entire local collection -await products.applyDiff({ - fullRefresh: true, - updated: [ - { id: 201, data: { Name: 'Monitor 4K', Price: 499.99, InStock: true } } - ], - deleted: [] -}); +const online = await Fliplet.DataSources.connectByName('Directory', { offline: false }); +const byId = await Fliplet.DataSources.connect(123, { offline: false }); ``` -### Indexes - -#### `indexes(columnsArray)` → `Promise` - -Returns unique values per column across all entries in the collection. If the bundle already contains a pre-computed `indexes` map, that is used directly; otherwise unique values are derived by scanning entries. - -```js -const db = await Fliplet.DataSources.Database({ id: 55 }); -const inventory = db.dataSource(55); +The SDK may reuse a connection for the same identifier and offline setting. Connecting establishes a handle; it does not prove a successful server query or authorized access. Native bundle loading is lazy and failed local loading can cause later reads to use the server. There is no public connection `close`, `disconnect` or `destroy` method. Unsubscribe each listener when its owner is disposed. -const uniqueValues = await inventory.indexes(['Category', 'InStock']); -// { Category: ['Electronics', 'Accessories'], InStock: [true, false] } -``` +## Security and verification -#### `index(columnName)` → `Promise` +Query reads and record reads evaluate different requirement targets. Object update requirements inspect stored rows, not proposed values. Field restrictions vary across single and bulk writes. Views, projections and client hooks do not replace server access rules. -Returns unique values for a single column. +For authorization tests, use an online connection, the intended app identity and confirmed preview enforcement. Check denied operations, returned data and persisted effects. Administrative calls, local results and preview with enforcement disabled cannot establish app-user authorization. A resolved write may be queued or pending; use the write reference's persistence checks. -```js -const categories = await inventory.index('Category'); -// ['Electronics', 'Accessories', 'Peripherals'] -``` +## SDK helper boundaries ---- +These exposed helpers have narrower purposes than record CRUD. They are not additional authorization or synchronization APIs. -[Back to API documentation](../API-Documentation) -{: .buttons} +| Helper | Signature and outcome | +|---|---| +| Temporary IDs | `isTempId(id)` returns a Boolean synchronously; true for strings starting with `temp_`. A false result alone does not prove server persistence. | +| Connection lookup | `clearActiveConnections()` returns `undefined` and resets the reuse lookup. It does not unsubscribe existing listeners. | +| Client hook registration | `bindHooks(id, hooks)` registers an object synchronously and rejects duplicate bindings or nonobject inputs by throwing. These run in the client, separately from server hooks. | +| Client hook lookup | `hasHook(id)` returns a Boolean synchronously. | +| Client hook application | `applyHooks(id, method, entries)` invokes the bound callback. Array input returns `Promise.all` of callback results; a single item returns the callback's value/Promise. Without a callback it resolves `undefined`. | +| Mapped-row convenience | `fetchWithOptions(options)` returns a Promise. See [mapped rows](datasources/managing-data-sources.md#mapped-row-helper) for its different result shape and options. | + +Underscore-prefixed tracking/reconciliation helpers and core queue-storage helpers are internal implementation surfaces. Use the documented insert/update outcomes rather than manipulating the queue. Native `applyDiff` belongs to the update pipeline, not a custom app synchronization recipe. + +## Existing reference links + +Existing method-family fragments route to the references below. These references own the current signatures and outcomes. + + + + + + + + + + + + + + + + + + + +[Read methods, sorting, cursors and aggregation](datasources/reading-data.md). + + + + + + + + +[Connection setup and asynchronous behavior](#connect-to-a-source). + + + +[Query operators](datasources/query-operators.md). + + + + + + + + + + +[Individual and batch writes](datasources/writing-data.md). + + + + + + + + +[Subscriptions and lifecycle cleanup](datasources/subscriptions.md). + + + +[File attachments and import boundaries](datasources/file-attachments.md). + + + + + + + + +[Source management and setup](datasources/managing-data-sources.md). + + + +[Related records and joins](datasources/joins.md). + + + +[Requested views](datasources/views.md). + + + + + + + + + + + + + + + + +[Native Database handles and collection methods](datasources/offline-database.md). diff --git a/docs/bin/__tests__/datasource-reference-docs.test.mjs b/docs/bin/__tests__/datasource-reference-docs.test.mjs new file mode 100644 index 00000000..e8f4a941 --- /dev/null +++ b/docs/bin/__tests__/datasource-reference-docs.test.mjs @@ -0,0 +1,47 @@ +import { spawnSync } from 'node:child_process'; +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { collectDocs, validateFrontmatter, emitV3LibraryCatalog, CLUSTERS } from '../build-agent-indexes.mjs'; +import { copySiblings } from '../copy-md-siblings.mjs'; +const source = fileURLToPath(new URL('../../', import.meta.url)); +const paths = ['API/fliplet-datasources.md', ...['reading-data', 'writing-data', 'file-attachments', 'subscriptions', 'offline-database', 'managing-data-sources', 'query-operators', 'joins', 'views'].map(name => `API/datasources/${name}.md`)]; +it('discovers split SDK references and copies exact raw pages without adding namespace owners', () => { + const root = mkdtempSync(join(tmpdir(), 'datasource-reference-')); + try { + for (const path of paths) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), readFileSync(join(source, path))); + } + const docs = collectDocs(root); + assert.equal(docs.length, paths.length); + assert.deepEqual(validateFrontmatter(docs), []); + const cluster = CLUSTERS.find(cluster => cluster.name === 'fliplet-data-sources'); + for (const path of paths) assert.ok(cluster.match(path), path); + const catalog = emitV3LibraryCatalog(docs); + assert.equal(catalog.libraries.length, 1); + assert.equal(catalog.libraries[0].docUrl, 'https://developers.fliplet.com/API/fliplet-datasources.md'); + const out = join(root, '_site'); mkdirSync(out); + assert.equal(copySiblings(root, out), paths.length); + for (const path of paths) assert.equal(readFileSync(join(out, path), 'utf8'), readFileSync(join(source, path), 'utf8')); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +it('SDK reference JSON and plain JavaScript examples parse independently', () => { + const AsyncFunction = Object.getPrototypeOf(async function() {}).constructor; + let blocks = 0; + for (const path of paths) { + const body = readFileSync(join(source, path), 'utf8'); + for (const match of body.matchAll(/```(json|js)\n([\s\S]*?)```/g)) { + blocks++; + if (match[1] === 'json') JSON.parse(match[2]); + else if (/^import /m.test(match[2])) { + const parsed = spawnSync(process.execPath, ['--check', '--input-type=module'], { input: match[2], encoding: 'utf8' }); + assert.equal(parsed.status, 0, `${path}: ${parsed.stderr}`); + } else assert.doesNotThrow(() => new AsyncFunction(match[2]), path); + } + } + assert.ok(blocks >= 20); +}); diff --git a/docs/v3/capabilities.md b/docs/v3/capabilities.md index 83fd3fa3..4f185b5d 100644 --- a/docs/v3/capabilities.md +++ b/docs/v3/capabilities.md @@ -16,7 +16,7 @@ Every Fliplet JS API available to V3 apps, grouped by capability category. Each ## Data - [`Fliplet.Cache`](https://developers.fliplet.com/API/core/cache.html) **(preloaded)** — Run async operations once and memoize their results, with optional expiry and background refresh. -- [`Fliplet.DataSources`](https://developers.fliplet.com/API/fliplet-datasources.html) — Connect to, query, insert, update, and delete records in Fliplet Data Sources from inside an app. All methods are promise-based. +- [`Fliplet.DataSources`](https://developers.fliplet.com/API/fliplet-datasources.html) — Connect to Fliplet data sources and find references for queries, writes, subscriptions, native storage and source management. - [`Fliplet.DataSources.Encryption`](https://developers.fliplet.com/API/fliplet-encryption.html) — Automatically encrypt and decrypt selected Data Source columns on-device by registering a private key and column list. - [`Fliplet.Storage and Fliplet.App.Storage`](https://developers.fliplet.com/API/core/storage.html) **(preloaded)** — Persist JSON-serializable values to device or browser storage, scoped globally or to the current app. From aad1baeb0bcd24a1a549356973910c2726effe75 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 8 Oct 2026 15:56:02 +0100 Subject: [PATCH 04/13] DEV-2201 Clarify deletion acknowledgement runtime prerequisite --- docs/.well-known/llms-full.txt | 2 +- docs/.well-known/llms-v3-libraries.json | 2 +- docs/API/datasources/writing-data.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 789c46c6..fc7aec54 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14435,7 +14435,7 @@ Object update requirements evaluate stored rows. They do not validate every prop `connection.removeById(id, { ack? })` deletes one entry. A missing/falsy ID throws synchronously. An online missing entry rejects with HTTP 404; authorization and source permission failures can reject with HTTP 400. -Online success resolves `{}`. With native reconciliation enabled by `ack` or the connection, the result can instead be the removed local entry. Development sample mode resolves without a value. No `connection.remove()` method is provided by the online connection; the [native collection API](offline-database.md) is separate. +Online success resolves `{}`. With native reconciliation enabled by `ack` or the connection, the result can instead be the removed local entry. This requires native database support to remain active; `ack: true` does not override an `offline: false` connection. Development sample mode resolves without a value. No `connection.remove()` method is provided by the online connection; the [native collection API](offline-database.md) is separate. ## Writes selected by a query diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index b405f2c1..b32888e4 100644 --- a/docs/.well-known/llms-v3-libraries.json +++ b/docs/.well-known/llms-v3-libraries.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-08T14:54:45.963Z", + "generatedAt": "2026-10-08T14:56:02.145Z", "libraries": [ { "package": "fliplet-analytics-spa", diff --git a/docs/API/datasources/writing-data.md b/docs/API/datasources/writing-data.md index 0dc7fd82..9ac2023e 100644 --- a/docs/API/datasources/writing-data.md +++ b/docs/API/datasources/writing-data.md @@ -103,7 +103,7 @@ Object update requirements evaluate stored rows. They do not validate every prop `connection.removeById(id, { ack? })` deletes one entry. A missing/falsy ID throws synchronously. An online missing entry rejects with HTTP 404; authorization and source permission failures can reject with HTTP 400. -Online success resolves `{}`. With native reconciliation enabled by `ack` or the connection, the result can instead be the removed local entry. Development sample mode resolves without a value. No `connection.remove()` method is provided by the online connection; the [native collection API](offline-database.md) is separate. +Online success resolves `{}`. With native reconciliation enabled by `ack` or the connection, the result can instead be the removed local entry. This requires native database support to remain active; `ack: true` does not override an `offline: false` connection. Development sample mode resolves without a value. No `connection.remove()` method is provided by the online connection; the [native collection API](offline-database.md) is separate. ## Writes selected by a query From a29ccee137635a500cab7aed791752fdae7f2125 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Fri, 9 Oct 2026 10:40:01 +0100 Subject: [PATCH 05/13] DEV-2201 Document authorization boundary for joined data sources --- docs/.well-known/llms-full.txt | 51 ++++++++++++++++++++++++ docs/.well-known/llms-v3-libraries.json | 2 +- docs/API/datasources/joins.md | 12 ++++++ docs/API/datasources/security-rules.md | 6 +++ docs/API/datasources/testing-security.md | 33 +++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index fc7aec54..91da6e1d 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -12005,6 +12005,16 @@ URL: https://developers.fliplet.com/API/datasources/joins.md Fetch related rows with named join configuration and read them from each record’s joins property. +## Security boundary for online joins + +An online join does not automatically enforce the joined source's access rules and field restrictions in the same way as a direct read. The primary query evaluates its source's select rules. The caller must be able to resolve the joined source through the source access lookup. This lookup does not establish enforcement of the joined source's read permission, access rules or column restrictions. The primary rule's `include` and `exclude` do not filter nested joined data. + +For example, joining public articles to a Users source available to the token can return user rows or fields that a direct Users read would restrict. Use only related data that the requesting audience is allowed to receive. Do not join private user records into a public response on the assumption that their direct-read rules protect them. + +Join `where` and `attributes` options select data for that request; they are caller-controlled and do not establish a security boundary. Before claiming privacy, compare direct and joined reads as allowed and denied actors and inspect all returned rows and nested fields. See the [access-rule reference](security-rules.md#joined-source-reads) and [preview security tests](testing-security.md#check-joined-source-responses). Native-local joins have a different execution context; online authorization evidence does not establish native behavior. + +## Define a join + Joins are defined by a unique name and their configuration options; any number of joins can be defined when fetching data from one data source: **Using async/await (recommended)** @@ -12018,6 +12028,7 @@ const result = await connection.find({ Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them + // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }); @@ -12035,6 +12046,7 @@ Fliplet.DataSources.connect(123).then(function (connection) { Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them + // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }) @@ -13931,6 +13943,12 @@ Column restrictions do not have a uniform write contract: A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. +### Joined-source reads + +Online joins evaluate the primary source's select rules. The caller must be able to resolve the joined source through the source access lookup. This lookup does not establish enforcement of the joined source's read permission, access rules or column restrictions. Primary-source `include` and `exclude` filtering does not sanitize nested joined data. A token-accessible joined source can therefore return rows or fields that its direct-read rules restrict. + +Do not treat direct-read authorization as proof of joined-data privacy. Caller-supplied join filters or projections are not enforceable protection against other callers. See [Data Source joins](joins.md#security-boundary-for-online-joins) for the contract and [joined-response tests](testing-security.md#check-joined-source-responses) before exposing related private data. + ## Custom scripts A nonempty script runs regardless of `type` and `allow`, subject to the enabled/app filters. It must check operation, authenticated identity and login source itself. Return `{ granted: true }` to grant; bare booleans, missing returns and `{ granted: false }` do not grant. A script grant returns immediately for that rule, so standard `require` is not an additional payload validator. @@ -14253,6 +14271,39 @@ For grants that admit commit/bulk operations, seed another owner's row and a sen Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. +## Check joined-source responses + +Use disposable primary and related sources with an allowed related row, another actor's private row and a sensitive column. Record both sources' rules and the caller's source access. As each intended actor, compare a direct read of the related source with a primary query that joins it. Include an actor whose primary read is allowed but whose direct related read is denied or restricted. + +Inspect every returned related row and nested field, including `entry.joins`, and compare them with the intended policy. Repeat without optional join `where` or `attributes` restrictions; cooperative projections do not prove another caller cannot request more data. Also check the case where the caller cannot access or resolve the related source. A primary-query denial alone does not test the joined-source boundary. + +For a disposable Articles source (123) and related Users source (789), seed `AuthorID` on an article with a matching Users record metadata `id`. Run both requests as the same actor. Capture each outcome independently so a denied direct read does not prevent the joined read: + +```js +const users = await Fliplet.DataSources.connect(789, { offline: false }); +const articles = await Fliplet.DataSources.connect(123, { offline: false }); + +try { + console.log('Direct Users read:', await users.find()); +} catch (error) { + console.log('Direct Users read failed:', error); +} + +try { + console.log('Joined Users read:', await articles.find({ + join: { + Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } + } + })); +} catch (error) { + console.log('Joined Users read failed:', error); +} +``` + +Use only disposable fixture data in these logs. Repeat the direct read with any owner filter required by the policy, and inspect each returned `entry.joins.Users` row and field. A rejected unfiltered direct read does not by itself establish owner-read behavior or joined-data privacy. + +The [online join contract](joins.md#security-boundary-for-online-joins) does not promise equivalent direct-read rule enforcement for related sources. If joined results violate the required policy, do not use that join for private data or report it as secure. Record the tested configuration and limitation. Check native-local joins separately when that runtime is required. + ## Diagnose a failure A policy denial must be supported by the error response and unchanged forbidden state. An HTTP 400 or 401 alone is insufficient because errors vary by method and caller. diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index b32888e4..cc5d4daa 100644 --- a/docs/.well-known/llms-v3-libraries.json +++ b/docs/.well-known/llms-v3-libraries.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-08T14:56:02.145Z", + "generatedAt": "2026-10-09T09:40:01.582Z", "libraries": [ { "package": "fliplet-analytics-spa", diff --git a/docs/API/datasources/joins.md b/docs/API/datasources/joins.md index 83b008f6..b4bf1b78 100644 --- a/docs/API/datasources/joins.md +++ b/docs/API/datasources/joins.md @@ -10,6 +10,16 @@ deprecated: false Fetch related rows with named join configuration and read them from each record’s joins property. +## Security boundary for online joins + +An online join does not automatically enforce the joined source's access rules and field restrictions in the same way as a direct read. The primary query evaluates its source's select rules. The caller must be able to resolve the joined source through the source access lookup. This lookup does not establish enforcement of the joined source's read permission, access rules or column restrictions. The primary rule's `include` and `exclude` do not filter nested joined data. + +For example, joining public articles to a Users source available to the token can return user rows or fields that a direct Users read would restrict. Use only related data that the requesting audience is allowed to receive. Do not join private user records into a public response on the assumption that their direct-read rules protect them. + +Join `where` and `attributes` options select data for that request; they are caller-controlled and do not establish a security boundary. Before claiming privacy, compare direct and joined reads as allowed and denied actors and inspect all returned rows and nested fields. See the [access-rule reference](security-rules.md#joined-source-reads) and [preview security tests](testing-security.md#check-joined-source-responses). Native-local joins have a different execution context; online authorization evidence does not establish native behavior. + +## Define a join + Joins are defined by a unique name and their configuration options; any number of joins can be defined when fetching data from one data source: **Using async/await (recommended)** @@ -23,6 +33,7 @@ const result = await connection.find({ Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them + // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }); @@ -40,6 +51,7 @@ Fliplet.DataSources.connect(123).then(function (connection) { Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them + // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }) diff --git a/docs/API/datasources/security-rules.md b/docs/API/datasources/security-rules.md index b334ec57..f6155442 100644 --- a/docs/API/datasources/security-rules.md +++ b/docs/API/datasources/security-rules.md @@ -145,6 +145,12 @@ Column restrictions do not have a uniform write contract: A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. +### Joined-source reads + +Online joins evaluate the primary source's select rules. The caller must be able to resolve the joined source through the source access lookup. This lookup does not establish enforcement of the joined source's read permission, access rules or column restrictions. Primary-source `include` and `exclude` filtering does not sanitize nested joined data. A token-accessible joined source can therefore return rows or fields that its direct-read rules restrict. + +Do not treat direct-read authorization as proof of joined-data privacy. Caller-supplied join filters or projections are not enforceable protection against other callers. See [Data Source joins](joins.md#security-boundary-for-online-joins) for the contract and [joined-response tests](testing-security.md#check-joined-source-responses) before exposing related private data. + ## Custom scripts A nonempty script runs regardless of `type` and `allow`, subject to the enabled/app filters. It must check operation, authenticated identity and login source itself. Return `{ granted: true }` to grant; bare booleans, missing returns and `{ granted: false }` do not grant. A script grant returns immediately for that rule, so standard `require` is not an additional payload validator. diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md index fe93eb14..af5fbfd7 100644 --- a/docs/API/datasources/testing-security.md +++ b/docs/API/datasources/testing-security.md @@ -71,6 +71,39 @@ For grants that admit commit/bulk operations, seed another owner's row and a sen Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. +## Check joined-source responses + +Use disposable primary and related sources with an allowed related row, another actor's private row and a sensitive column. Record both sources' rules and the caller's source access. As each intended actor, compare a direct read of the related source with a primary query that joins it. Include an actor whose primary read is allowed but whose direct related read is denied or restricted. + +Inspect every returned related row and nested field, including `entry.joins`, and compare them with the intended policy. Repeat without optional join `where` or `attributes` restrictions; cooperative projections do not prove another caller cannot request more data. Also check the case where the caller cannot access or resolve the related source. A primary-query denial alone does not test the joined-source boundary. + +For a disposable Articles source (123) and related Users source (789), seed `AuthorID` on an article with a matching Users record metadata `id`. Run both requests as the same actor. Capture each outcome independently so a denied direct read does not prevent the joined read: + +```js +const users = await Fliplet.DataSources.connect(789, { offline: false }); +const articles = await Fliplet.DataSources.connect(123, { offline: false }); + +try { + console.log('Direct Users read:', await users.find()); +} catch (error) { + console.log('Direct Users read failed:', error); +} + +try { + console.log('Joined Users read:', await articles.find({ + join: { + Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } + } + })); +} catch (error) { + console.log('Joined Users read failed:', error); +} +``` + +Use only disposable fixture data in these logs. Repeat the direct read with any owner filter required by the policy, and inspect each returned `entry.joins.Users` row and field. A rejected unfiltered direct read does not by itself establish owner-read behavior or joined-data privacy. + +The [online join contract](joins.md#security-boundary-for-online-joins) does not promise equivalent direct-read rule enforcement for related sources. If joined results violate the required policy, do not use that join for private data or report it as secure. Record the tested configuration and limitation. Check native-local joins separately when that runtime is required. + ## Diagnose a failure A policy denial must be supported by the error response and unchanged forbidden state. An HTTP 400 or 401 alone is insufficient because errors vary by method and caller. From 27637818446b0d0521eb319d85eada24053571b6 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Mon, 12 Oct 2026 13:43:12 +0100 Subject: [PATCH 06/13] DEV-2201 Align joins example comment indentation Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 2 +- docs/API/datasources/joins.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 91da6e1d..a3629cdb 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -12046,7 +12046,7 @@ Fliplet.DataSources.connect(123).then(function (connection) { Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them - // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. + // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }) diff --git a/docs/API/datasources/joins.md b/docs/API/datasources/joins.md index b4bf1b78..810d571c 100644 --- a/docs/API/datasources/joins.md +++ b/docs/API/datasources/joins.md @@ -51,7 +51,7 @@ Fliplet.DataSources.connect(123).then(function (connection) { Comments: { dataSourceId: 456, on: { 'id': 'data.ArticleID' } }, // ... and users who posted them - // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. + // Use only rows and fields safe for this audience; direct-read rules do not protect joined results. Users: { dataSourceId: 789, on: { 'data.AuthorID': 'id' } } } }) From 48c27fa8897c19eb6220ff37cf2e5950f870f8fc Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Tue, 13 Oct 2026 17:15:20 +0100 Subject: [PATCH 07/13] DEV-2201 Document redacted hashed fields in responses Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 4 ++++ docs/API/datasources/testing-security.md | 2 ++ docs/Data-Source-Hooks.md | 2 ++ 3 files changed, 8 insertions(+) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index a3629cdb..77291d2e 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14269,6 +14269,8 @@ Test `findOne({ where: ... })` separately from `findById(id)` if the app uses bo For grants that admit commit/bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data append or commit with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. +A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. + Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. ## Check joined-source responses @@ -35077,6 +35079,8 @@ Here's an example where the field named "Password" will be hashed before saving ``` {% endraw %} +The value is stored as a SHA-256 hash; a value that is already 64 characters long is treated as hashed and stored unchanged. API responses never return a hashed field: reads, queries and commit responses replace its value with `**REDACTED**`, and writing the placeholder back keeps the stored hash. Seeing `**REDACTED**` means the field is hashed and protected, not that the password was exposed. To check a password, sign in with it rather than reading the field. + And one more example where one field gets trimmed and converted to lowercase while the second field is converted to uppercase: {% raw %} diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md index af5fbfd7..856de37c 100644 --- a/docs/API/datasources/testing-security.md +++ b/docs/API/datasources/testing-security.md @@ -69,6 +69,8 @@ Test `findOne({ where: ... })` separately from `findById(id)` if the app uses bo For grants that admit commit/bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data append or commit with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. +A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. + Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. ## Check joined-source responses diff --git a/docs/Data-Source-Hooks.md b/docs/Data-Source-Hooks.md index 732a6872..d69dccb0 100644 --- a/docs/Data-Source-Hooks.md +++ b/docs/Data-Source-Hooks.md @@ -222,6 +222,8 @@ Here's an example where the field named "Password" will be hashed before saving ``` {% endraw %} +The value is stored as a SHA-256 hash; a value that is already 64 characters long is treated as hashed and stored unchanged. API responses never return a hashed field: reads, queries and commit responses replace its value with `**REDACTED**`, and writing the placeholder back keeps the stored hash. Seeing `**REDACTED**` means the field is hashed and protected, not that the password was exposed. To check a password, sign in with it rather than reading the field. + And one more example where one field gets trimmed and converted to lowercase while the second field is converted to uppercase: {% raw %} From a91155a2c68b87f7cb4105fe22ceb46a87375e80 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Wed, 21 Oct 2026 19:02:01 +0100 Subject: [PATCH 08/13] DEV-2201 Describe commit() after the access-rule fix and drop write-withholding advice Commits by app users now apply the matching rule's exclude/include, merge updates onto the stored row and return only the touched rows filtered by the caller's read rule; append() and replaceWith() still do not filter. The "leave writes unavailable until a trusted write path" instructions led the builder to refuse approval workflows, so replace them with a requests and approvals example built on per-actor exclusions. Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 87 +++++++++++++++---- docs/API/datasources/security-examples.md | 63 ++++++++++++-- docs/API/datasources/security-rules.md | 9 +- docs/API/datasources/testing-security.md | 8 +- docs/API/datasources/writing-data.md | 4 +- docs/Data-source-security.md | 4 +- .../datasource-security-docs.test.mjs | 2 +- 7 files changed, 144 insertions(+), 33 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 8a9a9f72..15fd8965 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14641,7 +14641,7 @@ Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: ] ``` -This illustrates the named read checks, not complete source privacy. Write/bulk response routes and actual source permissions require independent verification before using the source for private data. A select-only rule array does not prove every commit request is denied or its response isolated. Do not rely on UI availability, cooperative response options or evaluator-only tests to establish privacy. +This illustrates the named read checks, not complete source privacy. The source's own permissions and any bulk `append()`/`replaceWith()` access still need their own tests before using the source for private data. Do not rely on UI availability, cooperative response options or evaluator-only tests to establish privacy. Save this select-only illustration: @@ -14681,7 +14681,7 @@ The query rule validates `where`; it does not add an owner filter. `findById(id) ## Editable profiles -**Goal:** An enabled member updates their stored profile through single-record writes while excluded fields remain unchanged. This example does not guarantee protection on bulk/commit writes. +**Goal:** An enabled member updates their stored profile through single-record writes while excluded fields remain unchanged. Single-record writes and `commit()` drop the excluded fields; bulk `append()` and `replaceWith()` do not. Create **Performer Profiles** with columns `OwnerEmail`, `DisplayName` and `Badge`. Seed: @@ -14692,7 +14692,7 @@ Create **Performer Profiles** with columns `OwnerEmail`, `DisplayName` and `Badg ] ``` -For immutable ownership or protected badges across **all** endpoints, leave app-session updates disabled until an implemented trusted write path has been verified against source and runtime tests. This example does not provide that guarantee: bulk/commit updates can write fields that single-record `exclude` filters out. Omitting bulk methods from the UI is not server enforcement. A script's `query` can also come from caller-supplied `body.where`, so proposed-payload validation cannot be assumed even when `entry.data` exists. +Single updates and `commit()` leave `OwnerEmail` and `Badge` unchanged; commit updates merge onto the stored row. Bulk `append()` and `replaceWith()` do not apply exclusions, and omitting them from the UI is not server enforcement. A script's `query` can also come from caller-supplied `body.where`, so proposed-payload validation cannot be assumed even when `entry.data` exists. Save: @@ -14736,7 +14736,7 @@ Also submit a single update of Ada's row with `Badge: 'Director'` and `OwnerEmai Create **Festival Reviews** with columns `WorkTitle`, `Stage` and `Comment`. Seed `{ "WorkTitle": "River quartet", "Stage": "Pending", "Comment": "" }`. -This policy permits changing Pending directly to any next stage. Do not use it as approval-transition enforcement. Leave strict workflow writes disabled until an implemented trusted write path and actor-operation tests verify caller role, stored stage, proposed stage and protected fields across all allowed endpoints. +The requirement checks the stored stage, not the proposed one, so an approver can move a Pending review to any stage. For a workflow where other people submit and approvers decide, see [requests and approvals](#requests-and-approvals). Save this illustrative stored-state policy: @@ -14767,7 +14767,59 @@ async function addReviewComment(reviewId) { Denied tests: Lin (`Roles: ['Author']`), a user with only `['SuperApprover']`, an inactive user or an anonymous caller attempting that update. Ada updating a seeded `Stage: 'Closed'` row must also be denied. Insert and delete have no grant. -**Transition limit:** Ada can also submit `Stage: 'Published'` while the stored row is Pending. The requirement does not validate the next stage. Do not deploy this example as an approval-transition policy. A strict workflow needs server checks of caller role, stored stage, proposed stage and protected fields on every allowed write path. A single-update script can inspect `entry.data`, but commit lacks `entry`, and even a single-update request can supply `body.where` instead of the proposed payload to the script. Keep strict workflow writes unavailable until an implemented trusted write path and its actor-operation tests establish the required contract. Frontend guards and rule-query mutation do not supply that guarantee. +**Transition limit:** Ada can also submit `Stage: 'Published'` while the stored row is Pending. Rules cannot restrict which next stage an approver chooses; that choice is the approver's to make. What rules enforce is who may change `Stage` at all: exclude it from every other actor's insert and update rules, as in [requests and approvals](#requests-and-approvals). Do not rely on a script to validate the proposed stage: a single-update request can supply `body.where` instead of the proposed payload to the script. Frontend guards and rule-query mutation do not supply that guarantee either. + +## Requests and approvals + +**Goal:** Members submit requests and can edit their own; approvers decide. Nobody decides their own request, and members can never set the decision fields. + +Create **Purchase Requests** with columns `OwnerEmail`, `Item`, `Amount`, `Status` and `DecidedBy`. Seed one request owned by Lin: `{ "OwnerEmail": "lin@example.org", "Item": "Monitor", "Amount": 180, "Status": "", "DecidedBy": "" }`. + +Rules run most privileged first. The approver rule's stored-row requirement stops an approver deciding their own request; Ada's own requests fall through to the member rules. + +{% raw %} +```json +[ + { "type": ["select"], "allow": { "dataSourceId": 731, "user": { "Enabled": true, "Roles": "Approver" } } }, + { + "type": ["update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true, "Roles": "Approver" } }, + "require": [{ "OwnerEmail": { "notequals": "{{user.[Email]}}" } }], + "exclude": ["OwnerEmail"] + }, + { + "type": ["select"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] + }, + { + "type": ["insert"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], + "exclude": ["Status", "DecidedBy"] + }, + { + "type": ["update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], + "exclude": ["OwnerEmail", "Status", "DecidedBy"] + } +] +``` +{% endraw %} + +As Lin, submitting a decision is filtered rather than rejected: + +```js +async function trySelfApproval(requestId) { + const requests = await Fliplet.DataSources.connectByName('Purchase Requests', { offline: false }); + await requests.update(requestId, { Item: 'Monitor arm', Status: 'Approved', DecidedBy: 'lin@example.org' }); + const saved = await requests.findById(requestId); + console.log(saved.data.Item, saved.data.Status); // 'Monitor arm' '' +} +``` + +As Ada, updating Lin's request with `Status: 'Approved'` and `DecidedBy: 'ada@example.org'` saves both. Denied tests: Ada approving a request she owns keeps `Status` unchanged, Lin reading or updating another member's request is denied, and an anonymous or wrong-source caller is denied. Excluded fields start empty on insert, so treat an empty `Status` as pending in the app. To stop edits after a decision, add a stored-state requirement to the member update rule and test it against the values the app actually stores, including empty. --- @@ -14875,7 +14927,7 @@ Query writes first require a `select` grant for the submitted `where`. In the in ### Requirement operators -This select-only illustration compares the submitted read filter or stored record to the caller. On updates, the same object condition would authorize the **stored** owner's row without prohibiting a proposed change to `OwnerEmail`. See the explicitly limited [single-record profile example](security-examples.md#editable-profiles) before configuring updates; its exclusions do not protect bulk/commit writes. +This select-only illustration compares the submitted read filter or stored record to the caller. On updates, the same object condition would authorize the **stored** owner's row without prohibiting a proposed change to `OwnerEmail`. See the explicitly limited [single-record profile example](security-examples.md#editable-profiles) before configuring updates; its exclusions do not apply to bulk `append()` or `replaceWith()`. {% raw %} ```json @@ -14903,7 +14955,7 @@ When the read-filtering helpers run, source-level `definition.include`/`definiti The inspected query/record read routes invoke each helper when the matching rule has the corresponding **nonempty** list. Do not infer that a source list alone is always applied by those routes. These helpers filter read responses; they do not establish write-persistence restrictions. -Successful write authorization does not itself prove that returned rows or columns satisfy read restrictions. The inspected `commit()` response can return source entries without owner-select filtering or rule column filtering; hash redaction is separate. Source permissions and caller-supplied response options also affect this path. Test response isolation independently before choosing a writable policy for private data; setting `returnEntries: false` in cooperative clients does not enforce that option on other callers. +For app users, a `commit()` response contains only the rows that commit touched, each checked against the caller's own `select` rule: rows the caller cannot read are dropped and the read `include`/`exclude` applies. With no `select` rule the response has no entries. Studio, administrative and API-token commits still return the source. Other write routes do not filter their responses this way; test them before relying on a writable policy for private data. Column restrictions do not have a uniform write contract: @@ -14911,10 +14963,11 @@ Column restrictions do not have a uniform write contract: |---|---|---| | Select query/record response | Removes excluded data columns | Restricts returned data columns after exclusion; a field removed by `exclude` is not restored by `include` | | Single insert/update | Removes submitted excluded fields before persistence; this is filtering, not necessarily a rejected request | Does not reliably filter the persisted single-write payload | -| Bulk append/replace/commit | Does not filter each persisted entry | Does not filter each persisted entry | +| `commit()` by app users | Removes excluded fields from inserts and updates; updates merge onto the stored row, so unsent and excluded fields keep their stored values | Restricts written fields to the list | +| Bulk `append()` / `replaceWith()` | Does not filter each persisted entry | Does not filter each persisted entry | | Query update | Omits matched exclusions from affected-row update data when operation checks run | Not a write whitelist | -A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. +A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. `commit()` applies the same exclusions for app users; bulk `append()` and `replaceWith()` do not, so an `exclude` policy does not protect those two paths. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. ### Joined-source reads @@ -15197,7 +15250,7 @@ Create dedicated test data sources and records. Record the saved rule array, log | Inactive member | Protected write | Denied | | Approver with multiple roles | Supported review operation | Allowed | | Member without required role | Same operation | Denied | -| Bound member with commit/bulk access | Own-data write with response enabled/default | Returned rows and columns satisfy the intended read isolation | +| Bound member with commit access | Own-data commit with response enabled/default | Only the touched rows come back, filtered by the member's read rule | | Denied or wrong-source actor | Direct response-bearing request | No forbidden data returned or persisted | Adapt the matrix to the configured policy. Include missing/null identity fields, different native types and each write method your app uses. For protected ownership, privilege or state fields, attempt malicious payloads directly through the SDK as well as through the UI. A hidden button does not stop an SDK call. @@ -15236,15 +15289,15 @@ Record the successful server response and returned rows. This read illustration For every allowed write, perform a fresh read and check the exact saved values. For every forbidden write, inspect the rejection and verify that no forbidden insert, update or delete persisted. Use a trusted Studio readback where the app's read rules hide the affected fields; label it as persistence evidence, not an app-user access test. -Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate single-record `exclude` behavior to bulk writes. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. +Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate `exclude` behavior to bulk `append()` or `replaceWith()`. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. ## Check write responses -For grants that admit commit/bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data append or commit with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. +For grants that admit commit or bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data commit, and an append if the app uses one, with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. An app user's commit response should hold only the touched rows, filtered by their read rule. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. -Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. +A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. ## Check joined-source responses @@ -15562,7 +15615,9 @@ if (mapping) { } ``` -The online result is `{ entries?, clientIds: [{ id, clientId }, ...] }`. By default, `entries` can contain the source dataset, not only changed rows. Hashed fields are redacted, but query-read filters and projections must not be assumed to protect this write response. Test returned rows and columns as each actor before enabling commits on private data. A cooperative caller's `returnEntries: false` is not authorization enforcement. See [write-response tests](testing-security.md#check-write-responses). +The online result is `{ entries?, clientIds: [{ id, clientId }, ...] }`. For app users, `entries` holds only the rows this commit touched, each filtered by the caller's `select` rule, and is empty when no `select` rule grants access. Studio, administrative and API-token commits return the source dataset. Hashed fields are redacted. + +Commits by app users also follow the write rules: each insert and update drops the fields its matching rule excludes (or does not include), and updates merge onto the stored row, so unsent and excluded fields keep their stored values. `where` is ignored, `deleteColumns` and `renameColumns` are refused, and `columns` can only add columns. Test returned rows and saved values as each actor before enabling commits on private data. A cooperative caller's `returnEntries: false` is not authorization enforcement. See [write-response tests](testing-security.md#check-write-responses). ## Hooks and completion @@ -36130,9 +36185,9 @@ Use this only for data intended for anonymous readers. It grants no insert, upda Rules grant access in order. A failed rule falls through for writes as well as reads, so a later broad grant can defeat an earlier restriction. -Object conditions on updates check the **stored row**, while string requirements check submitted key presence. A condition that checks stored `Owner` or `Status` does not prevent a permitted write from changing those fields. Single-record writes honor `exclude`, but bulk/commit writes do not provide the same field protection. Custom scripts receive a copied input; assigning audit fields to that copy does not reliably persist them. +Object conditions on updates check the **stored row**, while string requirements check submitted key presence. A condition on stored `Owner` or `Status` decides whether the caller may update that row; it does not stop them changing those fields. Exclude the fields each actor must not set from that actor's insert and update rules: single-record writes and `commit()` both drop excluded fields, and commit updates merge onto the stored row. Bulk `append()` and `replaceWith()` do not apply exclusions. Custom scripts receive a copied input; assigning audit fields to that copy does not reliably persist them. -For immutable ownership or approval transitions, keep app-user writes unavailable until an implemented trusted write path has been verified to check both stored and proposed values on every supported endpoint. See [role and state limits](API/datasources/security-examples.md#role-and-state-access) before choosing a workflow policy. +For workflows where one person submits and another decides, see [requests and approvals](API/datasources/security-examples.md#requests-and-approvals). ## Existing section links diff --git a/docs/API/datasources/security-examples.md b/docs/API/datasources/security-examples.md index 05d38698..1f7d34fb 100644 --- a/docs/API/datasources/security-examples.md +++ b/docs/API/datasources/security-examples.md @@ -118,7 +118,7 @@ Create **Composition Sketches** with columns `OwnerEmail` and `Title`. Seed: ] ``` -This illustrates the named read checks, not complete source privacy. Write/bulk response routes and actual source permissions require independent verification before using the source for private data. A select-only rule array does not prove every commit request is denied or its response isolated. Do not rely on UI availability, cooperative response options or evaluator-only tests to establish privacy. +This illustrates the named read checks, not complete source privacy. The source's own permissions and any bulk `append()`/`replaceWith()` access still need their own tests before using the source for private data. Do not rely on UI availability, cooperative response options or evaluator-only tests to establish privacy. Save this select-only illustration: @@ -158,7 +158,7 @@ The query rule validates `where`; it does not add an owner filter. `findById(id) ## Editable profiles -**Goal:** An enabled member updates their stored profile through single-record writes while excluded fields remain unchanged. This example does not guarantee protection on bulk/commit writes. +**Goal:** An enabled member updates their stored profile through single-record writes while excluded fields remain unchanged. Single-record writes and `commit()` drop the excluded fields; bulk `append()` and `replaceWith()` do not. Create **Performer Profiles** with columns `OwnerEmail`, `DisplayName` and `Badge`. Seed: @@ -169,7 +169,7 @@ Create **Performer Profiles** with columns `OwnerEmail`, `DisplayName` and `Badg ] ``` -For immutable ownership or protected badges across **all** endpoints, leave app-session updates disabled until an implemented trusted write path has been verified against source and runtime tests. This example does not provide that guarantee: bulk/commit updates can write fields that single-record `exclude` filters out. Omitting bulk methods from the UI is not server enforcement. A script's `query` can also come from caller-supplied `body.where`, so proposed-payload validation cannot be assumed even when `entry.data` exists. +Single updates and `commit()` leave `OwnerEmail` and `Badge` unchanged; commit updates merge onto the stored row. Bulk `append()` and `replaceWith()` do not apply exclusions, and omitting them from the UI is not server enforcement. A script's `query` can also come from caller-supplied `body.where`, so proposed-payload validation cannot be assumed even when `entry.data` exists. Save: @@ -213,7 +213,7 @@ Also submit a single update of Ada's row with `Badge: 'Director'` and `OwnerEmai Create **Festival Reviews** with columns `WorkTitle`, `Stage` and `Comment`. Seed `{ "WorkTitle": "River quartet", "Stage": "Pending", "Comment": "" }`. -This policy permits changing Pending directly to any next stage. Do not use it as approval-transition enforcement. Leave strict workflow writes disabled until an implemented trusted write path and actor-operation tests verify caller role, stored stage, proposed stage and protected fields across all allowed endpoints. +The requirement checks the stored stage, not the proposed one, so an approver can move a Pending review to any stage. For a workflow where other people submit and approvers decide, see [requests and approvals](#requests-and-approvals). Save this illustrative stored-state policy: @@ -244,4 +244,57 @@ async function addReviewComment(reviewId) { Denied tests: Lin (`Roles: ['Author']`), a user with only `['SuperApprover']`, an inactive user or an anonymous caller attempting that update. Ada updating a seeded `Stage: 'Closed'` row must also be denied. Insert and delete have no grant. -**Transition limit:** Ada can also submit `Stage: 'Published'` while the stored row is Pending. The requirement does not validate the next stage. Do not deploy this example as an approval-transition policy. A strict workflow needs server checks of caller role, stored stage, proposed stage and protected fields on every allowed write path. A single-update script can inspect `entry.data`, but commit lacks `entry`, and even a single-update request can supply `body.where` instead of the proposed payload to the script. Keep strict workflow writes unavailable until an implemented trusted write path and its actor-operation tests establish the required contract. Frontend guards and rule-query mutation do not supply that guarantee. +**Transition limit:** Ada can also submit `Stage: 'Published'` while the stored row is Pending. Rules cannot restrict which next stage an approver chooses; that choice is the approver's to make. What rules enforce is who may change `Stage` at all: exclude it from every other actor's insert and update rules, as in [requests and approvals](#requests-and-approvals). Do not rely on a script to validate the proposed stage: a single-update request can supply `body.where` instead of the proposed payload to the script. Frontend guards and rule-query mutation do not supply that guarantee either. + +## Requests and approvals + +**Goal:** Members submit requests and can edit their own; approvers decide. Nobody decides their own request, and members can never set the decision fields. + +Create **Purchase Requests** with columns `OwnerEmail`, `Item`, `Amount`, `Status` and `DecidedBy`. Seed one request owned by Lin: `{ "OwnerEmail": "lin@example.org", "Item": "Monitor", "Amount": 180, "Status": "", "DecidedBy": "" }`. + +Rules run most privileged first. The approver rule's stored-row requirement stops an approver deciding their own request; Ada's own requests fall through to the member rules. + +{% raw %} +```json +[ + { "type": ["select"], "allow": { "dataSourceId": 731, "user": { "Enabled": true, "Roles": "Approver" } } }, + { + "type": ["update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true, "Roles": "Approver" } }, + "require": [{ "OwnerEmail": { "notequals": "{{user.[Email]}}" } }], + "exclude": ["OwnerEmail"] + }, + { + "type": ["select"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }] + }, + { + "type": ["insert"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], + "exclude": ["Status", "DecidedBy"] + }, + { + "type": ["update"], + "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], + "exclude": ["OwnerEmail", "Status", "DecidedBy"] + } +] +``` +{% endraw %} + +As Lin, submitting a decision is filtered rather than rejected: + +```js +async function trySelfApproval(requestId) { + const requests = await Fliplet.DataSources.connectByName('Purchase Requests', { offline: false }); + await requests.update(requestId, { Item: 'Monitor arm', Status: 'Approved', DecidedBy: 'lin@example.org' }); + const saved = await requests.findById(requestId); + console.log(saved.data.Item, saved.data.Status); // 'Monitor arm' '' +} +``` + +As Ada, updating Lin's request with `Status: 'Approved'` and `DecidedBy: 'ada@example.org'` saves both. Denied tests: Ada approving a request she owns keeps `Status` unchanged, Lin reading or updating another member's request is denied, and an anonymous or wrong-source caller is denied. Excluded fields start empty on insert, so treat an empty `Status` as pending in the app. To stop edits after a decision, add a stored-state requirement to the member update rule and test it against the values the app actually stores, including empty. + diff --git a/docs/API/datasources/security-rules.md b/docs/API/datasources/security-rules.md index 69fd3fe3..c6f8928e 100644 --- a/docs/API/datasources/security-rules.md +++ b/docs/API/datasources/security-rules.md @@ -108,7 +108,7 @@ Query writes first require a `select` grant for the submitted `where`. In the in ### Requirement operators -This select-only illustration compares the submitted read filter or stored record to the caller. On updates, the same object condition would authorize the **stored** owner's row without prohibiting a proposed change to `OwnerEmail`. See the explicitly limited [single-record profile example](security-examples.md#editable-profiles) before configuring updates; its exclusions do not protect bulk/commit writes. +This select-only illustration compares the submitted read filter or stored record to the caller. On updates, the same object condition would authorize the **stored** owner's row without prohibiting a proposed change to `OwnerEmail`. See the explicitly limited [single-record profile example](security-examples.md#editable-profiles) before configuring updates; its exclusions do not apply to bulk `append()` or `replaceWith()`. {% raw %} ```json @@ -136,7 +136,7 @@ When the read-filtering helpers run, source-level `definition.include`/`definiti The inspected query/record read routes invoke each helper when the matching rule has the corresponding **nonempty** list. Do not infer that a source list alone is always applied by those routes. These helpers filter read responses; they do not establish write-persistence restrictions. -Successful write authorization does not itself prove that returned rows or columns satisfy read restrictions. The inspected `commit()` response can return source entries without owner-select filtering or rule column filtering; hash redaction is separate. Source permissions and caller-supplied response options also affect this path. Test response isolation independently before choosing a writable policy for private data; setting `returnEntries: false` in cooperative clients does not enforce that option on other callers. +For app users, a `commit()` response contains only the rows that commit touched, each checked against the caller's own `select` rule: rows the caller cannot read are dropped and the read `include`/`exclude` applies. With no `select` rule the response has no entries. Studio, administrative and API-token commits still return the source. Other write routes do not filter their responses this way; test them before relying on a writable policy for private data. Column restrictions do not have a uniform write contract: @@ -144,10 +144,11 @@ Column restrictions do not have a uniform write contract: |---|---|---| | Select query/record response | Removes excluded data columns | Restricts returned data columns after exclusion; a field removed by `exclude` is not restored by `include` | | Single insert/update | Removes submitted excluded fields before persistence; this is filtering, not necessarily a rejected request | Does not reliably filter the persisted single-write payload | -| Bulk append/replace/commit | Does not filter each persisted entry | Does not filter each persisted entry | +| `commit()` by app users | Removes excluded fields from inserts and updates; updates merge onto the stored row, so unsent and excluded fields keep their stored values | Restricts written fields to the list | +| Bulk `append()` / `replaceWith()` | Does not filter each persisted entry | Does not filter each persisted entry | | Query update | Omits matched exclusions from affected-row update data when operation checks run | Not a write whitelist | -A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. Do not use an `exclude` policy to promise protection on bulk/commit writes. Leave strict app-session writes unavailable until an implemented trusted write path has been verified. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. +A single update that submits an excluded privilege field can resolve successfully while leaving that field unchanged. It is incorrect to describe that outcome as an authorization error. `commit()` applies the same exclusions for app users; bulk `append()` and `replaceWith()` do not, so an `exclude` policy does not protect those two paths. Script input selection can substitute `body.where` for proposed data, so a script that checks `query` is not automatically a complete payload validator. ### Joined-source reads diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md index 856de37c..95c80464 100644 --- a/docs/API/datasources/testing-security.md +++ b/docs/API/datasources/testing-security.md @@ -24,7 +24,7 @@ Create dedicated test data sources and records. Record the saved rule array, log | Inactive member | Protected write | Denied | | Approver with multiple roles | Supported review operation | Allowed | | Member without required role | Same operation | Denied | -| Bound member with commit/bulk access | Own-data write with response enabled/default | Returned rows and columns satisfy the intended read isolation | +| Bound member with commit access | Own-data commit with response enabled/default | Only the touched rows come back, filtered by the member's read rule | | Denied or wrong-source actor | Direct response-bearing request | No forbidden data returned or persisted | Adapt the matrix to the configured policy. Include missing/null identity fields, different native types and each write method your app uses. For protected ownership, privilege or state fields, attempt malicious payloads directly through the SDK as well as through the UI. A hidden button does not stop an SDK call. @@ -63,15 +63,15 @@ Record the successful server response and returned rows. This read illustration For every allowed write, perform a fresh read and check the exact saved values. For every forbidden write, inspect the rejection and verify that no forbidden insert, update or delete persisted. Use a trusted Studio readback where the app's read rules hide the affected fields; label it as persistence evidence, not an app-user access test. -Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate single-record `exclude` behavior to bulk writes. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. +Test `findOne({ where: ... })` separately from `findById(id)` if the app uses both. Test single writes, append/replace, query writes and `commit()` separately when enabled. Do not extrapolate `exclude` behavior to bulk `append()` or `replaceWith()`. Record the mode, actor, rule revision, request, response and persisted result together. Change any of those inputs and rerun the relevant checks. ## Check write responses -For grants that admit commit/bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data append or commit with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. +For grants that admit commit or bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data commit, and an append if the app uses one, with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. An app user's commit response should hold only the touched rows, filtered by their read rule. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. -Read-response filtering does not automatically apply to commit responses. A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. +A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. ## Check joined-source responses diff --git a/docs/API/datasources/writing-data.md b/docs/API/datasources/writing-data.md index 9ac2023e..6652c28c 100644 --- a/docs/API/datasources/writing-data.md +++ b/docs/API/datasources/writing-data.md @@ -204,7 +204,9 @@ if (mapping) { } ``` -The online result is `{ entries?, clientIds: [{ id, clientId }, ...] }`. By default, `entries` can contain the source dataset, not only changed rows. Hashed fields are redacted, but query-read filters and projections must not be assumed to protect this write response. Test returned rows and columns as each actor before enabling commits on private data. A cooperative caller's `returnEntries: false` is not authorization enforcement. See [write-response tests](testing-security.md#check-write-responses). +The online result is `{ entries?, clientIds: [{ id, clientId }, ...] }`. For app users, `entries` holds only the rows this commit touched, each filtered by the caller's `select` rule, and is empty when no `select` rule grants access. Studio, administrative and API-token commits return the source dataset. Hashed fields are redacted. + +Commits by app users also follow the write rules: each insert and update drops the fields its matching rule excludes (or does not include), and updates merge onto the stored row, so unsent and excluded fields keep their stored values. `where` is ignored, `deleteColumns` and `renameColumns` are refused, and `columns` can only add columns. Test returned rows and saved values as each actor before enabling commits on private data. A cooperative caller's `returnEntries: false` is not authorization enforcement. See [write-response tests](testing-security.md#check-write-responses). ## Hooks and completion diff --git a/docs/Data-source-security.md b/docs/Data-source-security.md index b5bbf91d..d71efa03 100644 --- a/docs/Data-source-security.md +++ b/docs/Data-source-security.md @@ -42,9 +42,9 @@ Use this only for data intended for anonymous readers. It grants no insert, upda Rules grant access in order. A failed rule falls through for writes as well as reads, so a later broad grant can defeat an earlier restriction. -Object conditions on updates check the **stored row**, while string requirements check submitted key presence. A condition that checks stored `Owner` or `Status` does not prevent a permitted write from changing those fields. Single-record writes honor `exclude`, but bulk/commit writes do not provide the same field protection. Custom scripts receive a copied input; assigning audit fields to that copy does not reliably persist them. +Object conditions on updates check the **stored row**, while string requirements check submitted key presence. A condition on stored `Owner` or `Status` decides whether the caller may update that row; it does not stop them changing those fields. Exclude the fields each actor must not set from that actor's insert and update rules: single-record writes and `commit()` both drop excluded fields, and commit updates merge onto the stored row. Bulk `append()` and `replaceWith()` do not apply exclusions. Custom scripts receive a copied input; assigning audit fields to that copy does not reliably persist them. -For immutable ownership or approval transitions, keep app-user writes unavailable until an implemented trusted write path has been verified to check both stored and proposed values on every supported endpoint. See [role and state limits](API/datasources/security-examples.md#role-and-state-access) before choosing a workflow policy. +For workflows where one person submits and another decides, see [requests and approvals](API/datasources/security-examples.md#requests-and-approvals). ## Existing section links diff --git a/docs/bin/__tests__/datasource-security-docs.test.mjs b/docs/bin/__tests__/datasource-security-docs.test.mjs index 3bdd2547..5b739824 100644 --- a/docs/bin/__tests__/datasource-security-docs.test.mjs +++ b/docs/bin/__tests__/datasource-security-docs.test.mjs @@ -106,5 +106,5 @@ it('compatibility links describe the actual replacement scopes', () => { const section = examples.slice(examples.indexOf(''), examples.indexOf('## Editable profiles')); const policy = JSON.parse(section.match(/```json\n(\[\s*\{\s*"type"[\s\S]*?)```/)[1]); assert.deepEqual(policy[0].type, ['select']); - assert.ok(section.includes('does not prove every commit request is denied')); + assert.ok(section.includes('`replaceWith()` access still need their own tests')); }); From 3330257aeaba78c8bea5577b7029098ce0d4a762 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Wed, 21 Oct 2026 21:56:38 +0100 Subject: [PATCH 09/13] DEV-2201 Scope security testing guidance to the operations an app uses Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 4 ++-- docs/API/datasources/security-examples.md | 2 +- docs/API/datasources/testing-security.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 15fd8965..ba160275 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14677,7 +14677,7 @@ async function readOwnSketch(adaSketchId) { Denied read tests as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })` and `findById(linSketchId)` using Lin's actual fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied on these read paths. -The query rule validates `where`; it does not add an owner filter. `findById(id)` instead evaluates the stored row. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. Before asserting private deployment safety, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under the source's real permissions, including their returned rows and columns. See [adversarial response tests](testing-security.md#check-write-responses). +The query rule validates `where`; it does not add an owner filter. `findById(id)` instead evaluates the stored row. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. Test every operation your app uses as each user, including the rows and columns it returns. See [adversarial response tests](testing-security.md#check-write-responses). ## Editable profiles @@ -15297,7 +15297,7 @@ For grants that admit commit or bulk operations, seed another owner's row and a A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. -A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. +A client choosing `returnEntries: false` is not proof that other callers cannot request entries, so test responses with entries enabled. Test the operations your app uses, as real app users on the deployed configuration; do not infer deployed behavior from local source alone. ## Check joined-source responses diff --git a/docs/API/datasources/security-examples.md b/docs/API/datasources/security-examples.md index 1f7d34fb..8d276ad4 100644 --- a/docs/API/datasources/security-examples.md +++ b/docs/API/datasources/security-examples.md @@ -154,7 +154,7 @@ async function readOwnSketch(adaSketchId) { Denied read tests as Ada: `find()` without an owner filter, `find({ where: { OwnerEmail: 'lin@example.org' } })` and `findById(linSketchId)` using Lin's actual fixture ID. Anonymous callers, a wrong-source login and `Enabled: false` must also be denied on these read paths. -The query rule validates `where`; it does not add an owner filter. `findById(id)` instead evaluates the stored row. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. Before asserting private deployment safety, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under the source's real permissions, including their returned rows and columns. See [adversarial response tests](testing-security.md#check-write-responses). +The query rule validates `where`; it does not add an owner filter. `findById(id)` instead evaluates the stored row. This textual email comparison is case-insensitive; use normalized unique identities and do not assume case-sensitive ownership. Test every operation your app uses as each user, including the rows and columns it returns. See [adversarial response tests](testing-security.md#check-write-responses). ## Editable profiles diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md index 95c80464..8a42497f 100644 --- a/docs/API/datasources/testing-security.md +++ b/docs/API/datasources/testing-security.md @@ -71,7 +71,7 @@ For grants that admit commit or bulk operations, seed another owner's row and a A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. -A client choosing `returnEntries: false` is not proof that other callers cannot request entries. To assert a read-only private configuration, verify commit and other response-bearing routes are actually unavailable or appropriately isolated under its real source permissions. A select-only evaluator result is insufficient. Record the tested deployment/configuration and any unknowns; do not infer deployed behavior from local source alone. +A client choosing `returnEntries: false` is not proof that other callers cannot request entries, so test responses with entries enabled. Test the operations your app uses, as real app users on the deployed configuration; do not infer deployed behavior from local source alone. ## Check joined-source responses From 7212e8d82f50f99be1cc4e218c78deb655832f61 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 22 Oct 2026 01:53:42 +0100 Subject: [PATCH 10/13] DEV-2201 Point user-dependent value lookups at find with attributes Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 2 +- docs/API/datasources/reading-data.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index ba160275..cb30fe12 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14495,7 +14495,7 @@ if (!cursor.isLastPage) { ## Column indexes -`connection.getIndex(column)` resolves an array of distinct values. `connection.getIndexes(columns)` resolves an object mapping each requested column to its values. These source-wide lookups do not accept the `where`, views, pagination or request options of `find`; do not use them to infer the values visible to a filtered user. Verify returned values separately when configuring security. +`connection.getIndex(column)` resolves an array of distinct values. `connection.getIndexes(columns)` resolves an object mapping each requested column to its values. These source-wide lookups do not accept the `where`, views, pagination or request options of `find`; for values that depend on the signed-in user, use `find` with `attributes`. | Argument | Type | Required | |---|---|---| diff --git a/docs/API/datasources/reading-data.md b/docs/API/datasources/reading-data.md index 87283ec5..da94f11a 100644 --- a/docs/API/datasources/reading-data.md +++ b/docs/API/datasources/reading-data.md @@ -132,7 +132,7 @@ if (!cursor.isLastPage) { ## Column indexes -`connection.getIndex(column)` resolves an array of distinct values. `connection.getIndexes(columns)` resolves an object mapping each requested column to its values. These source-wide lookups do not accept the `where`, views, pagination or request options of `find`; do not use them to infer the values visible to a filtered user. Verify returned values separately when configuring security. +`connection.getIndex(column)` resolves an array of distinct values. `connection.getIndexes(columns)` resolves an object mapping each requested column to its values. These source-wide lookups do not accept the `where`, views, pagination or request options of `find`; for values that depend on the signed-in user, use `find` with `attributes`. | Argument | Type | Required | |---|---|---| From 125f55eb27daa25d7666febe05459fff3996536d Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 22 Oct 2026 11:37:35 +0100 Subject: [PATCH 11/13] DEV-2201 Say Fliplet.Chat needs a Chat component and point V3 apps at data sources Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/agent-skills/fliplet-js-api/SKILL.md | 2 +- docs/.well-known/agent-skills/index.json | 2 +- docs/.well-known/llms-full.txt | 2 +- docs/.well-known/llms.txt | 2 +- docs/API/fliplet-chat.md | 6 +++--- docs/v3/capabilities.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/.well-known/agent-skills/fliplet-js-api/SKILL.md b/docs/.well-known/agent-skills/fliplet-js-api/SKILL.md index 01b686f9..faf2f303 100644 --- a/docs/.well-known/agent-skills/fliplet-js-api/SKILL.md +++ b/docs/.well-known/agent-skills/fliplet-js-api/SKILL.md @@ -62,7 +62,7 @@ The Fliplet client-side JavaScript API: every Fliplet.X namespace (Storage, User - [Fliplet.Media.Audio.Player](https://developers.fliplet.com/API/fliplet-audio-player.md): Embed an audio player UI in a screen by tagging HTML elements with audio URLs; the framework auto-initializes players on screen load. - [Fliplet.Media.Audio](https://developers.fliplet.com/API/fliplet-audio.md): Play, pause, stop, and seek audio files on device or from a URL in Fliplet apps via the Audio namespace. - [Fliplet.Barcode](https://developers.fliplet.com/API/fliplet-barcode.md): Scan QR codes and 1D/2D barcodes from the camera on web and native (attachScanner / scan) and generate barcode images, via the fliplet-barcode package. -- [Fliplet.Chat](https://developers.fliplet.com/API/fliplet-chat.md): Build one-to-one, group, and public-channel chat features. Fliplet.Chat owns the conversations and messages data sources internally — supply only the contacts list (who can chat with whom). +- [Fliplet.Chat](https://developers.fliplet.com/API/fliplet-chat.md): Chat API for V2 apps with a Chat component. Not available to V3 apps: build messaging on the app's own data sources. - [Fliplet.Communicate](https://developers.fliplet.com/API/fliplet-communicate.md): Send email, SMS, push notifications, and share URLs from a Fliplet app using a single Communicate namespace. - [Fliplet.Content()](https://developers.fliplet.com/API/fliplet-content.md): Create, query, update, and delete shared content records (bookmarks, likes, saved searches) backed by a data source via Fliplet.Content. - [Fliplet.CSV](https://developers.fliplet.com/API/fliplet-csv.md): Encode and decode CSV in Fliplet apps via the fliplet-csv package. diff --git a/docs/.well-known/agent-skills/index.json b/docs/.well-known/agent-skills/index.json index d2615ee8..f30517c9 100644 --- a/docs/.well-known/agent-skills/index.json +++ b/docs/.well-known/agent-skills/index.json @@ -130,7 +130,7 @@ "tags": [ "js-api" ], - "sha256": "7007a95ec4372a59848fc0d56c103f8bbf5d5cb7a9e4b46d32650532b12f4ec9" + "sha256": "2827713fd9724e21c105e11b42c904b91a436a863542078c0cd8e4979a85161e" }, { "name": "fliplet-docs-index", diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index cb30fe12..3689e923 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -16256,7 +16256,7 @@ For model-generated replies, use the [AI chatbot guide](/API/core/ai/chatbot). ` ## Install -Add the `fliplet-chat` dependency to your screen or app resources. In V3 apps, add it via Studio's library picker — required dependencies (`fliplet-datasources`, `fliplet-utils`) are pulled in automatically. +Add the `fliplet-chat` dependency to your screen or app resources; required dependencies (`fliplet-datasources`, `fliplet-utils`) are pulled in automatically. The chat's server routes read their settings from a Chat component on one of the app's screens, so they need one. V3 apps can't add components: build messaging on the app's own data sources instead, with [security rules](datasources/security-rules) that let only the sender and the recipient read each message. When using the chat instance from screens that aren't the chat screen itself, prefer `Fliplet.Chat.init()` (reads settings from the chat widget on the page) or `Fliplet.Chat.get()` (resolves once another screen has called `connect()`). diff --git a/docs/.well-known/llms.txt b/docs/.well-known/llms.txt index c96a4cb4..dd22344c 100644 --- a/docs/.well-known/llms.txt +++ b/docs/.well-known/llms.txt @@ -119,7 +119,7 @@ - [Fliplet.Media.Audio.Player](https://developers.fliplet.com/API/fliplet-audio-player.md): Embed an audio player UI in a screen by tagging HTML elements with audio URLs; the framework auto-initializes players on screen load. - [Fliplet.Media.Audio](https://developers.fliplet.com/API/fliplet-audio.md): Play, pause, stop, and seek audio files on device or from a URL in Fliplet apps via the Audio namespace. - [Fliplet.Barcode](https://developers.fliplet.com/API/fliplet-barcode.md): Scan QR codes and 1D/2D barcodes from the camera on web and native (attachScanner / scan) and generate barcode images, via the fliplet-barcode package. -- [Fliplet.Chat](https://developers.fliplet.com/API/fliplet-chat.md): Build one-to-one, group, and public-channel chat features. Fliplet.Chat owns the conversations and messages data sources internally — supply only the contacts list (who can chat with whom). +- [Fliplet.Chat](https://developers.fliplet.com/API/fliplet-chat.md): Chat API for V2 apps with a Chat component. Not available to V3 apps: build messaging on the app's own data sources. - [Fliplet.Communicate](https://developers.fliplet.com/API/fliplet-communicate.md): Send email, SMS, push notifications, and share URLs from a Fliplet app using a single Communicate namespace. - [Fliplet.Content()](https://developers.fliplet.com/API/fliplet-content.md): Create, query, update, and delete shared content records (bookmarks, likes, saved searches) backed by a data source via Fliplet.Content. - [Fliplet.CSV](https://developers.fliplet.com/API/fliplet-csv.md): Encode and decode CSV in Fliplet apps via the fliplet-csv package. diff --git a/docs/API/fliplet-chat.md b/docs/API/fliplet-chat.md index 7a8786e3..ff65e2ce 100644 --- a/docs/API/fliplet-chat.md +++ b/docs/API/fliplet-chat.md @@ -1,13 +1,13 @@ --- title: Fliplet.Chat -description: Build one-to-one, group, and public-channel chat features. Fliplet.Chat owns the conversations and messages data sources internally — supply only the contacts list (who can chat with whom). +description: "Chat API for V2 apps with a Chat component. Not available to V3 apps: build messaging on the app's own data sources." type: api-reference tags: [js-api, chat, messaging, realtime, conversations] v3_relevant: true deprecated: false category: communications capabilities: [chat, conversation, group chat, public channel, direct message, dm, im, messaging, real-time chat, contacts, chat room, typing indicator, message history] -notes: "Owns the conversations and messages data sources internally — never propose `Conversations`, `Messages`, `Typing Status`, or similar data sources of your own. The only data source you design is the `contacts` list passed to `Fliplet.Chat.connect()` (who can chat with whom)." +notes: "V3 apps can't use Fliplet.Chat: its server routes need a Chat component on a screen. Build messaging on the app's own data sources, with select rules that admit only the sender and the recipient." --- # `Fliplet.Chat` @@ -19,7 +19,7 @@ For model-generated replies, use the [AI chatbot guide](/API/core/ai/chatbot). ` ## Install -Add the `fliplet-chat` dependency to your screen or app resources. In V3 apps, add it via Studio's library picker — required dependencies (`fliplet-datasources`, `fliplet-utils`) are pulled in automatically. +Add the `fliplet-chat` dependency to your screen or app resources; required dependencies (`fliplet-datasources`, `fliplet-utils`) are pulled in automatically. The chat's server routes read their settings from a Chat component on one of the app's screens, so they need one. V3 apps can't add components: build messaging on the app's own data sources instead, with [security rules](datasources/security-rules) that let only the sender and the recipient read each message. When using the chat instance from screens that aren't the chat screen itself, prefer `Fliplet.Chat.init()` (reads settings from the chat widget on the page) or `Fliplet.Chat.get()` (resolves once another screen has called `connect()`). diff --git a/docs/v3/capabilities.md b/docs/v3/capabilities.md index 4f185b5d..ac304a1f 100644 --- a/docs/v3/capabilities.md +++ b/docs/v3/capabilities.md @@ -31,7 +31,7 @@ Every Fliplet JS API available to V3 apps, grouped by capability category. Each ## Communications -- [`Fliplet.Chat`](https://developers.fliplet.com/API/fliplet-chat.html) — Build one-to-one, group, and public-channel chat features. Fliplet.Chat owns the conversations and messages data sources internally — supply only the contacts list (who can chat with whom). +- [`Fliplet.Chat`](https://developers.fliplet.com/API/fliplet-chat.html) — Chat API for V2 apps with a Chat component. Not available to V3 apps: build messaging on the app's own data sources. - [`Fliplet.Communicate`](https://developers.fliplet.com/API/fliplet-communicate.html) — Send email, SMS, push notifications, and share URLs from a Fliplet app using a single Communicate namespace. - [`Fliplet.Navigator.Notifications`](https://developers.fliplet.com/API/core/notifications.html) **(preloaded)** — Check notification support, request permission, and send local device notifications from JavaScript. - [`Fliplet.Notifications`](https://developers.fliplet.com/API/fliplet-notifications.html) — Read, send, and schedule in-app and push notifications in Fliplet apps, with support for scopes, read receipts, and badge counts. From 38d96734ca25cba197a32c86010adc6e3d24e0d8 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 22 Oct 2026 13:27:48 +0100 Subject: [PATCH 12/13] DEV-2201 Document that query operators can't satisfy a select requirement Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 4 +++- docs/.well-known/llms-v3-libraries.json | 6 +++--- docs/API/datasources/security-rules.md | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 3689e923..2cc56166 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14917,6 +14917,8 @@ String requirements check **key presence**, not a nonempty value or type. For ex | Query admission | Submitted `where` keys, evaluated as `select` | Submitted `where`, evaluated as `select` | | Query update, affected-record check | Submitted update-data keys | Stored row | | Query delete, affected-record check | Stored record keys | Stored record values | + +An object requirement on a query is met only by a value on that key at the top level of `where`. A condition on it only inside `$or`, or written with `$ne` or `$in`, does not meet it, and extra conditions beside it only narrow the result. To let either of two parties read a record, such as a message's sender and recipient, add one `select` rule per identity column. | Commit delete | Existing data for the requested IDs | Existing data for the requested IDs | Components such as **List from Data Source**, **Chart** and **Form** may issue broad queries that do not satisfy your read requirements. Configure their filters or use custom queries that satisfy the intended restrictions. A permissive fallback rule grants broader access even when a stricter rule appears first, and `appId` scopes a rule to apps, not to individual components. @@ -42742,7 +42744,7 @@ Every Fliplet JS API available to V3 apps, grouped by capability category. Each ## Communications -- [`Fliplet.Chat`](https://developers.fliplet.com/API/fliplet-chat.html) — Build one-to-one, group, and public-channel chat features. Fliplet.Chat owns the conversations and messages data sources internally — supply only the contacts list (who can chat with whom). +- [`Fliplet.Chat`](https://developers.fliplet.com/API/fliplet-chat.html) — Chat API for V2 apps with a Chat component. Not available to V3 apps: build messaging on the app's own data sources. - [`Fliplet.Communicate`](https://developers.fliplet.com/API/fliplet-communicate.html) — Send email, SMS, push notifications, and share URLs from a Fliplet app using a single Communicate namespace. - [`Fliplet.Navigator.Notifications`](https://developers.fliplet.com/API/core/notifications.html) **(preloaded)** — Check notification support, request permission, and send local device notifications from JavaScript. - [`Fliplet.Notifications`](https://developers.fliplet.com/API/fliplet-notifications.html) — Read, send, and schedule in-app and push notifications in Fliplet apps, with support for scopes, read receipts, and badge counts. diff --git a/docs/.well-known/llms-v3-libraries.json b/docs/.well-known/llms-v3-libraries.json index e8a7ed64..8c92de6b 100644 --- a/docs/.well-known/llms-v3-libraries.json +++ b/docs/.well-known/llms-v3-libraries.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-21T12:49:35.272Z", + "generatedAt": "2026-10-22T12:21:52.485Z", "libraries": [ { "package": "fliplet-analytics-spa", @@ -84,7 +84,7 @@ "package": "fliplet-chat", "namespace": "Fliplet.Chat", "title": "Fliplet.Chat", - "description": "Build one-to-one, group, and public-channel chat features. Fliplet.Chat owns the conversations and messages data sources internally — supply only the contacts list (who can chat with whom).", + "description": "Chat API for V2 apps with a Chat component. Not available to V3 apps: build messaging on the app's own data sources.", "docUrl": "https://developers.fliplet.com/API/fliplet-chat.md", "preloaded": false, "capabilities": [ @@ -103,7 +103,7 @@ "message history" ], "category": "communications", - "notes": "Owns the conversations and messages data sources internally — never propose `Conversations`, `Messages`, `Typing Status`, or similar data sources of your own. The only data source you design is the `contacts` list passed to `Fliplet.Chat.connect()` (who can chat with whom)." + "notes": "V3 apps can't use Fliplet.Chat: its server routes need a Chat component on a screen. Build messaging on the app's own data sources, with select rules that admit only the sender and the recipient." }, { "package": "fliplet-communicate", diff --git a/docs/API/datasources/security-rules.md b/docs/API/datasources/security-rules.md index c6f8928e..6428d921 100644 --- a/docs/API/datasources/security-rules.md +++ b/docs/API/datasources/security-rules.md @@ -98,6 +98,8 @@ String requirements check **key presence**, not a nonempty value or type. For ex | Query admission | Submitted `where` keys, evaluated as `select` | Submitted `where`, evaluated as `select` | | Query update, affected-record check | Submitted update-data keys | Stored row | | Query delete, affected-record check | Stored record keys | Stored record values | + +An object requirement on a query is met only by a value on that key at the top level of `where`. A condition on it only inside `$or`, or written with `$ne` or `$in`, does not meet it, and extra conditions beside it only narrow the result. To let either of two parties read a record, such as a message's sender and recipient, add one `select` rule per identity column. | Commit delete | Existing data for the requested IDs | Existing data for the requested IDs | Components such as **List from Data Source**, **Chart** and **Form** may issue broad queries that do not satisfy your read requirements. Configure their filters or use custom queries that satisfy the intended restrictions. A permissive fallback rule grants broader access even when a stricter rule appears first, and `appId` scopes a rule to apps, not to individual components. From f17ad12c4fa07fcbcbb810a9c5963d567d3af128 Mon Sep 17 00:00:00 2001 From: Hugo Carneiro Date: Thu, 22 Oct 2026 18:06:01 +0100 Subject: [PATCH 13/13] DEV-2201 Pin approval state with require so append() can't create a decision Co-Authored-By: Claude Opus 5.5 --- docs/.well-known/llms-full.txt | 18 ++++++++++-------- docs/API/datasources/security-examples.md | 14 ++++++++------ docs/API/datasources/testing-security.md | 4 ++-- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/docs/.well-known/llms-full.txt b/docs/.well-known/llms-full.txt index 2cc56166..ded75a89 100644 --- a/docs/.well-known/llms-full.txt +++ b/docs/.well-known/llms-full.txt @@ -14771,9 +14771,9 @@ Denied tests: Lin (`Roles: ['Author']`), a user with only `['SuperApprover']`, a ## Requests and approvals -**Goal:** Members submit requests and can edit their own; approvers decide. Nobody decides their own request, and members can never set the decision fields. +**Goal:** Members submit requests and can edit their own; approvers decide. Nobody decides their own request, and members can't create or change a decision. -Create **Purchase Requests** with columns `OwnerEmail`, `Item`, `Amount`, `Status` and `DecidedBy`. Seed one request owned by Lin: `{ "OwnerEmail": "lin@example.org", "Item": "Monitor", "Amount": 180, "Status": "", "DecidedBy": "" }`. +Create **Purchase Requests** with columns `OwnerEmail`, `Item`, `Amount`, `Status` and `DecidedBy`. Seed one request owned by Lin: `{ "OwnerEmail": "lin@example.org", "Item": "Monitor", "Amount": 180, "Status": "Pending", "DecidedBy": "" }`. Rules run most privileged first. The approver rule's stored-row requirement stops an approver deciding their own request; Ada's own requests fall through to the member rules. @@ -14795,8 +14795,8 @@ Rules run most privileged first. The approver rule's stored-row requirement stop { "type": ["insert"], "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, - "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], - "exclude": ["Status", "DecidedBy"] + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }, { "Status": { "equals": "Pending" } }], + "exclude": ["DecidedBy"] }, { "type": ["update"], @@ -14815,11 +14815,13 @@ async function trySelfApproval(requestId) { const requests = await Fliplet.DataSources.connectByName('Purchase Requests', { offline: false }); await requests.update(requestId, { Item: 'Monitor arm', Status: 'Approved', DecidedBy: 'lin@example.org' }); const saved = await requests.findById(requestId); - console.log(saved.data.Item, saved.data.Status); // 'Monitor arm' '' + console.log(saved.data.Item, saved.data.Status); // 'Monitor arm' 'Pending' } ``` -As Ada, updating Lin's request with `Status: 'Approved'` and `DecidedBy: 'ada@example.org'` saves both. Denied tests: Ada approving a request she owns keeps `Status` unchanged, Lin reading or updating another member's request is denied, and an anonymous or wrong-source caller is denied. Excluded fields start empty on insert, so treat an empty `Status` as pending in the app. To stop edits after a decision, add a stored-state requirement to the member update rule and test it against the values the app actually stores, including empty. +The insert rule pins `Status` with `require` rather than `exclude`. `exclude` doesn't apply to bulk `append()`, but `require` checks every appended record, so Lin's `append([{ OwnerEmail: 'lin@example.org', Item: 'Laptop', Amount: 3000, Status: 'Approved' }])` is refused. Inserts must send `Status: 'Pending'`. `append()` can still fill in `DecidedBy` on a pending request, so treat `DecidedBy` as meaningful only once `Status` is decided; the approver's update sets it. + +As Ada, updating Lin's request with `Status: 'Approved'` and `DecidedBy: 'ada@example.org'` saves both. Denied tests: Lin appending or inserting a request whose `Status` isn't `Pending` is refused, Ada approving a request she owns keeps `Status` unchanged, Lin reading or updating another member's request is denied, and an anonymous or wrong-source caller is denied. To stop edits after a decision, add `{ "Status": { "equals": "Pending" } }` to the member update rule's `require`; it checks the stored row. --- @@ -15295,11 +15297,11 @@ Test `findOne({ where: ... })` separately from `findById(id)` if the app uses bo ## Check write responses -For grants that admit commit or bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data commit, and an append if the app uses one, with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. An app user's commit response should hold only the touched rows, filtered by their read rule. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. +For grants that admit commit or bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data commit and an append whenever the rules grant insert, with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. An app user's commit response should hold only the touched rows, filtered by their read rule. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. -A client choosing `returnEntries: false` is not proof that other callers cannot request entries, so test responses with entries enabled. Test the operations your app uses, as real app users on the deployed configuration; do not infer deployed behavior from local source alone. +A client choosing `returnEntries: false` is not proof that other callers cannot request entries, so test responses with entries enabled. Test every SDK call the rules grant, not only the ones your app's screens use, as real app users on the deployed configuration; do not infer deployed behavior from local source alone. ## Check joined-source responses diff --git a/docs/API/datasources/security-examples.md b/docs/API/datasources/security-examples.md index 8d276ad4..13b729d1 100644 --- a/docs/API/datasources/security-examples.md +++ b/docs/API/datasources/security-examples.md @@ -248,9 +248,9 @@ Denied tests: Lin (`Roles: ['Author']`), a user with only `['SuperApprover']`, a ## Requests and approvals -**Goal:** Members submit requests and can edit their own; approvers decide. Nobody decides their own request, and members can never set the decision fields. +**Goal:** Members submit requests and can edit their own; approvers decide. Nobody decides their own request, and members can't create or change a decision. -Create **Purchase Requests** with columns `OwnerEmail`, `Item`, `Amount`, `Status` and `DecidedBy`. Seed one request owned by Lin: `{ "OwnerEmail": "lin@example.org", "Item": "Monitor", "Amount": 180, "Status": "", "DecidedBy": "" }`. +Create **Purchase Requests** with columns `OwnerEmail`, `Item`, `Amount`, `Status` and `DecidedBy`. Seed one request owned by Lin: `{ "OwnerEmail": "lin@example.org", "Item": "Monitor", "Amount": 180, "Status": "Pending", "DecidedBy": "" }`. Rules run most privileged first. The approver rule's stored-row requirement stops an approver deciding their own request; Ada's own requests fall through to the member rules. @@ -272,8 +272,8 @@ Rules run most privileged first. The approver rule's stored-row requirement stop { "type": ["insert"], "allow": { "dataSourceId": 731, "user": { "Enabled": true } }, - "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }], - "exclude": ["Status", "DecidedBy"] + "require": [{ "OwnerEmail": { "equals": "{{user.[Email]}}" } }, { "Status": { "equals": "Pending" } }], + "exclude": ["DecidedBy"] }, { "type": ["update"], @@ -292,9 +292,11 @@ async function trySelfApproval(requestId) { const requests = await Fliplet.DataSources.connectByName('Purchase Requests', { offline: false }); await requests.update(requestId, { Item: 'Monitor arm', Status: 'Approved', DecidedBy: 'lin@example.org' }); const saved = await requests.findById(requestId); - console.log(saved.data.Item, saved.data.Status); // 'Monitor arm' '' + console.log(saved.data.Item, saved.data.Status); // 'Monitor arm' 'Pending' } ``` -As Ada, updating Lin's request with `Status: 'Approved'` and `DecidedBy: 'ada@example.org'` saves both. Denied tests: Ada approving a request she owns keeps `Status` unchanged, Lin reading or updating another member's request is denied, and an anonymous or wrong-source caller is denied. Excluded fields start empty on insert, so treat an empty `Status` as pending in the app. To stop edits after a decision, add a stored-state requirement to the member update rule and test it against the values the app actually stores, including empty. +The insert rule pins `Status` with `require` rather than `exclude`. `exclude` doesn't apply to bulk `append()`, but `require` checks every appended record, so Lin's `append([{ OwnerEmail: 'lin@example.org', Item: 'Laptop', Amount: 3000, Status: 'Approved' }])` is refused. Inserts must send `Status: 'Pending'`. `append()` can still fill in `DecidedBy` on a pending request, so treat `DecidedBy` as meaningful only once `Status` is decided; the approver's update sets it. + +As Ada, updating Lin's request with `Status: 'Approved'` and `DecidedBy: 'ada@example.org'` saves both. Denied tests: Lin appending or inserting a request whose `Status` isn't `Pending` is refused, Ada approving a request she owns keeps `Status` unchanged, Lin reading or updating another member's request is denied, and an anonymous or wrong-source caller is denied. To stop edits after a decision, add `{ "Status": { "equals": "Pending" } }` to the member update rule's `require`; it checks the stored row. diff --git a/docs/API/datasources/testing-security.md b/docs/API/datasources/testing-security.md index 8a42497f..7ea5a69f 100644 --- a/docs/API/datasources/testing-security.md +++ b/docs/API/datasources/testing-security.md @@ -67,11 +67,11 @@ Test `findOne({ where: ... })` separately from `findById(id)` if the app uses bo ## Check write responses -For grants that admit commit or bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data commit, and an append if the app uses one, with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. An app user's commit response should hold only the touched rows, filtered by their read rule. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. +For grants that admit commit or bulk operations, seed another owner's row and a sensitive column in the same disposable source. As the allowed actor, submit an own-data commit and an append whenever the rules grant insert, with response entries enabled or left at their default. Inspect **all returned rows and columns**, not just the submitted entry. An app user's commit response should hold only the touched rows, filtered by their read rule. Repeat direct requests as denied and wrong-source actors, and check both response data and persisted effects. Test these SDK calls independently of whether the UI exposes them. A `**REDACTED**` value in a returned row is a hashed field such as a password, and is not an exposure of that value. Still check whether the other returned rows and columns are acceptable for the actor. -A client choosing `returnEntries: false` is not proof that other callers cannot request entries, so test responses with entries enabled. Test the operations your app uses, as real app users on the deployed configuration; do not infer deployed behavior from local source alone. +A client choosing `returnEntries: false` is not proof that other callers cannot request entries, so test responses with entries enabled. Test every SDK call the rules grant, not only the ones your app's screens use, as real app users on the deployed configuration; do not infer deployed behavior from local source alone. ## Check joined-source responses