Admin (DropCoreAdmin)#
src/admin/drop_core_admin.ts
Resolved via getDropCoreAdmin(session?) or getDropCoreInstance().admin.
| Property | Type | Underlying path |
|---|---|---|
store |
Store |
store/{sid} |
stores |
Stores (DropCollection<StoreEntity>) |
store — cross-store listing. Master-only; other roles are rejected by the access middleware. |
data |
Data |
Escape hatch for free-form paths: data.collection(path) / data.document(path) for nested user collections. Also the store-wide versioned pair over FULL ref paths: data.batch(paths, {mask?, meta?}) (meta: true adds _meta[path].update_time) and data.commit(writes) — up to 100 CommitWrites (create / set / update / delete, precondition: {exists?, update_time?}) applied atomically by POST /admin/commit; a refused precondition rejects with the server's 409 {error: 'precondition_failed', index, code}. |
templates |
Templates |
templates.document(locale, tid) (invoice / e-mail templates), templates.getDefault(name). |
stock |
Stock (DropCollection<StockEntity>) |
stock filtered by owner == sid |
promo |
Promo |
wraps the PromoCredits / PromoCoupons / PromoCodes collections under promo/{sid} |
Further models live under src/admin/models/ — carts, customers,
orders, products, content, files, forms, schemas, slugs,
invoices — most
reachable through store (below).
Store extends DropDocument<StoreEntity>#
In addition to the inherited document methods:
setup: StoreSetup— one typedDropDocumentper configuration doc understore/{sid}/setup(see the table below).content: StoreContentcontent.collection<T>(name)— returnsRecord<locale, ContentCollection<T>>for every locale on the store.content.document<T>(name, id)— returns aContentDocument<T>spanning every locale.
customers: Customers—DropCollection<CustomerEntity>atstore/{sid}/customers.orders: Orders—DropCollection<OrderEntity>atstore/{sid}/orders. Addsrecords(oid)for the$recordssubcollection.forms: Forms—DropCollection<FormEntity>atstore/{sid}/forms(the form's runtime doc: theactivegate, an optional displayname, and per-statussummarycounters). The submittable CONTRACT is not authored here: akind:'form'schema field places the form in a document ({form: slug, fields: […]}— static fields mixed with slim{key, kind:'variable', input}entries), and each variable's constraints/strings live in the document's per-locale value as aFormVariableValuedescriptor ({type, label?, required?, pattern?, pattern_message?, max_length?, …}). Assemble/validate with the re-exportedresolveFormContract(fields, docValue, slug)/validateFormData(contract, data)helpers. Addsrecords(form)(aDropCollection<FormRecordEntity>, newest first),setStatus(form, id, status),setActive(form, active),setOrigins(form, origins)(exact-match origin allowlist for public submissions; empty = unrestricted),setColumns(form, columns)(the admin records-listing columns —FormEntity.columns, record built-ins anddata.{key}paths in render order; empty = defaults),setNotify(form, config)(e-mail forwarding:FormNotifyConfig—emails, optionalenabled/template/client; on record CREATE the backend renders the template — the bundledformdefault when unset — and mails it through the store's SMTP setup). Visitors submit via the publicform.push; theformRecordChangedtrigger maintainssummary, firesformshooks and sends the notify e-mail.products: Products—DropCollection<ProductEntity>atstore/{sid}/products.products.product(sku)returns aProductdocument withloadContent()(multi-localeProductContent) andupdateVariant(sku, variant, data).Product.content?.records: ProductStockRecordsexposesstockUp(count).files: Files— the store files tree (store/{sid}/files/...). Rich enough to get its own chapter — see Files below.invoices: Invoices—DropCollection<InvoiceEntity>atstore/{sid}/invoices.schemas: Schemas—DropCollection<SchemaEntity>atstore/{sid}/schemas(field definitions per collection; versioned web templates live in thetemplatessubcollection of each schema doc).elements: Elements—DropCollection<ElementEntity>atstore/{sid}/elements, the store's prebuilt element library (reusable UI building blocks: fields + Handlebarshtmlfragment +css/js+sample). The library's display order + shared kit code live insetup.elements(below).slugs: Slugs—DropCollection<SlugEntity>atstore/{sid}/slug, the store-global slug registry.routeSlugs(collection, category?)returns aRouteSlugshandle on a collection's route registry (routes/{collection}/slugs) or, withcategory, its category registry (routes/{collection}/routes/{category}/slugs). Registry documents areSlugEntity— bare URL-key ids holding a per-localelocalesmap +redirect/redirects(.dev/plans/slug-registry-locale-map.md); read them through the re-exportedlookupSlug/slugRowOfrather than the top-level compat mirror.addLocale(locale)/removeLocale(locale)— union/remove onlocales.
StoreSetup — the store/{sid}/setup/* docs#
Every property is a typed DropDocument over one configuration doc:
| property | doc | entity | what it holds |
|---|---|---|---|
setup.organization |
setup/organization |
TaxAddressEntity |
Legal/tax identity for invoicing. |
setup.address |
setup/address |
AddressEntity |
Contact/shipping address. |
setup.data |
setup/data |
DataSetupEntity |
collections[] — the curated registry the admin Data page lists. |
setup.order |
setup/order |
OrderSetup |
Order/invoice numbering + order workflow config. |
setup.invoice |
setup/invoice |
InvoiceSetup |
Invoice rendering config. |
setup.hooks |
setup/hooks |
HooksSetup |
Outbound data-hook endpoints per event (see verifyHookSeed below for the receiver side). |
setup.smtp |
setup/smtp |
SmtpSetupEntity |
Outgoing e-mail transport. |
setup.design |
setup/design |
DesignSetupEntity |
The committed design system (token CSS, onboarding seeds, version); its assets field lists ingested template bundles (see Files.uploadTemplate). |
setup.web |
setup/web |
WebSetupEntity |
Web publication: publish gate, index/header/footer refs, shell.menu, SEO, headTags, footerTags, httpHeaders. |
setup.elements |
setup/elements |
ElementsSetupEntity |
Element-library display order/categories + the shared element-kit css/js/assets. |
| setup.shipping | setup/shipping | ShippingSetupEntity | Carrier configuration — validated server-side (shared keys + carriers.{tag} blocks, no credential-looking keys; verified_at / the credentials marker are platform-written). The credentials go through secure.secret.set('carrier_{tag}', json). |
| setup.delivery | setup/delivery | DeliverySetupEntity | The public delivery catalog — methods validated server-side, public: true forced. |
| setup.analytics | setup/analytics | StoreAnalyticsSetup | GA4 configuration — property_id / measurement_id normalised; sa_email / verified_at platform-written. The service-account JSON goes through secure.secret.set('ga4_service_account', json). |
The three validated docs refuse a platform-written field (403 setup_field_guarded) and an unknown key (400 setup_key_unknown); the shape rules are exported from drop-core-lib (shippingSetupShape, deliveryMethodsShape, analyticsSetupShape, analyticsConfigured) so a client can check before saving.
Files (admin.store.files)#
src/admin/models/files.ts — facade over /api/admin/upload,
/api/admin/folder, and the CRUD verbs for the store files tree
(store/{sid}/files/..., up to 5 levels deep). Methods take a dir (a
relative path under files/, e.g. 'articles/abc'); read-style methods also
accept an absolute doc ref as returned by list.
| method | notes |
|---|---|
upload(file, {dir?, id?, name?, variant?, metadata?, keepName?, onProgress?, signal?, resumable?, onRecord?}) |
Binary upload. resumable: 'auto' (default) sends files above LARGE_UPLOAD_THRESHOLD_BYTES (4 MiB) — and every file on a client without multipart (Node DropHttpClient) — through uploadLarge; smaller ones take the one-shot multipart /admin/upload (25 MB server cap; a 503 upload_busy from the core's per-instance upload gate is retried three times with a 2 / 4 / 6 s back-off — upload_retry.ts, as is writeText). true/false force the path; variant uploads are always one-shot. variant patches variants[variant] on an existing file doc. The object is stored with Content-Disposition: inline; filename="<name>", so it opens in the browser but saves under its uploaded name; keepName stores it under the slugified filename (…/urbanity-esg-report-2024.pdf) instead of the minted id — a taken basename gets a short suffix, the doc id is unaffected. Returns {path, id, entity} — an image's entity carries width / height from the server's header read (P3, 2026-10-27), on the original and on every variant. |
uploadLarge(source, {dir?, id?, name?, contentType?, metadata?, keepName?, onProgress?, signal?, onRecord?, origin?}) |
Resumable upload up to 1 GiB (LARGE_UPLOAD_MAX_BYTES): /admin/upload/start mints a Cloud Storage session, the bytes go straight to the bucket in 8 MiB chunks (never through the API), /admin/upload/finish writes the file doc. source is a Blob/File (sliced lazily) or a Node byte source (fileSource(path) from drop-core-sdk/node). Network drops are retried and resumed in place; signal pauses (the session stays resumable for 24 h). onRecord hands you the ResumableUploadRecord right after start — persist it to resume after a reload. Same destination rules and result as upload. |
resumeUpload(record, source, {onProgress?, signal?}) |
Continue an interrupted uploadLarge with the same file (size must match): asks GCS what it holds, sends the rest, finishes. |
abortUpload(record) |
Cancel the GCS session and drop the server session; bytes that already landed are reaped unless a file doc references them. |
uploadTemplate(file, {id?, name?, dir?}) |
Zip upload with server-side unzip (unzip: 'design'): extracted entries land in Storage, ONE type:'archive' manifest doc is written into the files tree (default dir templates), and a DesignAssetRef is appended to setup/design.assets. Browser-only (multipart). |
writeText({dir?, id?, name, content, contentType?, metadata?, keepName?}) |
Create/replace a TEXT file via the JSON/base64 shape of /admin/upload — works in Node too. Same id replaces in place (stable URL); text uploads get a short (5 min) cache-control. |
createBundle(name, {id?, dir?}) |
Create an EMPTY type:'archive' bundle (zero-entry manifest + its DesignAssetRef) — the bootstrap writeArchiveEntry needs. Idempotent per id. |
writeArchiveEntry(archiveLocator, entryPath, content) |
Overwrite/create ONE text entry of an ingested bundle; upserts the manifest row and bumps the bundle's DesignAssetRef.version. Entry extensions ride the bundle allow-list; max 4 MB. |
removeArchiveEntry(archiveLocator, entryPath) |
Drop one manifest row (the filesChanged trigger reaps the orphaned Storage object); bumps the bundle version. |
folder(dir, name?) |
mkdir -p along dir (root rejected — the root collection has no doc). |
list(dir = '', {sort?}) |
Immediate children; each FileNode carries id/ref + an SDK-computed dir. Sorted folders-first/alphabetical unless {sort: false}. |
get(locator) |
One file/folder doc, undefined when missing. |
setMetadata(locator, metadata) |
Patch the doc's metadata map. |
setVariants(locator, variants) |
REPLACE the whole variants map — omitting a key drops that variant (triggers delete the orphaned Storage object). |
delete(locator) |
Delete a file or folder; folder deletion cascades via the filesChanged trigger. |
Static helpers (exposed so UI layers don't duplicate the rules):
Files.publicUrl(storageBase, storage_path) (files store only bucket-relative
paths — compose URLs per environment), Files.validateSegment(name) /
Files.joinDir(...) / Files.dirSegments(dir) (path-segment rules, mirror
the server's FILE_PATH_SEGMENT_RX + FILE_PATH_MAX_DEPTH),
Files.textContentType(name), Files.isImage(node), Files.sortNodes(nodes).
When deriving a variant/file location, use the node's
storage_path— aFileNode.dirincludes the leaf id.
The resumable transport (src/admin/models/resumable_upload.ts,
browser-safe, global fetch) is exported for hosts that drive a session
themselves: ResumableUpload(sessionUri, source, chunkBytes, {onProgress, signal, retries, fetch}) with run(resume?) / status() / cancel().
Failures are ResumableUploadError with a reason: expired (404/410 —
the session is gone, start over), conflict (412 — another upload replaced
the object meanwhile), failed (a non-retryable status), aborted (the
signal fired). ResumableUploadRecord carries {sid, upload, id, path, dir, name, size, storage_path, session_uri, chunk_bytes, fingerprint, expires} —
session_uri is a write credential for that one object, keep it private;
fingerprint (name|size|lastModified) matches a re-picked file.
import {fileSource} from 'drop-core-sdk/node';
const files = core.admin.store.files;
const result = await files.uploadLarge(await fileSource('./media/showreel.mp4', {type: 'video/mp4'}), {
dir: 'video',
onProgress: (sent, total) => console.log(`${Math.round(sent / total * 100)} %`),
});
console.log(result.entity.storage_path);Localized content (ContentCollection, ContentDocument)#
ContentCollection<T> is just a DropCollection scoped to
store/{sid}/data/{locale}/{collection}. It adds documentPath(id) and the
revision history reads revisions(id, limit = 20) (newest first, one list
read) / revision(id, rev) over the server-written …/{id}/$revisions
subcollection (.dev/plans/document-editor-durability.md decision 3; writes there
are refused for every token but master — restoring = writing the document). ContentDocument<T> keeps a parallel
DropDocument per locale and offers:
of(locale)— the underlyingDropDocument<T>for that locale.addLocale/removeLocale.getRefs()—{ ref: locale }map for batch fetches.load()— batch-loads every locale and returns aRecord<locale, T>map.set(locale, data, merge?),update(locale, data),delete(locale).
Query keys#
A number is aggregate(), a chart is series(). On any admin collection
(Orders, Invoices, a ContentCollection, …) aggregate({sum, avg})
answers {count, sum, avg} for the active where in one aggregation query,
and series({start, count, stepSeconds?, field?}, {sum, avg}) answers the
same per time bucket (buckets: [{count, sum, avg}, …], null on an API
that predates it) — 1 + min(matching docs, count) reads, so a 28-day chart
never lists the 28 days' documents.
Most listings need nothing. A list, count or aggregate that combines ==
filters with a range or an order_by on ANOTHER field ("articles of category
x, newest first") runs on a query key of the collection, and the common
shapes are built in: every content collection carries status_timestamp,
status_{d} for its first two datetime fields and, for its first enum
field e, {e}_{d1} + status_{e}_{d1} (d1 = the first datetime field,
else timestamp) — recipes with an enum category get status_timestamp,
category_timestamp, status_category_timestamp; articles with a
publish_date get status_timestamp, status_publish_date. The products
mirror carries status_price, status_name, status_timestamp, and its
category / tag pages (QueryObject.arrayContains on categories / tags +
orderBy price, name or timestamp, plain or with status: 'public') run
on platform composite indexes — one array filter per query.
// built in — nothing to declare
const recipes = getDropCoreAdmin().data.collection('store/instore/data/cs_CZ/recipes');
recipes.where({ status: QueryObject.equalTo('public'), category: QueryObject.equalTo('soup') });
recipes.orderBy({ timestamp: 'desc' });
await recipes.load(); // → key status_category_timestampA shape beyond the built-ins (a number sort such as prep_time, a second
enum) needs an explicit key: a named, ordered field list on the collection's
setup/data row (keys: {status_prep_time: ['status', 'prep_time']}; ≤ 8
explicit keys on their own budget, 1–4 fields each, dotted paths allowed,
names ^[a-z][a-z0-9_]{0,31}$, never id or _k; null disables a
built-in of that name, the same name overrides it). The platform stamps
_k.{name} on every document and rewrites a matching query — equality on the
leading fields, range and/or order_by on the last — onto it; it ships no
composite index for a store's collections. An equality-only query and a
range + sort on one field need no key at all. Without a matching key the
query still runs while its equality part matches ≤ 500 documents (sorted in
memory); above that the server answers
400 {error: 'query_key_required', collection, suggest: ['status', 'prep_time']}
— the SDK rejects with an Error whose .status is 400 and whose .body
is that object. The fix is a key with exactly suggest (a data collection:
the console's collection settings → Sortable filters, or registerCollection
keys over MCP; a module's records: the manifest's collection.keys;
products: the products collection row (setup/data.collections[key = "products"].keys)) — never a smaller listing. A key
backfills asynchronously (building → ready; a built-in on the first
listing after it appears); until ready the query keeps its native/fallback
path. Never write _k (it is stripped and recomputed) — it never appears in
public reads or hook payloads. Composite indexes remain only for the fixed
core collections (orders, stock, log, the products array filters); a
missing one there is 400 {error: 'index_required', collection}. To see
which plan served a query, a master caller sends x-drop-read-profile: 1
and reads the x-drop-query-plan response header (key:{name} | native |
fallback).
Stock and the promo collections#
All are plain DropCollections preconfigured to the right path. PromoCredits.push(uid, value, { id?, note? }) writes a StockRecordType.stock record into promo/{sid}/credits/{uid}/records. PromoCredits.records(oid) lists records ordered by timestamp desc.
PromoCoupons is direct CRUD on coupon batches (promo/{sid}/coupons, newest-first); PromoCodes is the flat coupon-code index (promo/{sid}/codes), with forBatch(batch, { limit?, offset? }) loading one batch's codes via a single-field batch == query (status filtering / ordering is client-side — no composite index). Batch management rides this generic CRUD; minting / verifying / redeeming codes is the promo facade's coupons module (below).