The Extension Kit provides data storage capabilities for persisting app data on the Teachfloor platform. Data is automatically scoped by organization and app, with optional user-level scoping.
Overview
Three types of storage are available:
Storage Type
Scope
Use Case
App Data
Organization + App
Shared settings, configurations
User Data
Organization + App + User
User-specific preferences, state
User Collection
Organization + App + User
Lists, activity logs, history
Two API styles are available on top of App Data and User Data:
Raw primitives (store / retrieve) — one row per key, described in the App Data / User Data sections below.
Storage Manager (createStorage, recommended) — a namespaced wrapper adding TTL and query() for paged filter/sort iteration across many rows. See Storage Manager.
For per-key append semantics (many rows sharing a key, id-based CRUD), use User Collection Storage instead.
Each storage type requires appropriate read/write permissions. Write permissions automatically include read access. See Permissions Reference for details.
Security: All data is automatically encrypted at rest on the Teachfloor platform.
App Data Storage
Store data shared across all users in your organization.
Permissions Required
Read and Write:
Code
{ "permissions": [ { "permission": "appdata:write", "purpose": "Save and load app configuration and settings" } ]}
The Storage Manager (createStorage) is the recommended way to work with App Data and User Data. It's a thin wrapper over store / retrieve that adds three things:
Namespaced keys — every operation is scoped under a baseKey prefix so different features of your app can hold their own storage instances without key collisions.
TTL / expiry — pass { ttl: seconds } on set() to auto-expire values.
query() — paged iteration across many rows in the namespace with a small filter + sort DSL. Available in kit 1.29.0+.
Permissions Required
Same as App Data / User Data — the Storage Manager doesn't add its own permissions. Pass { source: 'appdata' } for org-shared storage or { source: 'userdata' } for per-user storage.
Code
{ "permissions": [ { "permission": "userdata:write", "purpose": "Save and load user notes" } ]}
Basic Usage
Code
import { createStorage } from '@teachfloor/extension-kit'// Per-user lesson notes, all keys automatically prefixed with 'lesson-notes:'const notes = createStorage('lesson-notes', { source: 'userdata' })// set → row key: 'lesson-notes:lesson-42-note-1'await notes.set('lesson-42-note-1', { content: 'Mitochondria produce ATP via oxidative phosphorylation', lesson_id: 'lesson-42', tag: 'lecture',})// get by sub-key (namespace stripped from returned key)const note = await notes.get('lesson-42-note-1')// → { content: 'Mitochondria produce ATP via oxidative phosphorylation',// lesson_id: 'lesson-42', tag: 'lecture' }// TTL — auto-expires after 3600 seconds// (e.g. flag a lesson as "recently viewed" for one hour)await notes.set('recently-viewed:lesson-42', true, { ttl: 3600 })// removeawait notes.remove('lesson-42-note-1')
The kit prepends the baseKey to every operation, so callers only ever see un-namespaced sub-keys. Different createStorage(...) instances can't accidentally reach each other's data.
Query — paged filter + sort
query({ where, sort, limit, after }) returns { items, nextCursor } where each item is { key, value, created_at, updated_at }. key is the sub-key (namespace stripped).
Filters run against metadata columns only — the value column is encrypted at rest and can't be predicated on. Expired rows are always excluded (no escape).
Predicates are [field, op, value] tuples. Multiple tuples in a where array AND together implicitly. For OR, wrap in a { or: [...] } group; for explicit AND groups, use { and: [...] }. Groups nest arbitrarily.
Code
// All notes for lesson 42 (key sub-namespace)await notes.query({ where: [['key', 'contains', 'lesson-42-']],})// Batch fetch by known ids (max 100 values per in / not in)await notes.query({ where: [['key', 'in', ['lesson-42-note-1', 'lesson-73-note-2']]],})// Recent notes only, oldest first — e.g. review what you took this weekawait notes.query({ where: [['updated_at', '>=', '2026-07-01']], sort: [['updated_at', 'asc']],})// Combined AND — notes for lesson 42, updated since July 1await notes.query({ where: [ ['key', 'contains', 'lesson-42-'], ['updated_at', '>=', '2026-07-01'], ],})// Nested OR — recent notes from module 3 OR any pinned exam-prep cardawait notes.query({ where: [ { or: [ { and: [ ['key', 'contains', 'module-3-'], ['updated_at', '>=', '2026-07-01'], ]}, ['key', 'in', ['exam-prep:cell-biology', 'exam-prep:genetics']], ]}, ], sort: [['updated_at', 'desc']], limit: 20,})
For key exact-match ops (=, !=, in, not in), values are treated as sub-keys and namespaced automatically — you write un-namespaced sub-keys, matching get() / set() semantics. contains / not contains values pass through as raw substrings and search only within the current namespace.
Constraints and errors
in / not in cap — max 100 values per predicate. Larger arrays throw storage.query: "in" cannot accept more than 100 values (got N). Split into multiple pages instead.
Result size cap — server hard cap is 200 rows per page regardless of limit.
Fail-loud validation — unsupported fields or operators throw at the call site before any RPC. Example: where: [['value', '=', 'x']] throws storage.query: unsupported field "value".
Cursor is opaque — pass the exact string back in after. Don't decode / hand-craft.
Cursor is tied to the sort order it was minted with — if you change sort between pages, the cursor becomes semantically wrong (no error, but rows may be skipped or duplicated).
Example: Lesson notes with load-more search
A learner's notes browser — filter by lesson (lesson-42-, lesson-73-, …), paginate through everything.
Storage Manager vs raw store / retrieve: use raw primitives only for a small, well-known set of keys (like a single 'config' blob). If you're storing multiple items and might want to enumerate or filter them, use Storage Manager from the start.
User Collection Storage
Store lists of data items for a user, with pagination support.
Permissions Required
Read and Write:
Code
{ "permissions": [ { "permission": "usercollection:write", "purpose": "Save and load your activity history and saved items" } ]}
Note: usercollection:write includes read access.
Read-Only:
Code
{ "permissions": [ { "permission": "usercollection:read", "purpose": "Load your activity history and saved items" } ]}
Usage
Collections allow you to store multiple items under the same key and retrieve them with pagination.
Code
import { createCollection } from '@teachfloor/extension-kit'// Create a collection managerconst notes = createCollection('user-notes', { limit: 15 })// Add items to the collectionawait notes.add({ title: 'My Note', content: 'Note content', createdAt: Date.now()})await notes.add({ title: 'Another Note', content: 'More content', createdAt: Date.now()})// List items (first page)const page1 = await notes.list()console.log(page1.items) // Array of collection recordsconsole.log(page1.items[0].value) // Your actual dataconsole.log(page1.items[0].id) // Database record IDconsole.log(page1.hasMore) // true if more pages existconsole.log(page1.nextCursor) // Cursor for next page// Load next pageif (page1.hasMore) { const page2 = await notes.list({ cursor: page1.nextCursor })}// Update an existing itemconst itemId = page1.items[0].idawait notes.update(itemId, { title: 'Updated Title', content: 'Updated content', updatedAt: Date.now()})// Remove an itemawait notes.remove(itemId)// Get all items (auto-pagination)const allNotes = await notes.getAll()
Pagination
Code
import { createCollection } from '@teachfloor/extension-kit'const messages = createCollection('chat-messages', { limit: 20 })// Manual paginationconst page1 = await messages.list()console.log(page1.items) // First 20 collection recordsconsole.log(page1.items[0].value) // First item's dataconsole.log(page1.hasMore) // true if more exist// Load next pageif (page1.hasMore) { const page2 = await messages.list({ cursor: page1.nextCursor })}// Auto-pagination (get all items)const allMessages = await messages.getAll()
Use Cases
Activity logs
User notes or annotations
Saved items or bookmarks
History or timeline data
Multi-entry forms
Example: Chat Messages
Complete example using the Collection Manager API:
// Data is automatically serialized and deserializedconst settings = await retrieve('settings', 'userdata')// No need to JSON.parse - objects are returned as objectsconsole.log(settings.theme) // Direct property access// Arrays remain arraysconst items = await retrieve('items', 'userdata')items.forEach(item => console.log(item))
Security
All data stored through the Extension Kit is automatically encrypted at rest on the Teachfloor platform.