diff --git a/README.md b/README.md
index 695f7c5..f140faa 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# NotionToRoblox
> **Make Notion the single source of truth for Roblox monetization assets.**
-> Sync Developer Products, Game Passes, and Badges from Notion databases to Roblox via the Open Cloud API.
+> Sync Developer Products, Game Passes, Badges, and free Open Cloud Assets from Notion databases to Roblox.
[](./LICENSE)
[](https://github.com/rojo-rbx/rokit)
@@ -10,7 +10,7 @@
## Overview
-NotionToRoblox is a CLI that treats Notion databases as the source of truth and synchronizes **Developer Products**, **Game Passes**, and **Badges** to a Roblox universe through the Open Cloud API. Create and update rows in Notion; the tool creates or updates matching Roblox assets and writes results back to Notion.
+NotionToRoblox is a CLI that treats Notion databases as the source of truth and synchronizes **Developer Products**, **Game Passes**, **Badges**, and **Assets** (Animation, Audio, Decal, Image, Model) to Roblox through the Open Cloud API. Edit rows in Notion; run `sync` to create missing Roblox items and `update` to push current Notion fields onto existing ones. Results are written back to Notion.
**Distribution:** Distributed via [Rokit](https://github.com/rojo-rbx/rokit) / [GitHub Releases](https://github.com/Zac134/NotionToRoblox/releases). Not published to npm (`private: true` in `package.json`).
@@ -18,31 +18,27 @@ NotionToRoblox is a CLI that treats Notion databases as the source of truth and
- [Rokit](https://github.com/rojo-rbx/rokit)
- Notion Internal Integration with access to your parent page and databases (see [Notion setup](#notion-setup))
-- Roblox Creator Dashboard API Key for your universe (see [Roblox setup](#roblox-setup); required for `sync`, not for `init` / `create-databases`)
+- Roblox Creator Dashboard API Key (see [Roblox setup](#roblox-setup); required for `sync` / `update`, not for `init` / `create-db`)
---
## Quick start
-NotionToRoblox is distributed for [Rokit](https://github.com/rojo-rbx/rokit), the toolchain manager for Roblox projects.
-
```bash
-# From your Roblox project root
rokit add Zac134/NotionToRoblox ntn-roblox
rokit install
```
-The second argument (`ntn-roblox`) is the command name on `PATH`. Without it, Rokit exposes the tool as `NotionToRoblox`.
-
-Or add it to your project's `rokit.toml`:
+Or in `rokit.toml`:
```toml
[tools]
-ntn-roblox = "Zac134/NotionToRoblox@0.1.1"
+ntn-roblox = "Zac134/NotionToRoblox@0.2.0"
```
```bash
rokit install
+ntn-roblox --version
ntn-roblox sync --help
```
@@ -52,404 +48,231 @@ ntn-roblox sync --help
ntn-roblox init
```
-Creates `.env` and `ntn-roblox.toml` in the current directory (skips files that already exist; pass `--force` to overwrite). Both are read from `process.cwd()`, so run commands from the directory that contains them (typically your Roblox project root).
-
#### First-time workflow
1. Run `ntn-roblox init`.
-2. Create a Notion integration and set `NOTION_TOKEN` in `.env` (see [Notion setup](#notion-setup)).
-3. Pick a **parent page** in Notion (existing page or new empty page) and **share it with the integration**.
-4. Set `notion.parent_page_id` in `ntn-roblox.toml` (or pass `--parent-page-id` to the next step).
-5. Run `ntn-roblox create-databases`. This creates the three databases and writes their IDs into `ntn-roblox.toml`.
-6. Complete **Roblox setup** — set `ROBLOX_API_KEY` in `.env` and `roblox.universe_id` in `ntn-roblox.toml`.
-7. Run `ntn-roblox sync`.
-
-
-
-Notion setup — integration, parent page, and databases
-
-1. **Create an integration** at [notion.so/my-integrations](https://www.notion.so/my-integrations). Choose **Internal** integration.
-
-2. **Copy the Internal Integration Secret** into `.env` as `NOTION_TOKEN`.
-
-3. **Capabilities** (integration settings → Capabilities):
- - **Read content** — required for `sync` (fetch rows from databases)
- - **Update content** — required for `sync` (write back `Roblox ID`, `Sync Status`, `Sync Error`, and `Last Synced At`)
- - **Insert content** — required for `create-databases` (creates the three databases under your parent page)
-
- For `sync` only, **Insert content** is not required unless you create rows manually outside Notion.
-
-4. **Connect the parent page to the integration:** open the page where databases should live → **⋯** → **Connections** / **Share** → **Invite** → select your integration. `create-databases` verifies it can access this page before creating databases.
-
-5. **Create databases** — choose one:
-
- **Recommended — `create-databases`**
+2. Set `NOTION_TOKEN` in `.env` (see [Notion setup](#notion-setup)).
+3. Share a **parent page** with your integration; set `notion.parent_page_id` in `ntn-roblox.toml`.
+4. Configure `[roblox.universes]` **before** `create-db` if using multi-universe (optional).
+5. Run `ntn-roblox create-db` — creates four databases and writes IDs to TOML.
+6. Set `ROBLOX_API_KEY` in `.env`, `roblox.universe_id` (or `[roblox.universes]`), and `[roblox.asset_creator]` if using Assets.
+7. Run `ntn-roblox sync` to create Roblox items; run `ntn-roblox update` after editing Notion rows.
- ```bash
- # Set parent_page_id in ntn-roblox.toml, or pass --parent-page-id
- ntn-roblox create-databases
- ```
+See [Notion setup](#notion-setup) and [Roblox setup](#roblox-setup) for details.
- Creates **Developer Products**, **Game Passes**, and **Badges** under the parent page with the schema in [Notion database schema](#notion-database-schema), then writes database IDs into `ntn-roblox.toml`. Only `NOTION_TOKEN` is required; database IDs may be empty in the TOML beforehand.
-
- If database IDs are already configured, `create-databases` exits unless you pass `--force` (creates new databases anyway; IDs in TOML are updated).
-
- **Manual — create databases yourself**
-
- Create three Notion databases (one each for Developer Products, Game Passes, and Badges). Use the exact property names and types in [Notion database schema](#notion-database-schema). At minimum, each database needs:
- - `Name` (title) — required on every row
- - `Sync Status` (select or status — **select recommended**) with options: `Pending`, `Synced`, `Error`, `Skipped`
- - Remaining shared columns: `Description`, `Icon`, `Roblox ID`, `Sync Error`, `Last Synced At`
- - Type-specific columns: `Price` + `Is For Sale` (Developer Product / Game Pass), or `Is Active` (Badge)
-
- **Tip:** Duplicate one database twice after setting up the shared columns, then add the type-specific columns to each copy.
-
- Share **each database** with the integration (same **Connections** / **Invite** flow as the parent page). Copy each 32-character hex ID from the database URL (`.../{database_id}?v=...`) into `ntn-roblox.toml`:
- - `notion.dev_product_db_id`
- - `notion.game_pass_db_id`
- - `notion.badge_db_id`
+---
- Hyphens in the URL are optional; paste the ID as shown in Notion.
+
+
+Notion setup
-6. **`Sync Status` options (manual setup):** Add all four options before syncing (as select options or status options). New rows should start as `Pending`. After a successful sync the tool sets `Synced`; failures set `Error` or `Skipped`. (`create-databases` creates these options automatically.)
+1. Create an **Internal** integration at [notion.so/my-integrations](https://www.notion.so/my-integrations).
+2. Set `NOTION_TOKEN` in `.env`.
+3. **Capabilities:** Read content, Update content (sync/update); Insert content (`create-db`).
+4. Share the parent page and each database with the integration.
+5. Run `ntn-roblox create-db` (recommended) or create databases manually per [Notion database schema](#notion-database-schema).
-Roblox setup — API key, universe, and scopes
-
-1. Open [Creator Dashboard](https://create.roblox.com/) → your experience → **Creator Hub** → **Open Cloud** → **API Keys** (or the workspace-level API Keys page).
-
-2. **Create an API key** with access to the **universe** you want to sync.
-
-3. **Grant scopes** for every resource type you plan to sync (see [Roblox API Key scopes](#roblox-api-key-scopes)):
- - Developer Products: `developer-product:read`, `developer-product:write`
- - Game Passes: `game-pass:read`, `game-pass:write`
- - Badges: `legacy-universe.badge:write`, `legacy-universe.badge:manage-and-spend-robux`, and `legacy-badge:manage` (icon updates)
-
-4. **Copy the API key** into `.env` as `ROBLOX_API_KEY`. Store it securely — it is shown only once.
+Roblox setup
-5. **Universe ID:** from the experience URL or Creator Dashboard, set `roblox.universe_id` in `ntn-roblox.toml`.
-
-6. **Badge free quota:** new badges are created only when the free quota applies. For group-owned universes, set `roblox.badge_payment_source = "group"` in `ntn-roblox.toml`.
+1. Create an API key with scopes for resources you sync (see [Roblox API Key scopes](#roblox-api-key-scopes)).
+2. Set `ROBLOX_API_KEY` in `.env`.
+3. Set `roblox.universe_id` or `[roblox.universes]` for Dev Products / Game Passes / Badges.
+4. Set `[roblox.asset_creator]` for Assets (`is_group` + `id` — user or group upload target).
+5. For group-owned badges: `roblox.badge_payment_source = "group"`.
-#### Environment variables (`.env`)
-
-| Variable | Required for | Purpose |
-| --- | --- | --- |
-| `NOTION_TOKEN` | `create-databases`, `sync` | Notion integration internal secret |
-| `ROBLOX_API_KEY` | `sync` | Roblox Open Cloud API key |
-
-Do not commit `.env`. Keep database IDs, parent page ID, and universe settings in `ntn-roblox.toml` instead.
-
#### Configuration (`ntn-roblox.toml`)
-**Required for `sync`.** Configuration is read from `ntn-roblox.toml` and `.env`. There are no CLI flags to override TOML values during `sync`. For `create-databases`, `--parent-page-id` can supply `notion.parent_page_id` instead of setting it in the file. Unknown keys are rejected at startup.
-
-See [ntn-roblox.toml.example](./ntn-roblox.toml.example) for a commented template (same content as `ntn-roblox init`).
-
-| Key | Required for | Default | Purpose |
-| --- | --- | --- | --- |
-| `notion.parent_page_id` | `create-databases` | — | Parent page where `create-databases` creates databases (32-char hex ID) |
-| `notion.dev_product_db_id` | `sync` | — | Notion database ID for Developer Products (`create-databases` may leave empty beforehand) |
-| `notion.game_pass_db_id` | `sync` | — | Notion database ID for Game Passes (`create-databases` may leave empty beforehand) |
-| `notion.badge_db_id` | `sync` | — | Notion database ID for Badges (`create-databases` may leave empty beforehand) |
-| `roblox.universe_id` | `sync` | — | Target universe ID |
-| `roblox.badge_payment_source` | No | `user` | Badge create payment source: `user` or `group` (free quota only) |
-| `logging.level` | No | `info` | Log level: `debug`, `info`, `warn`, or `error` |
-
-Example `ntn-roblox.toml` (after `create-databases`):
+| Key | Required for | Purpose |
+| --- | --- | --- |
+| `notion.parent_page_id` | `create-db` | Parent page for database creation |
+| `notion.is_inline` | `create-db` | Inline DBs on parent (default `true`) |
+| `notion.dev_product_db_id` | `sync`, `update` | Developer Products database ID |
+| `notion.game_pass_db_id` | `sync`, `update` | Game Passes database ID |
+| `notion.badge_db_id` | `sync`, `update` | Badges database ID |
+| `notion.asset_db_id` | `sync`, `update` | Assets database ID |
+| `roblox.universe_id` | `sync`, `update` | Single universe (monetization types) |
+| `roblox.universes` | `sync`, `update` | Multi-universe map (alternative to `universe_id`) |
+| `roblox.asset_creator` | Assets | `is_group` + `id` for upload ownership |
+| `roblox.badge_payment_source` | Badges | `user` or `group` (free quota) |
+| `logging.level` | No | `debug` / `info` / `warn` / `error` |
+
+Example:
```toml
[notion]
parent_page_id = "your-parent-page-id"
-dev_product_db_id = "your-dev-product-database-id"
-game_pass_db_id = "your-game-pass-database-id"
-badge_db_id = "your-badge-database-id"
+dev_product_db_id = "..."
+game_pass_db_id = "..."
+badge_db_id = "..."
+asset_db_id = "..."
[roblox]
universe_id = 1234567890
-# badge_payment_source = "user"
+
+[roblox.asset_creator]
+is_group = false
+id = 123456789
[logging]
# level = "info"
```
-### First sync
-
-```bash
-ntn-roblox sync # full sync (all resource types)
-ntn-roblox sync --dry-run # log planned actions only
-ntn-roblox sync --type=badge # one resource type
-```
-
---
## Notion database schema
-Create three databases using the property names and types below. These names are **case-sensitive** and must match exactly.
+Property names are **case-sensitive**. Extra columns are ignored — you may add your own properties freely.
-### Shared properties
+### Shared (Developer Products, Game Passes, Badges)
-| Property | Notion type | Description |
+| Property | Type | Description |
| --- | --- | --- |
-| `Name` | title | Required. Maps to Roblox `name`. |
-| `Description` | rich_text | Roblox `description`. |
-| `Icon` | files | Only the first file is synced. Skips icon update when empty. |
-| `Roblox ID` | number | Match key. Written back after a successful Create. |
-| `Sync Status` | select **or** status | `Pending` / `Synced` / `Error` / `Skipped`. Both **select** and **status** are supported for read and writeback. **select** is recommended. |
-| `Sync Error` | rich_text | Failure or skip reason. Cleared on success. |
-| `Last Synced At` | date | Timestamp on successful sync. |
-
-### DeveloperProduct and GamePass only
+| `Name` | title | Required |
+| `Description` | rich_text | Roblox description |
+| `Icon` | files | First file only (Dev Product / Game Pass / Badge) |
+| `Roblox ID` | number | Single-universe mode |
+| `Roblox ID (Key)` | number | Multi-universe: one column per TOML key |
+| `Sync Status` | select | `Pending` / `Synced` / `Error` / `Skipped` |
+| `Sync Error` | rich_text | Cleared on success |
+| `Last Synced At` | date | Success timestamp |
-| Property | Notion type | Description |
-| --- | --- | --- |
-| `Price` | number | Robux price. Required on Create. |
-| `Is For Sale` | checkbox | Maps to `isForSale`. Defaults to `true` when unset. |
+Create vs update is decided by whether the Roblox ID column is set, **not** by Sync Status.
-### Badge only
+### Assets database
-| Property | Notion type | Description |
+| Property | Type | Description |
| --- | --- | --- |
-| `Is Active` | checkbox | Create: `isActive` / Update: `enabled`. Defaults to `true` when unset. |
+| `Name` | title | Roblox `displayName` |
+| `File` | files | Upload file (first attachment). **Create only** |
+| `Asset Type` | select | `Animation`, `Audio`, `Decal`, `Image`, `Model` |
+| `Roblox ID` | number | Always single column (even in multi-universe) |
+| `Description` | rich_text | Optional; empty sends `""` |
+| `Sync Status` / `Sync Error` / `Last Synced At` | — | Same as above |
+
+**Not supported:** Video (paid), Mesh (Roblox-delivery only).
---
## Roblox API Key scopes
-Grant the following scopes on your API key.
-
-| Resource | Required scopes |
+| Resource | Scopes |
| --- | --- |
| Developer Product | `developer-product:read`, `developer-product:write` |
| Game Pass | `game-pass:read`, `game-pass:write` |
-| Badge (create & update) | `legacy-universe.badge:write`, `legacy-universe.badge:manage-and-spend-robux` |
-| Badge (icon update) | `legacy-badge:manage` |
-
-Badge listing and free-quota checks use the public `badges.roblox.com` API and do not require an API key.
-
----
-
-## Use in Roblox projects
-
-Typical layout when using Rokit:
-
-```
-my-game/
- .env # secrets (gitignored)
- ntn-roblox.toml # parent page ID, DB IDs, universe_id (safe to commit)
- rokit.toml # ntn-roblox = "Zac134/NotionToRoblox@0.1.1"
- default.project.json
-```
-
-Run `ntn-roblox init`, `ntn-roblox create-databases`, or `ntn-roblox sync` from the project root (where `.env` and `ntn-roblox.toml` live). Add sync to CI or a pre-commit hook to keep Notion authoritative.
+| Badge | `legacy-universe.badge:write`, `legacy-universe.badge:manage-and-spend-robux`, `legacy-badge:manage` |
+| Assets | `asset:read`, `asset:write` |
---
## CLI
-`sync` is the default command when omitted.
+Global options (all commands): `--help`, `-h`, `--version`, `-V`
-### `init`
+### `sync` (default)
-Create `.env` and `ntn-roblox.toml` in the current directory. No Notion or Roblox credentials required.
+Create Roblox items for rows **without** a Roblox ID.
```bash
-ntn-roblox init [--force]
+ntn-roblox sync [--dry-run] [--report-only] [--type=...] [--target=Key]
```
| Option | Description |
| --- | --- |
-| `--force` | Overwrite existing `.env` / `ntn-roblox.toml` |
-| `--help`, `-h` | Show help |
+| `--dry-run` | Log planned creates only |
+| `--report-only` | Orphan report only (monetization types) |
+| `--type=` | `developer-product`, `game-pass`, `badge`, or `asset` |
+| `--target=` | One universe (requires `[roblox.universes]`) |
-Existing files are skipped unless `--force` is set. After filling in secrets and `parent_page_id`, run `create-databases`.
+### `update`
-### `create-databases`
-
-Create the three Notion databases under a parent page and write their IDs into `ntn-roblox.toml`. Requires `NOTION_TOKEN` only.
+Push current Notion fields to **existing** Roblox items (ID set).
```bash
-ntn-roblox create-databases [--parent-page-id=] [--force]
+ntn-roblox update [--dry-run] [--type=...] [--target=Key]
```
-| Option | Description |
-| --- | --- |
-| `--parent-page-id=` | Parent page ID (overrides `notion.parent_page_id` in TOML) |
-| `--force` | Create new databases even when database IDs are already configured |
-| `--help`, `-h` | Show help |
+Assets: metadata only (`Name`, `Description`). `File` is ignored on update.
-Share the parent page with your integration before running `create-databases`. Database IDs may be empty in `ntn-roblox.toml` beforehand; they are required before `sync`.
+### `create-db`
-### `sync`
+Creates four Notion databases. Alias: `create-databases`.
```bash
-ntn-roblox sync [--dry-run] [--report-only] [--force] [--type=developer-product|game-pass|badge]
+ntn-roblox create-db [--parent-page-id=] [--force]
```
-| Option | Description |
-| --- | --- |
-| `--dry-run` | Log planned mutations without writing to Roblox or Notion |
-| `--report-only` | List Roblox orphans only; skip create/update |
-| `--force` | Include rows with `Sync Status = Synced` in create/update (default: skipped) |
-| `--type=` | Limit to one resource type (`developer-product`, `game-pass`, or `badge`) |
-| `--help`, `-h` | Show help |
-
-Exit code (`sync`): `1` if any row ends in `Error` or `Skipped`; otherwise `0`.
-
---
-## Sync overview
-
-1. Fetch current Roblox state via List APIs.
-2. Report Roblox items not present in Notion to the console (runs in all modes including `--dry-run` and `--report-only`; no Notion writes). Orphan reporting is informational only and does not affect the exit code.
-3. Create or update Notion rows whose `Sync Status` is not `Synced` (or all rows when `--force` is set), then write results back to Notion.
-
-Rows with `Sync Status = Synced` are skipped unless `--force` is set. With `--force`, Synced rows are create/update targets: rows without `Roblox ID` are created; rows with `Roblox ID` are updated. Rows with `Roblox ID` set and status `Pending`, `Error`, or `Skipped` are always updated on Roblox.
+## Multi-universe
-If Roblox Create succeeds but Notion writeback fails completely, the CLI logs a `CRITICAL` message with the new `robloxId`. Manually set that `Roblox ID` on the Notion page and set `Sync Status` to `Synced` or `Error`.
+```toml
+[roblox.universes]
+Prod = 111111111
+Dev = 222222222
+```
-**Badge free quota:** New badges are created only when the free quota applies (`expectedCost=0`). Paid creation (100 Robux each) is not supported. When quota is 0, creation is skipped with `Skipped` status and retried automatically on the next sync after the daily GMT reset. Set `badge_payment_source = "group"` in `ntn-roblox.toml` for group-owned universes.
+- Dev Product / Game Pass / Badge: `Roblox ID (Prod)` etc.; `--target=Prod` limits scope.
+- **Assets:** single `Roblox ID` column; creator-scoped, not per-universe.
---
-## Errors
-
-The CLI exits with code `1` when configuration is invalid, mapping fails, or any row ends in `Error` or `Skipped`. Common cases:
-
-- **Missing `ntn-roblox.toml`** — run `ntn-roblox init`, or copy from [ntn-roblox.toml.example](./ntn-roblox.toml.example)
-- **Missing or invalid secrets** — set `NOTION_TOKEN` in `.env` for `create-databases`; both `NOTION_TOKEN` and `ROBLOX_API_KEY` for `sync`
-- **Missing `notion.parent_page_id` on create-databases** — set it in `ntn-roblox.toml` or pass `--parent-page-id`
-- **Parent page not shared with integration** — share the page via **Connections** / **Invite** (see [Notion setup](#notion-setup))
-- **Database IDs already configured on create-databases** — remove or update stale IDs, or pass `--force`
-- **Unknown TOML keys** — the config schema is strict; remove extra keys
-- **Invalid `Sync Status` value** — must be one of `Pending`, `Synced`, `Error`, `Skipped`
-- **Missing `Price` on Create** — Developer Product and Game Pass rows require a price
-- **Badge free quota exhausted** — row is marked `Skipped`; retries after the daily GMT reset
-- **Notion permission errors** — for `create-databases`, ensure **Insert content** and parent-page access; for `sync`, ensure **Read content** and **Update content**, and all three databases are shared (see [Notion setup](#notion-setup))
-- **Roblox API errors** — verify API key scopes (see [Roblox API Key scopes](#roblox-api-key-scopes))
-
----
+## Assets
-
-Contributing & development (Node.js)
+Upload free asset types via Open Cloud Assets API. Configure `[roblox.asset_creator]`:
-### Requirements
+```toml
+[roblox.asset_creator]
+is_group = true # true = groupId, false = userId
+id = 987654321
+```
-- Node.js 22+
-- npm 10.9.2+
+| Asset Type | Upload | Update content |
+| --- | --- | --- |
+| Decal, Image, Audio, Animation, Model | ✅ | Metadata only (`update`) |
-### Setup
+Video (2000 Robux) and Mesh are excluded.
-```bash
-npm install
-ntn-roblox init # or: npm run init
-# Edit .env and ntn-roblox.toml
-```
+---
-### Development commands
+## Migrating from 0.1.x
-```bash
-npm run build # tsc
-npm run check # type check
-npm run start -- init --help # init command
-npm run start -- create-databases --help # create-databases command
-npm run start -- sync --help # run compiled CLI
-npm run sync # Full sync (no build required)
-npm run sync -- --dry-run # No mutations (planned actions only)
-npm run sync -- --report-only # Orphan report only
-npm run sync -- --type=developer-product|game-pass|badge
-```
+- `sync --force` removed — use `update` to push changes to existing items.
+- `sync` creates only; `update` updates only.
+- Add `asset_db_id` and `asset_creator` for Assets.
+- Multi-universe: switch from `universe_id` to `[roblox.universes]` and rename ID columns.
-### Project layout
+---
-```
-ntn-roblox.toml.example # Non-secret config template (same as init output)
-src/
- cli.ts # CLI entry
- env.ts # .env loader (process.cwd())
- toml.ts # ntn-roblox.toml loader (process.cwd())
- tomlWrite.ts # create-databases TOML updates
- config.ts # .env + TOML validation (zod)
- init/ # Config scaffolding (init command)
- createDatabases/ # Notion database bootstrap (create-databases command)
- sync/
- engine.ts # Sync orchestration
- candidates.ts # Row action selection
- orphanReport.ts # Orphan reporting
- notion/
- mapRow.ts # Notion row mapping
- writeback.ts # Notion writeback
- roblox/ # Open Cloud & badge API clients
- util/
-```
+## Errors
-### Building binaries (maintainers)
+Exit code `1` on config errors, mapping failures, or any row ending in `Error` / `Skipped`.
-Release artifacts are standalone Bun-compiled binaries packaged as Rokit-compatible zip files.
+---
-**Prerequisites:** [Bun](https://bun.sh) on `PATH`, and `zip` or Python 3
+
+Contributing & development
```bash
-npm run compile -- 0.1.1 bun-darwin-arm64 ./release
-```
-
-| `bun-target` | Zip suffix |
-| --- | --- |
-| `bun-linux-x64` | `linux-x86_64` |
-| `bun-linux-arm64` | `linux-aarch64` |
-| `bun-darwin-x64` | `macos-x86_64` |
-| `bun-darwin-arm64` | `macos-aarch64` |
-| `bun-windows-x64` | `windows-x86_64` |
-| `bun-windows-arm64` | `windows-aarch64` |
-
-Output: `NotionToRoblox---.zip` containing a single `NotionToRoblox` (or `NotionToRoblox.exe` on Windows) binary. Rokit installs that binary and links it on `PATH` as `ntn-roblox` when your `rokit.toml` uses the `ntn-roblox` alias.
-
-#### Release checklist
-
-1. Confirm checks pass: `npm run check`
-2. Align version strings in `package.json`, README, and the commented consumer example in this repo's `rokit.toml` (the line stays commented here; consumer projects uncomment it in their own `rokit.toml`)
-3. Tag and push:
- ```bash
- git tag v0.1.1
- git push origin v0.1.1
+npm install
+npm run check
+npm test
+npm run sync -- --dry-run
+npm run update -- --dry-run
```
-4. GitHub Actions builds all six targets and attaches zip assets to the Release
-5. Verify from a clean Roblox project:
- ```bash
- rokit add Zac134/NotionToRoblox@0.1.1 ntn-roblox
- rokit install
- ntn-roblox sync --help
- ```
-
-#### Pre-publication checklist (maintainers)
-
-Before making the repository public or cutting the first GitHub Release:
-- [ ] Set the GitHub repository visibility to **Public**
-- [ ] Confirm no secrets in git history or tracked files (`.env`, API keys, real tokens in examples)
-- [ ] Enable **Dependabot** alerts and version updates (see [`.github/dependabot.yml`](./.github/dependabot.yml))
-- [ ] Enable **Private vulnerability reporting** (Security → Advisories) — recommended
-
-See [SECURITY.md](./SECURITY.md) for vulnerability reporting.
+Release: `npm run compile -- 0.2.0 bun-darwin-arm64 ./release`
---
-## Security
-
-Report vulnerabilities privately via [GitHub Security Advisories](https://github.com/Zac134/NotionToRoblox/security/advisories/new) or a high-level GitHub Issue. **Do not paste secrets** (`NOTION_TOKEN`, `ROBLOX_API_KEY`, `.env` contents) in public reports.
-
-Details: [SECURITY.md](./SECURITY.md)
-
----
-
## License
-MIT License. See [LICENSE](./LICENSE) for details.
+MIT — see [LICENSE](./LICENSE).
diff --git a/ntn-roblox.toml.example b/ntn-roblox.toml.example
index 8eaef5d..0255345 100644
--- a/ntn-roblox.toml.example
+++ b/ntn-roblox.toml.example
@@ -1,17 +1,25 @@
# Generated by `ntn-roblox init`. Safe to commit — secrets belong in .env only.
[notion]
-# Parent page where `ntn-roblox create-databases` creates the three databases
+# Parent page where `ntn-roblox create-db` creates the databases
# (32-char hex ID from the page URL). Share this page with your integration first.
parent_page_id = ""
-# Filled by `ntn-roblox create-databases`, or set manually if you create databases yourself.
+# Filled by `ntn-roblox create-db`, or set manually if you create databases yourself.
dev_product_db_id = ""
game_pass_db_id = ""
badge_db_id = ""
+asset_db_id = ""
+# is_inline = true
[roblox]
+# Use universe_id for a single universe, OR universes for multi-universe sync (not both).
universe_id = 1234567890
+# universes = { main = 1234567890, staging = 9876543210 }
# badge_payment_source = "user"
+# Required when asset_db_id is configured (for asset upload ownership):
+# [roblox.asset_creator]
+# is_group = false
+# id = 12345678
[logging]
# level = "info"
diff --git a/package-lock.json b/package-lock.json
index d4c521a..d193901 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "notion-to-roblox",
- "version": "0.1.1",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "notion-to-roblox",
- "version": "0.1.1",
+ "version": "0.2.0",
"license": "MIT",
"dependencies": {
"@notionhq/client": "^2.2.15",
diff --git a/package.json b/package.json
index 40d0303..5e8d8f7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "notion-to-roblox",
- "version": "0.1.1",
+ "version": "0.2.0",
"private": true,
"description": "CLI to sync Notion database records to Roblox Developer Products, Game Passes, and Badges via Open Cloud API",
"license": "MIT",
@@ -24,8 +24,10 @@
"check": "tsc --noEmit",
"test": "node --experimental-test-module-mocks --import tsx --import ./test/setup.ts --test test/**/*.test.ts",
"init": "tsx src/cli.ts init",
+ "create-db": "tsx src/cli.ts create-databases",
"create-databases": "tsx src/cli.ts create-databases",
- "sync": "tsx src/cli.ts",
+ "sync": "tsx src/cli.ts sync",
+ "update": "tsx src/cli.ts update",
"start": "node dist/cli.js",
"compile": "bash scripts/package-release.sh"
},
diff --git a/rokit.toml b/rokit.toml
index 5c73cf8..14d5e0d 100644
--- a/rokit.toml
+++ b/rokit.toml
@@ -5,4 +5,4 @@
[tools]
# After publishing a GitHub Release, consumer projects can add:
-# ntn-roblox = "Zac134/NotionToRoblox@0.1.1"
+# ntn-roblox = "Zac134/NotionToRoblox@0.2.0"
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
index 04f2f34..17072f3 100755
--- a/scripts/package-release.sh
+++ b/scripts/package-release.sh
@@ -66,7 +66,11 @@ trap 'rm -rf "$WORK_DIR"' EXIT
BINARY_PATH="$WORK_DIR/$BIN_NAME"
echo "Compiling src/cli.ts for $BUN_TARGET ..."
-bun build src/cli.ts --compile --target="$BUN_TARGET" --outfile="$BINARY_PATH"
+bun build src/cli.ts \
+ --compile \
+ --target="$BUN_TARGET" \
+ --define "NTN_ROBLOX_VERSION=\"$VERSION\"" \
+ --outfile="$BINARY_PATH"
if [[ "$BIN_NAME" != *.exe ]]; then
chmod +x "$BINARY_PATH"
diff --git a/src/assetTypes.ts b/src/assetTypes.ts
new file mode 100644
index 0000000..883d4e4
--- /dev/null
+++ b/src/assetTypes.ts
@@ -0,0 +1,13 @@
+export const SUPPORTED_ASSET_TYPES = [
+ "Animation",
+ "Audio",
+ "Decal",
+ "Image",
+ "Model",
+] as const;
+
+export type SupportedAssetType = (typeof SUPPORTED_ASSET_TYPES)[number];
+
+export function isSupportedAssetType(value: string): value is SupportedAssetType {
+ return (SUPPORTED_ASSET_TYPES as readonly string[]).includes(value);
+}
diff --git a/src/cli.ts b/src/cli.ts
index 1be1901..51b8ce8 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,5 +1,6 @@
#!/usr/bin/env node
+import { pathToFileURL } from "node:url";
import {
loadCreateDatabasesConfig,
loadSyncConfig,
@@ -10,29 +11,34 @@ import { loadEnvFile } from "./env.js";
import { runInit } from "./init/runInit.js";
import type { ResourceType } from "./types.js";
import { setLogLevel } from "./util/logger.js";
+import { VERSION } from "./version.js";
-type Command = "sync" | "init" | "create-databases";
+type SyncCommand = "sync" | "update";
+type Command = SyncCommand | "init" | "create-databases" | "create-db";
interface SyncParsedArgs {
- command: "sync";
+ command: SyncCommand;
dryRun: boolean;
reportOnly: boolean;
- force: boolean;
typeFilter?: ResourceType;
+ targetFilter?: string;
help: boolean;
+ version: boolean;
}
interface InitParsedArgs {
command: "init";
force: boolean;
help: boolean;
+ version: boolean;
}
interface CreateDatabasesParsedArgs {
- command: "create-databases";
+ command: "create-databases" | "create-db";
parentPageId?: string;
force: boolean;
help: boolean;
+ version: boolean;
}
type ParsedArgs =
@@ -44,41 +50,56 @@ const RESOURCE_TYPES: ResourceType[] = [
"developer-product",
"game-pass",
"badge",
+ "asset",
];
-const COMMANDS: Command[] = ["sync", "init", "create-databases"];
+const COMMANDS: Command[] = [
+ "sync",
+ "update",
+ "init",
+ "create-databases",
+ "create-db",
+];
function printUsage(): void {
- console.error(`Usage:
+ console.log(`Usage:
ntn-roblox init [options]
+ ntn-roblox create-db [options]
ntn-roblox create-databases [options]
ntn-roblox sync [options]
+ ntn-roblox update [options]
Development:
npm run init -- [options]
- npm run create-databases -- [options]
+ npm run create-db -- [options]
npm run sync -- [options]
+ npm run update -- [options]
Commands:
init Create .env and ntn-roblox.toml in the current directory
+ create-db Alias for create-databases
create-databases Create Notion databases and write IDs to ntn-roblox.toml
- sync Run full synchronization (default)
+ sync Create Roblox items for rows without Roblox ID
+ update Update Roblox items for rows with Roblox ID
Init options:
--force Overwrite existing .env / ntn-roblox.toml
--help, -h Show this help message
+ --version, -V Show version
Create-databases options:
--parent-page-id= Notion parent page ID (overrides ntn-roblox.toml)
--force Create new databases even if IDs are already configured
--help, -h Show this help message
+ --version, -V Show version
-Sync options:
+Sync / update options:
--dry-run Log planned mutations without writing to Roblox or Notion
--report-only List Roblox orphans only; skip create/update
- --force Re-sync rows with Sync Status = Synced
--type= Limit to one resource type (${RESOURCE_TYPES.join(" | ")})
+ --target= Limit to one universe key (multi-universe configs only)
--help, -h Show this help message
+ --version, -V Show version
`);
}
@@ -97,10 +118,6 @@ function parseSyncArg(arg: string, parsed: SyncParsedArgs): void {
parsed.reportOnly = true;
return;
}
- if (arg === "--force") {
- parsed.force = true;
- return;
- }
if (arg.startsWith("--type=")) {
const value = arg.slice("--type=".length) as ResourceType;
if (!RESOURCE_TYPES.includes(value)) {
@@ -111,6 +128,14 @@ function parseSyncArg(arg: string, parsed: SyncParsedArgs): void {
parsed.typeFilter = value;
return;
}
+ if (arg.startsWith("--target=")) {
+ const value = arg.slice("--target=".length).trim();
+ if (!value) {
+ throw new Error("Invalid --target value: value must not be empty");
+ }
+ parsed.targetFilter = value;
+ return;
+ }
throw new Error(`Unknown argument: ${arg}`);
}
@@ -119,6 +144,7 @@ function parseInitArg(arg: string, parsed: InitParsedArgs): void {
arg === "--dry-run" ||
arg === "--report-only" ||
arg.startsWith("--type=") ||
+ arg.startsWith("--target=") ||
arg.startsWith("--parent-page-id=") ||
arg === "--write-toml"
) {
@@ -139,6 +165,7 @@ function parseCreateDatabasesArg(
arg === "--dry-run" ||
arg === "--report-only" ||
arg.startsWith("--type=") ||
+ arg.startsWith("--target=") ||
arg === "--write-toml"
) {
throw new Error(`Unknown argument: ${arg}`);
@@ -164,9 +191,17 @@ function isCommand(value: string): value is Command {
return (COMMANDS as string[]).includes(value);
}
+function normalizeCommand(command: Command): ParsedArgs["command"] {
+ if (command === "create-db") {
+ return "create-databases";
+ }
+ return command;
+}
+
function parseArgs(argv: string[]): ParsedArgs {
let command: Command | undefined;
let help = false;
+ let version = false;
for (const arg of argv) {
if (isCommand(arg)) {
@@ -180,23 +215,25 @@ function parseArgs(argv: string[]): ParsedArgs {
const resolvedCommand = command ?? "sync";
const syncParsed: SyncParsedArgs = {
- command: "sync",
+ command: resolvedCommand === "update" ? "update" : "sync",
dryRun: false,
reportOnly: false,
- force: false,
help: false,
+ version: false,
};
const initParsed: InitParsedArgs = {
command: "init",
force: false,
help: false,
+ version: false,
};
const createDatabasesParsed: CreateDatabasesParsedArgs = {
command: "create-databases",
force: false,
help: false,
+ version: false,
};
for (const arg of argv) {
@@ -204,6 +241,10 @@ function parseArgs(argv: string[]): ParsedArgs {
help = true;
continue;
}
+ if (arg === "--version" || arg === "-V") {
+ version = true;
+ continue;
+ }
if (isCommand(arg)) {
continue;
}
@@ -211,7 +252,7 @@ function parseArgs(argv: string[]): ParsedArgs {
throw new Error(`Unknown argument: ${arg}`);
}
- if (resolvedCommand === "sync") {
+ if (resolvedCommand === "sync" || resolvedCommand === "update") {
parseSyncArg(arg, syncParsed);
} else if (resolvedCommand === "init") {
parseInitArg(arg, initParsed);
@@ -222,15 +263,18 @@ function parseArgs(argv: string[]): ParsedArgs {
if (resolvedCommand === "init") {
initParsed.help = help;
+ initParsed.version = version;
return initParsed;
}
- if (resolvedCommand === "create-databases") {
+ if (resolvedCommand === "create-databases" || resolvedCommand === "create-db") {
createDatabasesParsed.help = help;
+ createDatabasesParsed.version = version;
return createDatabasesParsed;
}
syncParsed.help = help;
+ syncParsed.version = version;
return syncParsed;
}
@@ -239,6 +283,11 @@ async function main(): Promise {
const args = parseArgs(process.argv.slice(2));
+ if (args.version) {
+ console.log(VERSION);
+ return;
+ }
+
if (args.help) {
printUsage();
return;
@@ -258,15 +307,20 @@ async function main(): Promise {
return;
}
+ if (args.command !== "sync" && args.command !== "update") {
+ return;
+ }
+
setConfig(loadSyncConfig());
const { runSync, shouldExitWithError } = await import("./sync/engine.js");
const result = await runSync({
+ mode: args.command,
dryRun: args.dryRun,
reportOnly: args.reportOnly,
- force: args.force,
typeFilter: args.typeFilter,
+ targetFilter: args.targetFilter,
});
if (shouldExitWithError(result)) {
@@ -274,8 +328,16 @@ async function main(): Promise {
}
}
-main().catch(async (error) => {
- const { logger } = await import("./util/logger.js");
- logger.error(error instanceof Error ? error.message : String(error));
- process.exitCode = 1;
-});
+const isMainModule =
+ process.argv[1] !== undefined &&
+ import.meta.url === pathToFileURL(process.argv[1]).href;
+
+if (isMainModule) {
+ main().catch(async (error) => {
+ const { logger } = await import("./util/logger.js");
+ logger.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ });
+}
+
+export { parseArgs, printUsage, VERSION };
diff --git a/src/config.ts b/src/config.ts
index 27018fc..0779633 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { assertValidUniverseKey } from "./notion/propertyNames.js";
import { loadTomlFile } from "./toml.js";
import { setLogLevel } from "./util/logger.js";
@@ -11,6 +12,62 @@ const createDatabasesSecretsSchema = z.object({
NOTION_TOKEN: z.string().min(1, "NOTION_TOKEN is required"),
});
+const assetCreatorSchema = z
+ .object({
+ is_group: z.boolean(),
+ id: z.number().int().positive(),
+ })
+ .strict();
+
+const robloxTomlBaseSchema = z
+ .object({
+ universe_id: z.number().int().positive().optional(),
+ universes: z.record(z.string(), z.number().int().positive()).optional(),
+ badge_payment_source: z.enum(["user", "group"]).default("user"),
+ asset_creator: assetCreatorSchema.optional(),
+ })
+ .strict();
+
+function validateUniverseConfig(
+ roblox: z.infer,
+ ctx: z.RefinementCtx,
+): void {
+ const hasUniverseId = roblox.universe_id !== undefined;
+ const hasUniverses =
+ roblox.universes !== undefined && Object.keys(roblox.universes).length > 0;
+
+ if (hasUniverseId && hasUniverses) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "roblox.universe_id and roblox.universes are mutually exclusive",
+ path: ["universe_id"],
+ });
+ }
+
+ if (!hasUniverseId && !hasUniverses) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Either roblox.universe_id or roblox.universes is required",
+ path: ["universe_id"],
+ });
+ }
+
+ if (hasUniverses) {
+ for (const key of Object.keys(roblox.universes!)) {
+ try {
+ assertValidUniverseKey(key);
+ } catch (error) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ error instanceof Error ? error.message : "Invalid universe key",
+ path: ["universes", key],
+ });
+ }
+ }
+ }
+}
+
const syncTomlSchema = z
.object({
notion: z
@@ -18,15 +75,12 @@ const syncTomlSchema = z
dev_product_db_id: z.string().min(1),
game_pass_db_id: z.string().min(1),
badge_db_id: z.string().min(1),
+ asset_db_id: z.string().min(1),
parent_page_id: z.string().optional(),
+ is_inline: z.boolean().default(true),
})
.strict(),
- roblox: z
- .object({
- universe_id: z.number().int().positive(),
- badge_payment_source: z.enum(["user", "group"]).default("user"),
- })
- .strict(),
+ roblox: robloxTomlBaseSchema.superRefine(validateUniverseConfig),
logging: z
.object({
level: z.enum(["debug", "info", "warn", "error"]).default("info"),
@@ -43,16 +97,46 @@ const createDatabasesTomlSchema = z
dev_product_db_id: z.string().optional(),
game_pass_db_id: z.string().optional(),
badge_db_id: z.string().optional(),
+ asset_db_id: z.string().optional(),
parent_page_id: z.string().optional(),
+ is_inline: z.boolean().default(true),
})
.strict()
.default({}),
- roblox: z
- .object({
- universe_id: z.number().int().positive().optional(),
- badge_payment_source: z.enum(["user", "group"]).default("user"),
+ roblox: robloxTomlBaseSchema
+ .partial({ badge_payment_source: true })
+ .superRefine((roblox, ctx) => {
+ const hasUniverseId = roblox.universe_id !== undefined;
+ const hasUniverses =
+ roblox.universes !== undefined &&
+ Object.keys(roblox.universes).length > 0;
+
+ if (hasUniverseId && hasUniverses) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "roblox.universe_id and roblox.universes are mutually exclusive",
+ path: ["universe_id"],
+ });
+ }
+
+ if (hasUniverses) {
+ for (const key of Object.keys(roblox.universes!)) {
+ try {
+ assertValidUniverseKey(key);
+ } catch (error) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ error instanceof Error
+ ? error.message
+ : "Invalid universe key",
+ path: ["universes", key],
+ });
+ }
+ }
+ }
})
- .strict()
.optional(),
logging: z
.object({
@@ -63,15 +147,26 @@ const createDatabasesTomlSchema = z
})
.strict();
+export type RobloxAssetCreator = z.infer;
+
+export type SyncTarget = {
+ key: string | null;
+ universeId: number;
+};
+
export type Config = {
NOTION_TOKEN: string;
ROBLOX_API_KEY: string;
NOTION_DEVPRODUCT_DB_ID: string;
NOTION_GAMEPASS_DB_ID: string;
NOTION_BADGE_DB_ID: string;
+ NOTION_ASSET_DB_ID: string;
NOTION_PARENT_PAGE_ID?: string;
+ NOTION_IS_INLINE: boolean;
ROBLOX_UNIVERSE_ID: number;
+ ROBLOX_UNIVERSES: Record;
ROBLOX_BADGE_PAYMENT_SOURCE: "user" | "group";
+ ROBLOX_ASSET_CREATOR?: RobloxAssetCreator;
LOG_LEVEL: "debug" | "info" | "warn" | "error";
};
@@ -94,7 +189,51 @@ function normalizeOptionalDbId(value: string | undefined): string {
return value?.trim() ?? "";
}
+function buildRobloxUniverses(roblox: {
+ universe_id?: number;
+ universes?: Record;
+}): Record {
+ if (roblox.universes !== undefined) {
+ return { ...roblox.universes };
+ }
+ return {};
+}
+
+function resolveInitialUniverseId(roblox: {
+ universe_id?: number;
+ universes?: Record;
+}): number {
+ if (roblox.universe_id !== undefined) {
+ return roblox.universe_id;
+ }
+ if (
+ roblox.universes !== undefined &&
+ Object.keys(roblox.universes).length > 0
+ ) {
+ const keys = Object.keys(roblox.universes).sort();
+ return roblox.universes[keys[0]!]!;
+ }
+ return 0;
+}
+
+function validateAssetCreatorWhenAssetDbConfigured(
+ assetDbId: string,
+ assetCreator: RobloxAssetCreator | undefined,
+ errors: string[],
+): void {
+ if (!isNotionDatabaseIdConfigured(assetDbId)) {
+ return;
+ }
+ if (!assetCreator) {
+ errors.push(
+ "roblox.asset_creator: required when notion.asset_db_id is configured",
+ );
+ }
+}
+
let currentConfig: Config | null = null;
+let multiUniverseMode = false;
+let activeUniverseKey: string | null = null;
export function getConfig(): Config {
if (!currentConfig) {
@@ -110,6 +249,67 @@ export function setConfig(config: Config): void {
setLogLevel(config.LOG_LEVEL);
}
+export function isMultiUniverseMode(): boolean {
+ return multiUniverseMode;
+}
+
+export function getUniverseKeys(): string[] {
+ if (!multiUniverseMode) {
+ return [];
+ }
+ return Object.keys(getConfig().ROBLOX_UNIVERSES).sort();
+}
+
+export function setActiveUniverse(key: string | null): void {
+ if (!currentConfig) {
+ throw new Error(
+ "Configuration has not been loaded. Call setConfig(loadSyncConfig()) first.",
+ );
+ }
+
+ if (!multiUniverseMode) {
+ if (key !== null) {
+ throw new Error("Cannot set universe key in single-universe mode");
+ }
+ activeUniverseKey = null;
+ return;
+ }
+
+ if (key === null) {
+ throw new Error("Universe key is required in multi-universe mode");
+ }
+
+ assertValidUniverseKey(key);
+ if (!(key in currentConfig.ROBLOX_UNIVERSES)) {
+ throw new Error(`Unknown universe key: ${key}`);
+ }
+
+ activeUniverseKey = key;
+ currentConfig.ROBLOX_UNIVERSE_ID = currentConfig.ROBLOX_UNIVERSES[key]!;
+}
+
+export function resolveSyncTargets(cliTarget?: string): SyncTarget[] {
+ const config = getConfig();
+
+ if (!multiUniverseMode) {
+ return [{ key: null, universeId: config.ROBLOX_UNIVERSE_ID }];
+ }
+
+ if (cliTarget !== undefined) {
+ assertValidUniverseKey(cliTarget);
+ const universeId = config.ROBLOX_UNIVERSES[cliTarget];
+ if (universeId === undefined) {
+ throw new Error(`Unknown universe key: ${cliTarget}`);
+ }
+ return [{ key: cliTarget, universeId }];
+ }
+
+ return getUniverseKeys().map((key) => ({
+ key,
+ universeId: config.ROBLOX_UNIVERSES[key]!,
+ }));
+}
+
export function loadSyncConfig(options?: {
env?: NodeJS.ProcessEnv;
tomlPath?: string;
@@ -138,15 +338,37 @@ export function loadSyncConfig(options?: {
const secrets = secretsResult.data!;
const toml = tomlResult.data!;
+ validateAssetCreatorWhenAssetDbConfigured(
+ toml.notion.asset_db_id,
+ toml.roblox.asset_creator,
+ errors,
+ );
+
+ if (errors.length > 0) {
+ throw new Error(`Invalid configuration:\n${errors.join("\n")}`);
+ }
+
+ multiUniverseMode = toml.roblox.universes !== undefined;
+ activeUniverseKey = multiUniverseMode
+ ? Object.keys(toml.roblox.universes!).sort()[0]!
+ : null;
+
+ const robloxUniverses = buildRobloxUniverses(toml.roblox);
+ const robloxUniverseId = resolveInitialUniverseId(toml.roblox);
+
const config: Config = {
NOTION_TOKEN: secrets.NOTION_TOKEN,
ROBLOX_API_KEY: secrets.ROBLOX_API_KEY,
NOTION_DEVPRODUCT_DB_ID: toml.notion.dev_product_db_id,
NOTION_GAMEPASS_DB_ID: toml.notion.game_pass_db_id,
NOTION_BADGE_DB_ID: toml.notion.badge_db_id,
+ NOTION_ASSET_DB_ID: toml.notion.asset_db_id,
NOTION_PARENT_PAGE_ID: trimOptional(toml.notion.parent_page_id),
- ROBLOX_UNIVERSE_ID: toml.roblox.universe_id,
+ NOTION_IS_INLINE: toml.notion.is_inline,
+ ROBLOX_UNIVERSE_ID: robloxUniverseId,
+ ROBLOX_UNIVERSES: robloxUniverses,
ROBLOX_BADGE_PAYMENT_SOURCE: toml.roblox.badge_payment_source,
+ ROBLOX_ASSET_CREATOR: toml.roblox.asset_creator,
LOG_LEVEL: toml.logging.level,
};
@@ -186,10 +408,17 @@ export function loadCreateDatabasesConfig(options?: {
trimOptional(toml.notion.parent_page_id);
if (!parentPageId) {
throw new Error(
- "Invalid configuration:\nnotion.parent_page_id: parent page ID is required for create-databases (set in ntn-roblox.toml or pass --parent-page-id)",
+ "Invalid configuration:\nnotion.parent_page_id: parent page ID is required for create-db (set in ntn-roblox.toml or pass --parent-page-id)",
);
}
+ multiUniverseMode = toml.roblox?.universes !== undefined;
+ activeUniverseKey = null;
+
+ const robloxUniverses = toml.roblox ? buildRobloxUniverses(toml.roblox) : {};
+ const robloxUniverseId =
+ toml.roblox !== undefined ? resolveInitialUniverseId(toml.roblox) : 0;
+
const config: Config = {
NOTION_TOKEN: secrets.NOTION_TOKEN,
ROBLOX_API_KEY: "",
@@ -198,9 +427,13 @@ export function loadCreateDatabasesConfig(options?: {
),
NOTION_GAMEPASS_DB_ID: normalizeOptionalDbId(toml.notion.game_pass_db_id),
NOTION_BADGE_DB_ID: normalizeOptionalDbId(toml.notion.badge_db_id),
+ NOTION_ASSET_DB_ID: normalizeOptionalDbId(toml.notion.asset_db_id),
NOTION_PARENT_PAGE_ID: parentPageId,
- ROBLOX_UNIVERSE_ID: toml.roblox?.universe_id ?? 0,
+ NOTION_IS_INLINE: toml.notion.is_inline,
+ ROBLOX_UNIVERSE_ID: robloxUniverseId,
+ ROBLOX_UNIVERSES: robloxUniverses,
ROBLOX_BADGE_PAYMENT_SOURCE: toml.roblox?.badge_payment_source ?? "user",
+ ROBLOX_ASSET_CREATOR: toml.roblox?.asset_creator,
LOG_LEVEL: toml.logging.level,
};
diff --git a/src/createDatabases/createDatabases.ts b/src/createDatabases/createDatabases.ts
index cea9c71..4b6735f 100644
--- a/src/createDatabases/createDatabases.ts
+++ b/src/createDatabases/createDatabases.ts
@@ -5,6 +5,7 @@ import type {
} from "@notionhq/client/build/src/api-endpoints.js";
import { notionRateLimiter } from "../util/rateLimit.js";
import {
+ assetDatabaseProperties,
badgeDatabaseProperties,
DATABASE_TITLES,
developerProductDatabaseProperties,
@@ -15,6 +16,7 @@ export interface CreatedDatabaseIds {
devProductDbId: string;
gamePassDbId: string;
badgeDbId: string;
+ assetDbId: string;
}
export type PartialCreatedDatabaseIds = Partial;
@@ -50,6 +52,11 @@ const DATABASE_SPECS = [
properties: badgeDatabaseProperties,
idKey: "badgeDbId" as const,
},
+ {
+ title: DATABASE_TITLES.asset,
+ properties: assetDatabaseProperties,
+ idKey: "assetDbId" as const,
+ },
] as const;
export async function createAllDatabases(
diff --git a/src/createDatabases/databaseSchemas.ts b/src/createDatabases/databaseSchemas.ts
index 06ae608..f958207 100644
--- a/src/createDatabases/databaseSchemas.ts
+++ b/src/createDatabases/databaseSchemas.ts
@@ -1,11 +1,14 @@
import type { CreateDatabaseParameters } from "@notionhq/client/build/src/api-endpoints.js";
+import { SUPPORTED_ASSET_TYPES } from "../assetTypes.js";
import { NOTION_PROPERTY_NAMES } from "../notion/mapRow.js";
+import { ASSET_PROPERTY_NAMES } from "../notion/propertyNames.js";
import { SYNC_STATUSES } from "../types.js";
export const DATABASE_TITLES = {
developerProduct: "Developer Products",
gamePass: "Game Passes",
badge: "Badges",
+ asset: "Assets",
} as const;
function buildSharedProperties(): CreateDatabaseParameters["properties"] {
@@ -46,3 +49,24 @@ export function badgeDatabaseProperties(): CreateDatabaseParameters["properties"
[NOTION_PROPERTY_NAMES.isActive]: { checkbox: {} },
};
}
+
+export function assetDatabaseProperties(): CreateDatabaseParameters["properties"] {
+ return {
+ [NOTION_PROPERTY_NAMES.name]: { title: {} },
+ [NOTION_PROPERTY_NAMES.description]: { rich_text: {} },
+ [ASSET_PROPERTY_NAMES.file]: { files: {} },
+ [ASSET_PROPERTY_NAMES.assetType]: {
+ select: {
+ options: SUPPORTED_ASSET_TYPES.map((name) => ({ name })),
+ },
+ },
+ [NOTION_PROPERTY_NAMES.robloxId]: { number: {} },
+ [NOTION_PROPERTY_NAMES.syncStatus]: {
+ select: {
+ options: SYNC_STATUSES.map((name) => ({ name })),
+ },
+ },
+ [NOTION_PROPERTY_NAMES.syncError]: { rich_text: {} },
+ [NOTION_PROPERTY_NAMES.lastSyncedAt]: { date: {} },
+ };
+}
diff --git a/src/createDatabases/runCreateDatabases.ts b/src/createDatabases/runCreateDatabases.ts
index f546f94..89c5bb6 100644
--- a/src/createDatabases/runCreateDatabases.ts
+++ b/src/createDatabases/runCreateDatabases.ts
@@ -36,12 +36,17 @@ const CONFIGURED_DB_FIELDS = [
valueKey: "NOTION_BADGE_DB_ID" as const,
tomlKey: "badge_db_id",
},
+ {
+ valueKey: "NOTION_ASSET_DB_ID" as const,
+ tomlKey: "asset_db_id",
+ },
] as const;
const CREATED_DB_LINES = [
{ label: DATABASE_TITLES.developerProduct, idKey: "devProductDbId" as const },
{ label: DATABASE_TITLES.gamePass, idKey: "gamePassDbId" as const },
{ label: DATABASE_TITLES.badge, idKey: "badgeDbId" as const },
+ { label: DATABASE_TITLES.asset, idKey: "assetDbId" as const },
] as const;
function assertNoConfiguredDatabaseIds(config: CreateDatabasesConfig): void {
diff --git a/src/init/templates.ts b/src/init/templates.ts
index a7f4aad..7623cbb 100644
--- a/src/init/templates.ts
+++ b/src/init/templates.ts
@@ -13,17 +13,25 @@ ROBLOX_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
export const TOML_TEMPLATE = `# Generated by \`ntn-roblox init\`. Safe to commit — secrets belong in .env only.
[notion]
-# Parent page where \`ntn-roblox create-databases\` creates the three databases
+# Parent page where \`ntn-roblox create-db\` creates the databases
# (32-char hex ID from the page URL). Share this page with your integration first.
parent_page_id = ""
-# Filled by \`ntn-roblox create-databases\`, or set manually if you create databases yourself.
+# Filled by \`ntn-roblox create-db\`, or set manually if you create databases yourself.
dev_product_db_id = ""
game_pass_db_id = ""
badge_db_id = ""
+asset_db_id = ""
+# is_inline = true
[roblox]
+# Use universe_id for a single universe, OR universes for multi-universe sync (not both).
universe_id = 1234567890
+# universes = { main = 1234567890, staging = 9876543210 }
# badge_payment_source = "user"
+# Required when asset_db_id is configured (for asset upload ownership):
+# [roblox.asset_creator]
+# is_group = false
+# id = 12345678
[logging]
# level = "info"
diff --git a/src/notion/client.ts b/src/notion/client.ts
index 7ffdaa0..7bfd1eb 100644
--- a/src/notion/client.ts
+++ b/src/notion/client.ts
@@ -24,6 +24,8 @@ export function databaseIdForType(type: ResourceType): string {
return getConfig().NOTION_GAMEPASS_DB_ID;
case "badge":
return getConfig().NOTION_BADGE_DB_ID;
+ case "asset":
+ return getConfig().NOTION_ASSET_DB_ID;
}
}
diff --git a/src/notion/mapRow.ts b/src/notion/mapRow.ts
index 97a46fe..0aa6492 100644
--- a/src/notion/mapRow.ts
+++ b/src/notion/mapRow.ts
@@ -1,5 +1,8 @@
import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints.js";
+import { isSupportedAssetType } from "../assetTypes.js";
+import { getUniverseKeys, isMultiUniverseMode } from "../config.js";
import type {
+ AssetRow,
BadgeRow,
DeveloperProductRow,
GamePassRow,
@@ -8,6 +11,7 @@ import type {
SyncStatus,
} from "../types.js";
import { SYNC_STATUSES } from "../types.js";
+import { ASSET_PROPERTY_NAMES, robloxIdPropertyName } from "./propertyNames.js";
const PROPERTY = {
name: "Name",
@@ -25,7 +29,7 @@ const PROPERTY = {
export function mapDeveloperProductPage(
page: PageObjectResponse,
): DeveloperProductRow | RowMappingError {
- const base = mapBasePage(page, "developer-product");
+ const base = mapMonetizationBasePage(page);
if ("message" in base) {
return base;
}
@@ -41,7 +45,7 @@ export function mapDeveloperProductPage(
export function mapGamePassPage(
page: PageObjectResponse,
): GamePassRow | RowMappingError {
- const base = mapBasePage(page, "game-pass");
+ const base = mapMonetizationBasePage(page);
if ("message" in base) {
return base;
}
@@ -57,7 +61,7 @@ export function mapGamePassPage(
export function mapBadgePage(
page: PageObjectResponse,
): BadgeRow | RowMappingError {
- const base = mapBasePage(page, "badge");
+ const base = mapMonetizationBasePage(page);
if ("message" in base) {
return base;
}
@@ -69,9 +73,61 @@ export function mapBadgePage(
};
}
-function mapBasePage(
+export function mapAssetPage(
+ page: PageObjectResponse,
+): AssetRow | RowMappingError {
+ const pageId = page.id;
+
+ try {
+ const name = readTitle(page, PROPERTY.name);
+ if (!name) {
+ return { pageId, message: `Missing required property: ${PROPERTY.name}` };
+ }
+
+ const syncStatus = readSyncStatus(page, PROPERTY.syncStatus);
+ if (!syncStatus) {
+ return {
+ pageId,
+ message: `Missing or invalid ${PROPERTY.syncStatus}. Expected one of: ${SYNC_STATUSES.join(", ")}`,
+ };
+ }
+
+ const assetTypeRaw = readSelect(page, ASSET_PROPERTY_NAMES.assetType);
+ if (!assetTypeRaw) {
+ return {
+ pageId,
+ message: `Missing required property: ${ASSET_PROPERTY_NAMES.assetType}`,
+ };
+ }
+ if (!isSupportedAssetType(assetTypeRaw)) {
+ return {
+ pageId,
+ message: `Invalid ${ASSET_PROPERTY_NAMES.assetType}: ${assetTypeRaw}. Expected one of: Animation, Audio, Decal, Image, Model`,
+ };
+ }
+
+ return {
+ pageId,
+ name,
+ description: readRichText(page, PROPERTY.description),
+ iconUrl: null,
+ robloxId: readOptionalNumber(page, PROPERTY.robloxId),
+ syncStatus,
+ syncError: readRichText(page, PROPERTY.syncError),
+ lastSyncedAt: readOptionalDate(page, PROPERTY.lastSyncedAt),
+ type: "asset",
+ assetType: assetTypeRaw,
+ fileUrl: readFirstFileUrl(page, ASSET_PROPERTY_NAMES.file),
+ };
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unknown mapping error";
+ return { pageId, message };
+ }
+}
+
+function mapMonetizationBasePage(
page: PageObjectResponse,
- _type: NotionRow["type"],
): (Omit & { type?: never }) | RowMappingError {
const pageId = page.id;
@@ -89,12 +145,15 @@ function mapBasePage(
};
}
+ const robloxIds = readRobloxIds(page);
+
return {
pageId,
name,
description: readRichText(page, PROPERTY.description),
iconUrl: readFirstFileUrl(page, PROPERTY.icon),
- robloxId: readOptionalNumber(page, PROPERTY.robloxId),
+ robloxId: robloxIds.robloxId,
+ ...(robloxIds.robloxIds !== undefined ? { robloxIds: robloxIds.robloxIds } : {}),
syncStatus,
syncError: readRichText(page, PROPERTY.syncError),
lastSyncedAt: readOptionalDate(page, PROPERTY.lastSyncedAt),
@@ -106,6 +165,26 @@ function mapBasePage(
}
}
+function readRobloxIds(page: PageObjectResponse): {
+ robloxId: number | null;
+ robloxIds?: Record;
+} {
+ if (isMultiUniverseMode()) {
+ const robloxIds: Record = {};
+ for (const key of getUniverseKeys()) {
+ robloxIds[key] = readOptionalNumber(
+ page,
+ robloxIdPropertyName(key),
+ );
+ }
+ return { robloxId: null, robloxIds };
+ }
+
+ return {
+ robloxId: readOptionalNumber(page, robloxIdPropertyName(null)),
+ };
+}
+
function readTitle(page: PageObjectResponse, propertyName: string): string {
const property = page.properties[propertyName];
if (!property || property.type !== "title") {
@@ -128,6 +207,14 @@ function readRichText(page: PageObjectResponse, propertyName: string): string {
return property.rich_text.map((item) => item.plain_text).join("");
}
+function readSelect(page: PageObjectResponse, propertyName: string): string | null {
+ const property = page.properties[propertyName];
+ if (!property || property.type !== "select") {
+ return null;
+ }
+ return property.select?.name ?? null;
+}
+
function readOptionalNumber(
page: PageObjectResponse,
propertyName: string,
diff --git a/src/notion/propertyNames.ts b/src/notion/propertyNames.ts
new file mode 100644
index 0000000..0001f2c
--- /dev/null
+++ b/src/notion/propertyNames.ts
@@ -0,0 +1,21 @@
+export const UNIVERSE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
+
+export function assertValidUniverseKey(key: string): void {
+ if (!UNIVERSE_KEY_PATTERN.test(key)) {
+ throw new Error(
+ `Invalid universe key "${key}": must match ${UNIVERSE_KEY_PATTERN}`,
+ );
+ }
+}
+
+export function robloxIdPropertyName(targetKey: string | null): string {
+ if (targetKey === null) {
+ return "Roblox ID";
+ }
+ return `Roblox ID (${targetKey})`;
+}
+
+export const ASSET_PROPERTY_NAMES = {
+ assetType: "Asset Type",
+ file: "File",
+} as const;
diff --git a/src/notion/schema.ts b/src/notion/schema.ts
index ce081ed..4065297 100644
--- a/src/notion/schema.ts
+++ b/src/notion/schema.ts
@@ -12,6 +12,7 @@ const TYPE_LABELS: Record = {
"developer-product": "Developer Product",
"game-pass": "Game Pass",
badge: "Badge",
+ asset: "Asset",
};
export async function ensureSyncStatusSchemas(
diff --git a/src/notion/writeback.ts b/src/notion/writeback.ts
index 03f1496..5582fed 100644
--- a/src/notion/writeback.ts
+++ b/src/notion/writeback.ts
@@ -1,12 +1,78 @@
import type { UpdatePageParameters } from "@notionhq/client/build/src/api-endpoints.js";
import type { SyncStatus, WritebackPayload } from "../types.js";
import { notionRateLimiter } from "../util/rateLimit.js";
+import { robloxIdPropertyName } from "./propertyNames.js";
import { getNotionClient } from "./client.js";
import { NOTION_PROPERTY_NAMES, richTextFromString } from "./mapRow.js";
import { getSyncStatusPropertyType } from "./schema.js";
const MAX_SYNC_ERROR_LENGTH = 1900;
+export interface TargetResult {
+ targetKey: string | null;
+ outcome: "created" | "updated" | "skipped" | "error";
+ robloxId?: number;
+ message?: string;
+}
+
+export interface AggregatedWriteback {
+ syncStatus: SyncStatus;
+ syncError: string;
+ robloxIds: Record;
+ lastSyncedAt?: string;
+}
+
+export function aggregateTargetResults(
+ results: TargetResult[],
+): AggregatedWriteback {
+ const errors: string[] = [];
+ const skips: string[] = [];
+ const robloxIds: Record = {};
+ let hasSuccess = false;
+
+ for (const result of results) {
+ const prefix =
+ result.targetKey !== null ? `[${result.targetKey}] ` : "";
+
+ if (result.robloxId !== undefined && result.targetKey !== null) {
+ robloxIds[result.targetKey] = result.robloxId;
+ } else if (result.robloxId !== undefined && result.targetKey === null) {
+ robloxIds[""] = result.robloxId;
+ }
+
+ if (result.outcome === "created" || result.outcome === "updated") {
+ hasSuccess = true;
+ } else if (result.outcome === "error" && result.message) {
+ errors.push(`${prefix}${result.message}`);
+ } else if (result.outcome === "skipped" && result.message) {
+ skips.push(`${prefix}${result.message}`);
+ }
+ }
+
+ if (errors.length > 0) {
+ return {
+ syncStatus: "Error",
+ syncError: errors.join("\n"),
+ robloxIds,
+ };
+ }
+
+ if (!hasSuccess && skips.length > 0) {
+ return {
+ syncStatus: "Skipped",
+ syncError: skips.join("\n"),
+ robloxIds,
+ };
+ }
+
+ return {
+ syncStatus: "Synced",
+ syncError: "",
+ robloxIds,
+ lastSyncedAt: new Date().toISOString(),
+ };
+}
+
export async function writebackPage(
pageId: string,
databaseId: string,
@@ -28,7 +94,8 @@ export async function writebackPage(
}
if (payload.robloxId !== undefined) {
- properties[NOTION_PROPERTY_NAMES.robloxId] = {
+ const targetKey = payload.robloxIdTargetKey ?? null;
+ properties[robloxIdPropertyName(targetKey)] = {
number: payload.robloxId,
};
}
@@ -52,13 +119,14 @@ export async function writebackRobloxId(
pageId: string,
_databaseId: string,
robloxId: number,
+ targetKey: string | null = null,
): Promise {
const client = getNotionClient();
await notionRateLimiter.schedule(() =>
client.pages.update({
page_id: pageId,
properties: {
- [NOTION_PROPERTY_NAMES.robloxId]: {
+ [robloxIdPropertyName(targetKey)]: {
number: robloxId,
},
},
@@ -66,15 +134,54 @@ export async function writebackRobloxId(
);
}
+export async function writebackAggregatedTargetResults(
+ pageId: string,
+ databaseId: string,
+ aggregated: AggregatedWriteback,
+): Promise {
+ const properties: NonNullable = {
+ [NOTION_PROPERTY_NAMES.syncStatus]: buildSyncStatusProperty(
+ databaseId,
+ aggregated.syncStatus,
+ ),
+ [NOTION_PROPERTY_NAMES.syncError]: {
+ rich_text: richTextFromString(
+ truncate(aggregated.syncError, MAX_SYNC_ERROR_LENGTH),
+ ),
+ },
+ };
+
+ for (const [key, robloxId] of Object.entries(aggregated.robloxIds)) {
+ const targetKey = key === "" ? null : key;
+ properties[robloxIdPropertyName(targetKey)] = { number: robloxId };
+ }
+
+ if (aggregated.lastSyncedAt !== undefined) {
+ properties[NOTION_PROPERTY_NAMES.lastSyncedAt] = {
+ date: { start: aggregated.lastSyncedAt },
+ };
+ }
+
+ const client = getNotionClient();
+ await notionRateLimiter.schedule(() =>
+ client.pages.update({
+ page_id: pageId,
+ properties,
+ }),
+ );
+}
+
export async function writebackSuccess(
pageId: string,
databaseId: string,
robloxId: number,
+ targetKey: string | null = null,
): Promise {
await writebackPage(pageId, databaseId, {
syncStatus: "Synced",
syncError: "",
robloxId,
+ robloxIdTargetKey: targetKey,
lastSyncedAt: new Date().toISOString(),
});
}
@@ -84,11 +191,14 @@ export async function writebackError(
databaseId: string,
message: string,
robloxId?: number,
+ targetKey: string | null = null,
): Promise {
await writebackPage(pageId, databaseId, {
syncStatus: "Error",
syncError: message,
- ...(robloxId !== undefined ? { robloxId } : {}),
+ ...(robloxId !== undefined
+ ? { robloxId, robloxIdTargetKey: targetKey }
+ : {}),
});
}
diff --git a/src/roblox/assets.ts b/src/roblox/assets.ts
new file mode 100644
index 0000000..266f273
--- /dev/null
+++ b/src/roblox/assets.ts
@@ -0,0 +1,190 @@
+import { getConfig, type RobloxAssetCreator } from "../config.js";
+import type { FileUpload } from "../types.js";
+import { robloxJson, robloxMultipart } from "./http.js";
+
+const CREATE_PATH = "/assets/v1/assets";
+const POLL_TIMEOUT_MS = 120_000;
+const POLL_INTERVAL_MS = 2_000;
+
+export interface CreateAssetInput {
+ assetType: string;
+ displayName: string;
+ description: string;
+ file: FileUpload;
+}
+
+export interface UpdateAssetMetadataInput {
+ displayName: string;
+ description: string;
+}
+
+interface AssetOperation {
+ path: string;
+ done?: boolean;
+ error?: { code?: number; message?: string };
+ response?: {
+ path?: string;
+ assetId?: string | number;
+ };
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function getAssetCreator(): RobloxAssetCreator {
+ const creator = getConfig().ROBLOX_ASSET_CREATOR;
+ if (!creator) {
+ throw new Error(
+ "roblox.asset_creator is required in ntn-roblox.toml (is_group, id)",
+ );
+ }
+ return creator;
+}
+
+function buildCreationContext() {
+ const creator = getAssetCreator();
+ return {
+ creator: creator.is_group
+ ? { groupId: String(creator.id) }
+ : { userId: String(creator.id) },
+ expectedPrice: 0,
+ };
+}
+
+export function mimeTypeFromFilename(filename: string): string {
+ const lower = filename.toLowerCase();
+ if (lower.endsWith(".png")) {
+ return "image/png";
+ }
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
+ return "image/jpeg";
+ }
+ if (lower.endsWith(".bmp")) {
+ return "image/bmp";
+ }
+ if (lower.endsWith(".tga")) {
+ return "image/tga";
+ }
+ if (lower.endsWith(".mp3")) {
+ return "audio/mpeg";
+ }
+ if (lower.endsWith(".ogg")) {
+ return "audio/ogg";
+ }
+ if (lower.endsWith(".wav")) {
+ return "audio/wav";
+ }
+ if (lower.endsWith(".flac")) {
+ return "audio/flac";
+ }
+ if (lower.endsWith(".fbx")) {
+ return "model/fbx";
+ }
+ if (lower.endsWith(".gltf")) {
+ return "model/gltf+json";
+ }
+ if (lower.endsWith(".glb")) {
+ return "model/gltf-binary";
+ }
+ if (lower.endsWith(".rbxm") || lower.endsWith(".rbxmx")) {
+ return "model/x-rbxm";
+ }
+ return "application/octet-stream";
+}
+
+function parseAssetIdFromPath(path: string): number | null {
+ const match = path.match(/^assets\/(\d+)$/);
+ if (!match) {
+ return null;
+ }
+ return Number(match[1]);
+}
+
+function parseAssetIdFromOperation(operation: AssetOperation): number {
+ const responsePath = operation.response?.path;
+ if (responsePath) {
+ const assetId = parseAssetIdFromPath(responsePath);
+ if (assetId !== null) {
+ return assetId;
+ }
+ }
+
+ if (operation.response?.assetId !== undefined) {
+ return Number(operation.response.assetId);
+ }
+
+ throw new Error("Asset operation completed without asset ID");
+}
+
+async function pollAssetOperation(operationPath: string): Promise {
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
+
+ while (Date.now() < deadline) {
+ const operation = await robloxJson({
+ path: `/assets/v1/${operationPath}`,
+ });
+
+ if (operation.error?.message) {
+ throw new Error(`Asset operation failed: ${operation.error.message}`);
+ }
+
+ if (operation.done) {
+ return parseAssetIdFromOperation(operation);
+ }
+
+ await sleep(POLL_INTERVAL_MS);
+ }
+
+ throw new Error(
+ `Asset operation timed out after ${POLL_TIMEOUT_MS / 1000}s (${operationPath})`,
+ );
+}
+
+export async function createAsset(input: CreateAssetInput): Promise {
+ const request = {
+ assetType: input.assetType,
+ displayName: input.displayName,
+ description: input.description,
+ creationContext: buildCreationContext(),
+ };
+
+ const operation = await robloxMultipart({
+ method: "POST",
+ path: CREATE_PATH,
+ fields: [{ name: "request", value: JSON.stringify(request) }],
+ file: {
+ ...input.file,
+ mimeType:
+ input.file.mimeType || mimeTypeFromFilename(input.file.filename),
+ fieldName: "fileContent",
+ },
+ });
+
+ if (operation.done) {
+ return parseAssetIdFromOperation(operation);
+ }
+
+ if (!operation.path) {
+ throw new Error("Asset create response missing operation path");
+ }
+
+ return pollAssetOperation(operation.path);
+}
+
+export async function updateAssetMetadata(
+ assetId: number,
+ input: UpdateAssetMetadataInput,
+): Promise {
+ const request = {
+ assetId,
+ displayName: input.displayName,
+ description: input.description,
+ };
+
+ await robloxMultipart({
+ method: "PATCH",
+ path: `/assets/v1/assets/${assetId}?updateMask=description,displayName`,
+ fields: [{ name: "request", value: JSON.stringify(request) }],
+ });
+}
diff --git a/src/sync/candidates.ts b/src/sync/candidates.ts
index a700c26..3d2d2fb 100644
--- a/src/sync/candidates.ts
+++ b/src/sync/candidates.ts
@@ -1,4 +1,4 @@
-import type { NotionRow, SyncStatus } from "../types.js";
+import type { NotionRow } from "../types.js";
export type SyncAction = "create" | "update" | "skip";
@@ -8,42 +8,38 @@ export interface SyncCandidate {
reason?: string;
}
-const UPDATE_STATUSES: SyncStatus[] = ["Pending", "Error", "Skipped"];
+export type ClassifyMode = "sync" | "update";
export interface ClassifyOptions {
- force?: boolean;
+ mode: ClassifyMode;
+ robloxIdForTarget?: number | null;
}
export function classifyRow(
row: NotionRow,
- options: ClassifyOptions = {},
+ options: ClassifyOptions,
): SyncCandidate {
- const force = options.force ?? false;
+ const robloxId =
+ options.robloxIdForTarget !== undefined
+ ? options.robloxIdForTarget
+ : row.robloxId;
- if (row.syncStatus === "Synced") {
- if (!force) {
- return { row, action: "skip", reason: "Sync Status is Synced" };
- }
- if (row.robloxId === null) {
+ if (options.mode === "sync") {
+ if (robloxId === null) {
return { row, action: "create" };
}
- return { row, action: "update" };
- }
-
- if (row.robloxId === null) {
- return { row, action: "create" };
+ return { row, action: "skip", reason: "Roblox ID already set" };
}
- if (UPDATE_STATUSES.includes(row.syncStatus)) {
- return { row, action: "update" };
+ if (robloxId === null) {
+ return { row, action: "skip", reason: "Roblox ID is empty" };
}
-
- return { row, action: "skip", reason: `Unhandled Sync Status: ${row.syncStatus}` };
+ return { row, action: "update" };
}
export function classifyRows(
rows: NotionRow[],
- options: ClassifyOptions = {},
+ options: ClassifyOptions,
): SyncCandidate[] {
return rows.map((row) => classifyRow(row, options));
}
diff --git a/src/sync/engine.ts b/src/sync/engine.ts
index fde5a05..3a0f3fb 100644
--- a/src/sync/engine.ts
+++ b/src/sync/engine.ts
@@ -1,17 +1,27 @@
+import {
+ isMultiUniverseMode,
+ resolveSyncTargets,
+ setActiveUniverse,
+} from "../config.js";
import { databaseIdForType, queryAllPagesForType } from "../notion/client.js";
import {
isMappingError,
+ mapAssetPage,
mapBadgePage,
mapDeveloperProductPage,
mapGamePassPage,
} from "../notion/mapRow.js";
import {
+ aggregateTargetResults,
+ writebackAggregatedTargetResults,
writebackError,
writebackRobloxId,
writebackSkipped,
writebackSuccess,
+ type TargetResult,
} from "../notion/writeback.js";
import { ensureSyncStatusSchemas } from "../notion/schema.js";
+import { createAsset, updateAssetMetadata } from "../roblox/assets.js";
import { RobloxHttpError } from "../roblox/http.js";
import {
BadgeQuotaExhaustedError,
@@ -34,16 +44,20 @@ import {
updateGamePass,
} from "../roblox/gamePasses.js";
import type {
+ AssetRow,
FileUpload,
NotionRow,
ResourceType,
RowMappingError,
} from "../types.js";
-import { downloadFile } from "../util/download.js";
+import {
+ downloadFile,
+ MAX_ASSET_DOWNLOAD_BYTES,
+} from "../util/download.js";
import { logger } from "../util/logger.js";
import { sanitizeErrorMessage } from "../util/sanitizeError.js";
import {
- classifyRows,
+ classifyRow,
filterActionable,
type SyncCandidate,
} from "./candidates.js";
@@ -55,12 +69,14 @@ import {
type OrphanItem,
type OrphanReportSection,
} from "./orphanReport.js";
+import { getRobloxIdForTarget } from "./robloxIds.js";
export interface SyncOptions {
+ mode: "sync" | "update";
dryRun: boolean;
reportOnly: boolean;
- force: boolean;
typeFilter?: ResourceType;
+ targetFilter?: string;
}
export interface SyncResult {
@@ -72,25 +88,39 @@ export interface SyncResult {
orphans: number;
}
-const ALL_TYPES: ResourceType[] = ["developer-product", "game-pass", "badge"];
+const ALL_TYPES: ResourceType[] = [
+ "developer-product",
+ "game-pass",
+ "badge",
+ "asset",
+];
+
+const MONETIZATION_TYPES: ResourceType[] = [
+ "developer-product",
+ "game-pass",
+ "badge",
+];
const TYPE_LABELS: Record = {
"developer-product": "Developer Product",
"game-pass": "Game Pass",
badge: "Badge",
+ asset: "Asset",
};
interface TypeSyncContext {
type: ResourceType;
rows: NotionRow[];
mappingErrors: RowMappingError[];
- candidates: SyncCandidate[];
}
export async function runSync(options: SyncOptions): Promise {
const types = resolveTypes(options.typeFilter);
await ensureSyncStatusSchemas(types);
+ const targets = resolveSyncTargets(options.targetFilter);
+ const skipWriteback = options.dryRun || options.reportOnly;
+
const result: SyncResult = {
created: 0,
updated: 0,
@@ -101,8 +131,8 @@ export async function runSync(options: SyncOptions): Promise {
};
const orphanSections: OrphanReportSection[] = [];
- const typeContexts: TypeSyncContext[] = [];
- const skipWriteback = options.dryRun || options.reportOnly;
+ const monetizationContexts: TypeSyncContext[] = [];
+ let assetContext: TypeSyncContext | undefined;
for (const type of types) {
logger.info(`Loading ${TYPE_LABELS[type]}...`);
@@ -115,19 +145,23 @@ export async function runSync(options: SyncOptions): Promise {
result.errors += 1;
}
- const robloxItems = await listRobloxItemsForOrphans(type);
- const notionIds = collectNotionRobloxIds(rows);
- orphanSections.push({
- type,
- orphans: findOrphans(robloxItems, notionIds),
- });
-
- typeContexts.push({
- type,
- rows,
- mappingErrors,
- candidates: filterActionable(classifyRows(rows, { force: options.force })),
- });
+ if (type === "asset") {
+ assetContext = { type, rows, mappingErrors };
+ continue;
+ }
+
+ for (const target of targets) {
+ setActiveUniverse(target.key);
+
+ const robloxItems = await listRobloxItemsForOrphans(type);
+ const notionIds = collectNotionRobloxIds(rows, target.key);
+ orphanSections.push({
+ type,
+ orphans: findOrphans(robloxItems, notionIds),
+ });
+ }
+
+ monetizationContexts.push({ type, rows, mappingErrors });
}
result.orphans = countOrphans(orphanSections);
@@ -140,42 +174,113 @@ export async function runSync(options: SyncOptions): Promise {
let badgeQuota: number | undefined;
- for (const context of typeContexts) {
- const { type, candidates } = context;
+ for (const context of monetizationContexts) {
+ const { type, rows } = context;
logger.info(`Syncing ${TYPE_LABELS[type]}...`);
- if (candidates.length === 0) {
- logger.info(`No actionable rows for ${TYPE_LABELS[type]}`);
- continue;
- }
+ if (isMultiUniverseMode()) {
+ const rowResults = new Map();
+
+ for (const target of targets) {
+ setActiveUniverse(target.key);
+
+ const candidates = buildCandidates(rows, options.mode, target.key);
+ if (candidates.length === 0) {
+ continue;
+ }
+
+ if (type === "badge" && badgeQuota === undefined) {
+ badgeQuota = await getFreeBadgeQuota();
+ logger.info(`Badge free quota remaining: ${badgeQuota}`);
+ }
+
+ for (const candidate of candidates) {
+ const rowResult = await processCandidate(candidate, {
+ dryRun: options.dryRun,
+ badgeQuota,
+ targetKey: target.key,
+ deferWriteback: true,
+ });
+
+ applyRowResult(result, rowResult);
+
+ if (
+ type === "badge" &&
+ badgeQuota !== undefined &&
+ rowResult.consumedBadgeQuota
+ ) {
+ badgeQuota -= 1;
+ }
+
+ const targetOutcome = toTargetResult(
+ target.key,
+ rowResult,
+ candidate.reason,
+ );
+ const existing = rowResults.get(candidate.row.pageId) ?? [];
+ existing.push(targetOutcome);
+ rowResults.set(candidate.row.pageId, existing);
+ }
+ }
- if (type === "badge" && badgeQuota === undefined) {
- badgeQuota = await getFreeBadgeQuota();
- logger.info(`Badge free quota remaining: ${badgeQuota}`);
- }
+ if (!options.dryRun) {
+ for (const [pageId, targetResults] of rowResults) {
+ const aggregated = aggregateTargetResults(targetResults);
+ await writebackAggregatedTargetResults(
+ pageId,
+ databaseIdForType(type),
+ aggregated,
+ );
+ }
+ }
+ } else {
+ const target = targets[0]!;
+ setActiveUniverse(target.key);
- for (const candidate of candidates) {
- const rowResult = await processCandidate(candidate, {
- dryRun: options.dryRun,
- badgeQuota,
- });
+ const candidates = buildCandidates(rows, options.mode, target.key);
+ if (candidates.length === 0) {
+ logger.info(`No actionable rows for ${TYPE_LABELS[type]}`);
+ continue;
+ }
- if (rowResult.outcome === "created") {
- result.created += 1;
- } else if (rowResult.outcome === "updated") {
- result.updated += 1;
- } else if (rowResult.outcome === "skipped") {
- result.skipped += 1;
- } else {
- result.errors += 1;
+ if (type === "badge" && badgeQuota === undefined) {
+ badgeQuota = await getFreeBadgeQuota();
+ logger.info(`Badge free quota remaining: ${badgeQuota}`);
}
- if (
- type === "badge" &&
- badgeQuota !== undefined &&
- rowResult.consumedBadgeQuota
- ) {
- badgeQuota -= 1;
+ for (const candidate of candidates) {
+ const rowResult = await processCandidate(candidate, {
+ dryRun: options.dryRun,
+ badgeQuota,
+ targetKey: target.key,
+ });
+
+ applyRowResult(result, rowResult);
+
+ if (
+ type === "badge" &&
+ badgeQuota !== undefined &&
+ rowResult.consumedBadgeQuota
+ ) {
+ badgeQuota -= 1;
+ }
+ }
+ }
+ }
+
+ if (assetContext && types.includes("asset")) {
+ logger.info(`Syncing ${TYPE_LABELS.asset}...`);
+ const candidates = buildCandidates(assetContext.rows, options.mode, null);
+
+ if (candidates.length === 0) {
+ logger.info(`No actionable rows for ${TYPE_LABELS.asset}`);
+ } else {
+ for (const candidate of candidates) {
+ const rowResult = await processCandidate(candidate, {
+ dryRun: options.dryRun,
+ targetKey: null,
+ });
+ applyRowResult(result, rowResult);
}
}
}
@@ -184,8 +289,72 @@ export async function runSync(options: SyncOptions): Promise {
return result;
}
+function buildCandidates(
+ rows: NotionRow[],
+ mode: SyncOptions["mode"],
+ targetKey: string | null,
+): SyncCandidate[] {
+ return filterActionable(
+ rows.map((row) =>
+ classifyRow(row, {
+ mode,
+ robloxIdForTarget: getRobloxIdForTarget(row, targetKey),
+ }),
+ ),
+ );
+}
+
+function toTargetResult(
+ targetKey: string | null,
+ rowResult: ProcessCandidateResult,
+ skipReason?: string,
+): TargetResult {
+ if (rowResult.outcome === "created") {
+ return {
+ targetKey,
+ outcome: "created",
+ robloxId: rowResult.robloxId,
+ };
+ }
+ if (rowResult.outcome === "updated") {
+ return {
+ targetKey,
+ outcome: "updated",
+ robloxId: rowResult.robloxId,
+ };
+ }
+ if (rowResult.outcome === "skipped") {
+ return {
+ targetKey,
+ outcome: "skipped",
+ message: rowResult.message ?? skipReason,
+ };
+ }
+ return {
+ targetKey,
+ outcome: "error",
+ message: rowResult.message,
+ robloxId: rowResult.robloxId,
+ };
+}
+
+function applyRowResult(result: SyncResult, rowResult: ProcessCandidateResult): void {
+ if (rowResult.outcome === "created") {
+ result.created += 1;
+ } else if (rowResult.outcome === "updated") {
+ result.updated += 1;
+ } else if (rowResult.outcome === "skipped") {
+ result.skipped += 1;
+ } else {
+ result.errors += 1;
+ }
+}
+
function resolveTypes(typeFilter?: ResourceType): ResourceType[] {
- return typeFilter ? [typeFilter] : ALL_TYPES;
+ if (typeFilter) {
+ return [typeFilter];
+ }
+ return ALL_TYPES;
}
async function loadNotionRows(type: ResourceType): Promise<{
@@ -219,6 +388,8 @@ function mapPage(
return mapGamePassPage(page);
case "badge":
return mapBadgePage(page);
+ case "asset":
+ return mapAssetPage(page);
}
}
@@ -246,6 +417,8 @@ async function listRobloxItemsForOrphans(
name: item.name,
enabled: item.enabled,
}));
+ case "asset":
+ return [];
}
}
@@ -254,11 +427,15 @@ type RowOutcome = "created" | "updated" | "skipped" | "error";
export interface ProcessCandidateResult {
outcome: RowOutcome;
consumedBadgeQuota?: boolean;
+ robloxId?: number;
+ message?: string;
}
interface ProcessContext {
dryRun: boolean;
badgeQuota?: number;
+ targetKey: string | null;
+ deferWriteback?: boolean;
}
export async function processCandidate(
@@ -279,18 +456,24 @@ export async function processCandidate(
const reason = error.message;
warnQuotaExhausted(row.name);
logger.warn(`${label}: ${reason}`);
- if (!context.dryRun) {
+ if (!context.dryRun && !context.deferWriteback) {
await writebackSkipped(row.pageId, databaseId, reason);
}
- return { outcome: "skipped" };
+ return { outcome: "skipped", message: reason };
}
const message = formatError(error);
logger.error(`${label}: ${message}`);
- if (!context.dryRun) {
- await writebackError(row.pageId, databaseId, message);
+ if (!context.dryRun && !context.deferWriteback) {
+ await writebackError(
+ row.pageId,
+ databaseId,
+ message,
+ undefined,
+ context.targetKey,
+ );
}
- return { outcome: "error" };
+ return { outcome: "error", message };
}
}
@@ -300,43 +483,78 @@ async function processCreate(
context: ProcessContext,
): Promise {
const databaseId = databaseIdForType(row.type);
+
+ if (row.type === "asset" && !row.fileUrl) {
+ const reason = "File is required for asset create";
+ logger.error(`${label}: ${reason}`);
+ if (!context.dryRun && !context.deferWriteback) {
+ await writebackError(row.pageId, databaseId, reason);
+ }
+ return { outcome: "error", message: reason };
+ }
+
if (row.type === "badge") {
const quota = context.badgeQuota ?? 0;
if (quota <= 0) {
const reason = "Badge free quota exhausted";
warnQuotaExhausted(row.name);
logger.warn(`${label}: ${reason}`);
- if (!context.dryRun) {
+ if (!context.dryRun && !context.deferWriteback) {
await writebackSkipped(row.pageId, databaseId, reason);
}
- return { outcome: "skipped" };
+ return { outcome: "skipped", message: reason };
}
}
- if (row.type !== "badge" && row.price === null) {
+ if (
+ row.type !== "badge" &&
+ row.type !== "asset" &&
+ row.price === null
+ ) {
const reason = "Price is required for create";
logger.error(`${label}: ${reason}`);
- if (!context.dryRun) {
- await writebackError(row.pageId, databaseId, reason);
+ if (!context.dryRun && !context.deferWriteback) {
+ await writebackError(
+ row.pageId,
+ databaseId,
+ reason,
+ undefined,
+ context.targetKey,
+ );
}
- return { outcome: "error" };
+ return { outcome: "error", message: reason };
}
- const iconHint = row.iconUrl ? "yes" : "no";
+ const iconHint = row.type === "asset" ? "n/a" : row.iconUrl ? "yes" : "no";
if (context.dryRun) {
logger.info(`[DRY-RUN] CREATE ${label} (icon=${iconHint})`);
return { outcome: "created" };
}
- const icon = await resolveIcon(row.iconUrl);
+ const icon =
+ row.type === "asset" ? undefined : await resolveIcon(row.iconUrl);
const robloxId = await createRobloxItem(row, icon, context.badgeQuota ?? 0);
const consumedBadgeQuota = row.type === "badge";
+ if (context.deferWriteback) {
+ logger.info(`Created ${label} → Roblox ID ${robloxId}`);
+ return {
+ outcome: "created",
+ robloxId,
+ ...(consumedBadgeQuota ? { consumedBadgeQuota: true } : {}),
+ };
+ }
+
let idWritebackOk = true;
try {
- await writebackRobloxId(row.pageId, databaseId, robloxId);
+ await writebackRobloxId(
+ row.pageId,
+ databaseId,
+ robloxId,
+ context.targetKey,
+ );
} catch (error) {
idWritebackOk = false;
logger.error(
@@ -347,10 +565,16 @@ async function processCreate(
let robloxIdPersisted = idWritebackOk;
try {
- await writebackSuccess(row.pageId, databaseId, robloxId);
+ await writebackSuccess(
+ row.pageId,
+ databaseId,
+ robloxId,
+ context.targetKey,
+ );
logger.info(`Created ${label} → Roblox ID ${robloxId}`);
return {
outcome: "created",
+ robloxId,
...(consumedBadgeQuota ? { consumedBadgeQuota: true } : {}),
};
} catch (error) {
@@ -358,7 +582,13 @@ async function processCreate(
logger.error(`${label}: Sync success writeback failed: ${message}`);
try {
- await writebackError(row.pageId, databaseId, message, robloxId);
+ await writebackError(
+ row.pageId,
+ databaseId,
+ message,
+ robloxId,
+ context.targetKey,
+ );
robloxIdPersisted = true;
} catch (writebackErr) {
logger.error(
@@ -376,6 +606,8 @@ async function processCreate(
return {
outcome: "error",
+ message,
+ robloxId,
...(consumedBadgeQuota ? { consumedBadgeQuota: true } : {}),
};
}
@@ -387,30 +619,50 @@ async function processUpdate(
context: ProcessContext,
): Promise {
const databaseId = databaseIdForType(row.type);
- if (row.robloxId === null) {
+ const robloxId = getRobloxIdForTarget(row, context.targetKey);
+
+ if (robloxId === null) {
const reason = "Roblox ID is required for update";
logger.error(`${label}: ${reason}`);
- if (!context.dryRun) {
- await writebackError(row.pageId, databaseId, reason);
+ if (!context.dryRun && !context.deferWriteback) {
+ await writebackError(
+ row.pageId,
+ databaseId,
+ reason,
+ undefined,
+ context.targetKey,
+ );
}
- return { outcome: "error" };
+ return { outcome: "error", message: reason };
}
- const iconHint = row.iconUrl ? "yes" : "no";
+ const iconHint = row.type === "asset" ? "n/a" : row.iconUrl ? "yes" : "no";
if (context.dryRun) {
logger.info(
- `[DRY-RUN] UPDATE ${label} (Roblox ID ${row.robloxId}, icon=${iconHint})`,
+ `[DRY-RUN] UPDATE ${label} (Roblox ID ${robloxId}, icon=${iconHint})`,
);
- return { outcome: "updated" };
+ return { outcome: "updated", robloxId };
}
- const icon = await resolveIcon(row.iconUrl);
+ const icon =
+ row.type === "asset" ? undefined : await resolveIcon(row.iconUrl);
- await updateRobloxItem(row, icon);
- await writebackSuccess(row.pageId, databaseId, row.robloxId);
- logger.info(`Updated ${label} (Roblox ID ${row.robloxId})`);
- return { outcome: "updated" };
+ await updateRobloxItem(row, icon, robloxId);
+
+ if (context.deferWriteback) {
+ logger.info(`Updated ${label} (Roblox ID ${robloxId})`);
+ return { outcome: "updated", robloxId };
+ }
+
+ await writebackSuccess(
+ row.pageId,
+ databaseId,
+ robloxId,
+ context.targetKey,
+ );
+ logger.info(`Updated ${label} (Roblox ID ${robloxId})`);
+ return { outcome: "updated", robloxId };
}
async function createRobloxItem(
@@ -445,15 +697,30 @@ async function createRobloxItem(
},
badgeQuota,
);
+ case "asset":
+ return createAssetFromRow(row);
}
}
+async function createAssetFromRow(row: AssetRow): Promise {
+ const file = await downloadFile(
+ row.fileUrl as string,
+ undefined,
+ MAX_ASSET_DOWNLOAD_BYTES,
+ );
+ return createAsset({
+ assetType: row.assetType,
+ displayName: row.name,
+ description: row.description,
+ file,
+ });
+}
+
async function updateRobloxItem(
row: NotionRow,
icon: FileUpload | undefined,
+ robloxId: number,
): Promise {
- const robloxId = row.robloxId as number;
-
switch (row.type) {
case "developer-product":
await updateDeveloperProduct(robloxId, {
@@ -481,6 +748,12 @@ async function updateRobloxItem(
icon,
});
return;
+ case "asset":
+ await updateAssetMetadata(robloxId, {
+ displayName: row.name,
+ description: row.description,
+ });
+ return;
}
}
@@ -535,10 +808,12 @@ function logSummary(result: SyncResult, options: SyncOptions): void {
? "report-only"
: options.dryRun
? "dry-run"
- : "sync";
+ : options.mode;
logger.info(`Finished (${mode}): ${parts.join(", ")}`);
}
export function shouldExitWithError(result: SyncResult): boolean {
return result.errors > 0 || result.skipped > 0;
}
+
+export { MONETIZATION_TYPES };
diff --git a/src/sync/orphanReport.ts b/src/sync/orphanReport.ts
index b1e200f..a815948 100644
--- a/src/sync/orphanReport.ts
+++ b/src/sync/orphanReport.ts
@@ -17,13 +17,22 @@ const TYPE_LABELS: Record = {
"developer-product": "Developer Product",
"game-pass": "Game Pass",
badge: "Badge",
+ asset: "Asset",
};
export function collectNotionRobloxIds(
- rows: Array<{ robloxId: number | null }>,
+ rows: Array<{ robloxId: number | null; robloxIds?: Record }>,
+ targetKey: string | null = null,
): Set {
const ids = new Set();
for (const row of rows) {
+ if (targetKey !== null && row.robloxIds) {
+ const id = row.robloxIds[targetKey];
+ if (id !== null && id !== undefined) {
+ ids.add(id);
+ }
+ continue;
+ }
if (row.robloxId !== null) {
ids.add(row.robloxId);
}
@@ -45,15 +54,16 @@ export function countOrphans(sections: OrphanReportSection[]): number {
}
export function printOrphanReport(sections: OrphanReportSection[]): void {
- const totalOrphans = countOrphans(sections);
+ const filteredSections = sections.filter((section) => section.type !== "asset");
+ const totalOrphans = countOrphans(filteredSections);
console.log("");
console.log("Roblox items not referenced in Notion");
console.log(`Total: ${totalOrphans}`);
console.log("");
- sections.forEach((section, index) => {
- const isLast = index === sections.length - 1;
+ filteredSections.forEach((section, index) => {
+ const isLast = index === filteredSections.length - 1;
const branch = isLast ? "└──" : "├──";
const childPrefix = isLast ? " " : "│ ";
const label = TYPE_LABELS[section.type];
diff --git a/src/sync/robloxIds.ts b/src/sync/robloxIds.ts
new file mode 100644
index 0000000..c4bb38d
--- /dev/null
+++ b/src/sync/robloxIds.ts
@@ -0,0 +1,25 @@
+import type { NotionRowBase } from "../types.js";
+
+export function getRobloxIdForTarget(
+ row: NotionRowBase,
+ targetKey: string | null,
+): number | null {
+ if (targetKey === null) {
+ return row.robloxId;
+ }
+ return row.robloxIds?.[targetKey] ?? null;
+}
+
+export function collectRobloxIdsForTarget(
+ rows: NotionRowBase[],
+ targetKey: string | null,
+): Set {
+ const ids = new Set();
+ for (const row of rows) {
+ const id = getRobloxIdForTarget(row, targetKey);
+ if (id !== null) {
+ ids.add(id);
+ }
+ }
+ return ids;
+}
diff --git a/src/tomlWrite.ts b/src/tomlWrite.ts
index 3ea50ff..def5250 100644
--- a/src/tomlWrite.ts
+++ b/src/tomlWrite.ts
@@ -2,6 +2,7 @@ export type NotionDatabaseIds = {
devProductDbId: string;
gamePassDbId: string;
badgeDbId: string;
+ assetDbId: string;
};
const NOTION_DB_KEYS: Array<{
@@ -11,6 +12,7 @@ const NOTION_DB_KEYS: Array<{
{ field: "devProductDbId", tomlKey: "dev_product_db_id" },
{ field: "gamePassDbId", tomlKey: "game_pass_db_id" },
{ field: "badgeDbId", tomlKey: "badge_db_id" },
+ { field: "assetDbId", tomlKey: "asset_db_id" },
];
function escapeRegExp(value: string): string {
diff --git a/src/types.ts b/src/types.ts
index e26c647..37a9287 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,10 @@
-export type ResourceType = "developer-product" | "game-pass" | "badge";
+import type { SupportedAssetType } from "./assetTypes.js";
+
+export type ResourceType =
+ | "developer-product"
+ | "game-pass"
+ | "badge"
+ | "asset";
export const SYNC_STATUSES = ["Pending", "Synced", "Error", "Skipped"] as const;
export type SyncStatus = (typeof SYNC_STATUSES)[number];
@@ -9,6 +15,7 @@ export interface NotionRowBase {
description: string;
iconUrl: string | null;
robloxId: number | null;
+ robloxIds?: Record;
syncStatus: SyncStatus;
syncError: string;
lastSyncedAt: string | null;
@@ -31,7 +38,17 @@ export interface BadgeRow extends NotionRowBase {
isActive: boolean;
}
-export type NotionRow = DeveloperProductRow | GamePassRow | BadgeRow;
+export interface AssetRow extends NotionRowBase {
+ type: "asset";
+ assetType: SupportedAssetType;
+ fileUrl: string | null;
+}
+
+export type NotionRow =
+ | DeveloperProductRow
+ | GamePassRow
+ | BadgeRow
+ | AssetRow;
export interface RowMappingError {
pageId: string;
@@ -42,6 +59,7 @@ export interface WritebackPayload {
syncStatus: SyncStatus;
syncError?: string;
robloxId?: number;
+ robloxIdTargetKey?: string | null;
lastSyncedAt?: string;
}
@@ -129,6 +147,27 @@ export interface FileUpload {
mimeType: string;
}
+export interface RobloxAsset {
+ assetId: number;
+ name: string;
+ assetType: SupportedAssetType;
+}
+
+export interface AssetInput {
+ name: string;
+ description?: string;
+ assetType: SupportedAssetType;
+ file: FileUpload;
+}
+
+export interface AssetUpdateInput {
+ name?: string;
+ description?: string;
+ file?: FileUpload;
+}
+
+export type RobloxAssetOperation = "create" | "update";
+
export function extractDefaultPrice(
priceInformation: RobloxPriceInformation | undefined,
): number | null {
diff --git a/src/util/download.ts b/src/util/download.ts
index 5285549..30dc2ac 100644
--- a/src/util/download.ts
+++ b/src/util/download.ts
@@ -5,6 +5,8 @@ import {
validateDownloadUrl,
} from "./urlSafety.js";
+export const MAX_ASSET_DOWNLOAD_BYTES = 20 * 1024 * 1024;
+
const DEFAULT_TIMEOUT_MS = 30_000;
export interface DownloadedFile {
@@ -16,6 +18,7 @@ export interface DownloadedFile {
export async function downloadFile(
url: string,
timeoutMs = DEFAULT_TIMEOUT_MS,
+ maxBytes = MAX_DOWNLOAD_BYTES,
): Promise {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -31,10 +34,7 @@ export async function downloadFile(
);
}
- const buffer = await readResponseBodyLimited(
- response,
- MAX_DOWNLOAD_BYTES,
- );
+ const buffer = await readResponseBodyLimited(response, maxBytes);
const filename = filenameFromUrl(finalUrl);
const mimeType =
response.headers.get("content-type")?.split(";")[0]?.trim() ||
@@ -145,15 +145,48 @@ function filenameFromUrl(url: string): string {
function guessMimeType(filename: string): string {
const lower = filename.toLowerCase();
+ if (lower.endsWith(".png")) {
+ return "image/png";
+ }
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
return "image/jpeg";
}
+ if (lower.endsWith(".bmp")) {
+ return "image/bmp";
+ }
+ if (lower.endsWith(".tga")) {
+ return "image/tga";
+ }
if (lower.endsWith(".webp")) {
return "image/webp";
}
if (lower.endsWith(".gif")) {
return "image/gif";
}
+ if (lower.endsWith(".mp3")) {
+ return "audio/mpeg";
+ }
+ if (lower.endsWith(".ogg")) {
+ return "audio/ogg";
+ }
+ if (lower.endsWith(".wav")) {
+ return "audio/wav";
+ }
+ if (lower.endsWith(".flac")) {
+ return "audio/flac";
+ }
+ if (lower.endsWith(".fbx")) {
+ return "model/fbx";
+ }
+ if (lower.endsWith(".gltf")) {
+ return "model/gltf+json";
+ }
+ if (lower.endsWith(".glb")) {
+ return "model/gltf-binary";
+ }
+ if (lower.endsWith(".rbxm") || lower.endsWith(".rbxmx")) {
+ return "model/x-rbxm";
+ }
return "image/png";
}
diff --git a/src/version.ts b/src/version.ts
new file mode 100644
index 0000000..2ee8a71
--- /dev/null
+++ b/src/version.ts
@@ -0,0 +1,19 @@
+declare const NTN_ROBLOX_VERSION: string | undefined;
+
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+
+let VERSION = "0.0.0-dev";
+
+if (typeof NTN_ROBLOX_VERSION === "string" && NTN_ROBLOX_VERSION.length > 0) {
+ VERSION = NTN_ROBLOX_VERSION;
+} else {
+ try {
+ VERSION = require("../package.json").version;
+ } catch {
+ // keep fallback
+ }
+}
+
+export { VERSION };
diff --git a/test/candidates.test.ts b/test/candidates.test.ts
index 27ffb65..60b1aaf 100644
--- a/test/candidates.test.ts
+++ b/test/candidates.test.ts
@@ -4,34 +4,49 @@ import { classifyRow } from "../src/sync/candidates.js";
import { makeRow } from "./helpers.js";
describe("classifyRow", () => {
- it("skips Synced rows when force is false", () => {
- const row = makeRow({ syncStatus: "Synced", robloxId: 123 });
- const result = classifyRow(row);
- assert.equal(result.action, "skip");
- assert.match(result.reason ?? "", /Synced/);
- });
+ describe("sync mode", () => {
+ it("creates rows without robloxId", () => {
+ const row = makeRow({ syncStatus: "Pending", robloxId: null });
+ assert.equal(classifyRow(row, { mode: "sync" }).action, "create");
+ });
- it("creates Synced rows without robloxId when force is true", () => {
- const row = makeRow({ syncStatus: "Synced", robloxId: null });
- const result = classifyRow(row, { force: true });
- assert.equal(result.action, "create");
- });
+ it("skips rows with robloxId set regardless of Sync Status", () => {
+ const row = makeRow({ syncStatus: "Synced", robloxId: 123 });
+ const result = classifyRow(row, { mode: "sync" });
+ assert.equal(result.action, "skip");
+ assert.match(result.reason ?? "", /already set/);
+ });
- it("updates Synced rows with robloxId when force is true", () => {
- const row = makeRow({ syncStatus: "Synced", robloxId: 456 });
- const result = classifyRow(row, { force: true });
- assert.equal(result.action, "update");
+ it("uses robloxIdForTarget when provided", () => {
+ const row = makeRow({ robloxId: null, robloxIds: { main: 456 } });
+ const result = classifyRow(row, {
+ mode: "sync",
+ robloxIdForTarget: 456,
+ });
+ assert.equal(result.action, "skip");
+ });
});
- it("creates rows without robloxId", () => {
- const row = makeRow({ syncStatus: "Pending", robloxId: null });
- assert.equal(classifyRow(row).action, "create");
- });
+ describe("update mode", () => {
+ it("updates rows with robloxId", () => {
+ const row = makeRow({ syncStatus: "Pending", robloxId: 789 });
+ assert.equal(classifyRow(row, { mode: "update" }).action, "update");
+ });
- for (const syncStatus of ["Pending", "Error", "Skipped"] as const) {
- it(`updates rows with robloxId when syncStatus is ${syncStatus}`, () => {
- const row = makeRow({ syncStatus, robloxId: 789 });
- assert.equal(classifyRow(row).action, "update");
+ it("skips rows without robloxId regardless of Sync Status", () => {
+ const row = makeRow({ syncStatus: "Synced", robloxId: null });
+ const result = classifyRow(row, { mode: "update" });
+ assert.equal(result.action, "skip");
+ assert.match(result.reason ?? "", /empty/);
});
- }
+
+ it("uses robloxIdForTarget when provided", () => {
+ const row = makeRow({ robloxId: 999, robloxIds: { main: null } });
+ const result = classifyRow(row, {
+ mode: "update",
+ robloxIdForTarget: null,
+ });
+ assert.equal(result.action, "skip");
+ });
+ });
});
diff --git a/test/cli.test.ts b/test/cli.test.ts
new file mode 100644
index 0000000..dfdb4fb
--- /dev/null
+++ b/test/cli.test.ts
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { parseArgs, printUsage, VERSION } from "../src/cli.js";
+
+describe("cli", () => {
+ it("prints help to stdout", () => {
+ let output = "";
+ const originalLog = console.log;
+ console.log = (...args: unknown[]) => {
+ output += args.join(" ");
+ };
+ try {
+ printUsage();
+ } finally {
+ console.log = originalLog;
+ }
+
+ assert.match(output, /Sync \/ update options:/);
+ assert.match(output, /ntn-roblox update/);
+ assert.match(output, /create-db/);
+ const syncSection = output.slice(output.indexOf("Sync / update options:"));
+ assert.doesNotMatch(syncSection, /--force/);
+ });
+
+ it("parses update command and --target", () => {
+ const parsed = parseArgs(["update", "--target=main", "--dry-run"]);
+ assert.equal(parsed.command, "update");
+ if (parsed.command === "update" || parsed.command === "sync") {
+ assert.equal(parsed.targetFilter, "main");
+ assert.equal(parsed.dryRun, true);
+ }
+ });
+
+ it("parses create-db alias", () => {
+ const parsed = parseArgs(["create-db", "--force"]);
+ assert.equal(parsed.command, "create-databases");
+ });
+
+ it("exports VERSION from package.json", () => {
+ assert.match(VERSION, /^0\.2\.0$/);
+ });
+});
diff --git a/test/config.test.ts b/test/config.test.ts
index ba39c90..e0d6efc 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -106,7 +106,7 @@ level = "info"
env: { NOTION_TOKEN: "create-db-token" },
tomlPath,
}),
- /parent page ID is required for create-databases/,
+ /parent page ID is required for create-db/,
);
});
@@ -129,22 +129,23 @@ parent_page_id = "parent"
});
describe("loadSyncConfig", () => {
- it("loads when NOTION_TOKEN, ROBLOX_API_KEY, and database IDs are present", () => {
- const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-"));
- const tomlPath = writeToml(
- dir,
- `[notion]
+ const syncTomlBase = `[notion]
dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
badge_db_id = "cccccccccccccccccccccccccccccccc"
+asset_db_id = "dddddddddddddddddddddddddddddddd"
[roblox]
universe_id = 987654321
+asset_creator = { is_group = false, id = 87654321 }
[logging]
level = "warn"
-`,
- );
+`;
+
+ it("loads when NOTION_TOKEN, ROBLOX_API_KEY, and database IDs are present", () => {
+ const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-"));
+ const tomlPath = writeToml(dir, syncTomlBase);
const config = loadSyncConfig({
env: {
@@ -160,7 +161,16 @@ level = "warn"
config.NOTION_DEVPRODUCT_DB_ID,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
+ assert.equal(
+ config.NOTION_ASSET_DB_ID,
+ "dddddddddddddddddddddddddddddddd",
+ );
assert.equal(config.ROBLOX_UNIVERSE_ID, 987654321);
+ assert.equal(config.NOTION_IS_INLINE, true);
+ assert.deepEqual(config.ROBLOX_ASSET_CREATOR, {
+ is_group: false,
+ id: 87654321,
+ });
assert.equal(config.LOG_LEVEL, "warn");
});
@@ -168,14 +178,7 @@ level = "warn"
const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-"));
const tomlPath = writeToml(
dir,
- `[notion]
-dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
-game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
-badge_db_id = "cccccccccccccccccccccccccccccccc"
-
-[roblox]
-universe_id = 123
-`,
+ syncTomlBase.replace("[logging]\nlevel = \"warn\"\n", ""),
);
assert.throws(
@@ -189,26 +192,39 @@ universe_id = 123
});
it("throws when NOTION_TOKEN is missing", () => {
+ const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-"));
+ const tomlPath = writeToml(dir, syncTomlBase);
+
+ assert.throws(
+ () =>
+ loadSyncConfig({
+ env: { ROBLOX_API_KEY: "sync-roblox" },
+ tomlPath,
+ }),
+ /NOTION_TOKEN.*Required/,
+ );
+ });
+
+ it("throws when asset_db_id is configured without asset_creator", () => {
const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-"));
const tomlPath = writeToml(
dir,
- `[notion]
-dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
-game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
-badge_db_id = "cccccccccccccccccccccccccccccccc"
-
-[roblox]
-universe_id = 123
-`,
+ syncTomlBase.replace(
+ "asset_creator = { is_group = false, id = 87654321 }\n",
+ "",
+ ),
);
assert.throws(
() =>
loadSyncConfig({
- env: { ROBLOX_API_KEY: "sync-roblox" },
+ env: {
+ NOTION_TOKEN: "sync-notion",
+ ROBLOX_API_KEY: "sync-roblox",
+ },
tomlPath,
}),
- /NOTION_TOKEN.*Required/,
+ /asset_creator.*required/i,
);
});
@@ -221,9 +237,11 @@ parent_page_id = ""
dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
badge_db_id = "cccccccccccccccccccccccccccccccc"
+asset_db_id = "dddddddddddddddddddddddddddddddd"
[roblox]
universe_id = 123
+asset_creator = { is_group = false, id = 87654321 }
`,
);
diff --git a/test/createDatabases.test.ts b/test/createDatabases.test.ts
index f6a0b4f..03abbb3 100644
--- a/test/createDatabases.test.ts
+++ b/test/createDatabases.test.ts
@@ -10,12 +10,14 @@ const CREATED_IDS = {
devProductDbId: "11111111111111111111111111111111",
gamePassDbId: "22222222222222222222222222222222",
badgeDbId: "33333333333333333333333333333333",
+ assetDbId: "44444444444444444444444444444444",
};
const DB_CREATE_ORDER = [
CREATED_IDS.devProductDbId,
CREATED_IDS.gamePassDbId,
CREATED_IDS.badgeDbId,
+ CREATED_IDS.assetDbId,
];
function makeConfig(overrides: Partial = {}): Config {
@@ -25,8 +27,11 @@ function makeConfig(overrides: Partial = {}): Config {
NOTION_DEVPRODUCT_DB_ID: "",
NOTION_GAMEPASS_DB_ID: "",
NOTION_BADGE_DB_ID: "",
+ NOTION_ASSET_DB_ID: "",
NOTION_PARENT_PAGE_ID: "parent-page-id-1234567890123456",
+ NOTION_IS_INLINE: true,
ROBLOX_UNIVERSE_ID: 0,
+ ROBLOX_UNIVERSES: {},
ROBLOX_BADGE_PAYMENT_SOURCE: "user",
LOG_LEVEL: "info",
...overrides,
@@ -101,7 +106,7 @@ describe("runCreateDatabases", () => {
assert.deepEqual(result, CREATED_IDS);
assert.equal(pagesRetrieve.mock.callCount(), 1);
- assert.equal(databasesCreate.mock.callCount(), 3);
+ assert.equal(databasesCreate.mock.callCount(), 4);
});
it("throws when database IDs are already configured", async () => {
@@ -134,7 +139,7 @@ describe("runCreateDatabases", () => {
);
assert.deepEqual(result, CREATED_IDS);
- assert.equal(databasesCreate.mock.callCount(), 3);
+ assert.equal(databasesCreate.mock.callCount(), 4);
});
it("writes created database IDs to TOML by default", async () => {
@@ -151,6 +156,7 @@ describe("runCreateDatabases", () => {
dev_product_db_id: string;
game_pass_db_id: string;
badge_db_id: string;
+ asset_db_id: string;
parent_page_id: string;
};
};
@@ -158,6 +164,7 @@ describe("runCreateDatabases", () => {
assert.equal(parsed.notion.dev_product_db_id, CREATED_IDS.devProductDbId);
assert.equal(parsed.notion.game_pass_db_id, CREATED_IDS.gamePassDbId);
assert.equal(parsed.notion.badge_db_id, CREATED_IDS.badgeDbId);
+ assert.equal(parsed.notion.asset_db_id, CREATED_IDS.assetDbId);
assert.equal(
parsed.notion.parent_page_id,
"parent-page-id-1234567890123456",
diff --git a/test/engine.test.ts b/test/engine.test.ts
index b581787..4c58ae3 100644
--- a/test/engine.test.ts
+++ b/test/engine.test.ts
@@ -24,11 +24,18 @@ describe("badge quota consumption", () => {
}),
writebackError: mock.fn(async () => {}),
writebackSkipped: mock.fn(async () => {}),
+ writebackAggregatedTargetResults: mock.fn(async () => {}),
+ aggregateTargetResults: mock.fn(() => ({
+ syncStatus: "Synced",
+ syncError: "",
+ robloxIds: {},
+ })),
},
});
mock.module("../src/util/download.js", {
namedExports: {
downloadFile: mock.fn(async () => undefined),
+ MAX_ASSET_DOWNLOAD_BYTES: 20 * 1024 * 1024,
},
});
@@ -49,6 +56,7 @@ describe("badge quota consumption", () => {
const result = await processCandidate(candidate, {
dryRun: false,
badgeQuota,
+ targetKey: null,
});
assert.equal(result.outcome, "error");
diff --git a/test/engineAsset.test.ts b/test/engineAsset.test.ts
new file mode 100644
index 0000000..ca55dce
--- /dev/null
+++ b/test/engineAsset.test.ts
@@ -0,0 +1,99 @@
+import assert from "node:assert/strict";
+import { after, before, beforeEach, describe, it, mock } from "node:test";
+import { makeRow } from "./helpers.js";
+import "./setup.js";
+
+describe("asset sync", () => {
+ let processCandidate: typeof import("../src/sync/engine.js").processCandidate;
+ const createAsset = mock.fn(async () => 99_001);
+ const updateAssetMetadata = mock.fn(async () => {});
+ const downloadFile = mock.fn(async () => ({
+ buffer: Buffer.from("file"),
+ filename: "model.fbx",
+ mimeType: "model/fbx",
+ }));
+
+ before(async () => {
+ mock.module("../src/roblox/assets.js", {
+ namedExports: {
+ createAsset,
+ updateAssetMetadata,
+ },
+ });
+ mock.module("../src/notion/writeback.js", {
+ namedExports: {
+ writebackRobloxId: mock.fn(async () => {}),
+ writebackSuccess: mock.fn(async () => {}),
+ writebackError: mock.fn(async () => {}),
+ writebackSkipped: mock.fn(async () => {}),
+ writebackAggregatedTargetResults: mock.fn(async () => {}),
+ aggregateTargetResults: mock.fn(() => ({
+ syncStatus: "Synced",
+ syncError: "",
+ robloxIds: {},
+ })),
+ },
+ });
+ mock.module("../src/util/download.js", {
+ namedExports: {
+ downloadFile,
+ MAX_ASSET_DOWNLOAD_BYTES: 20 * 1024 * 1024,
+ },
+ });
+
+ ({ processCandidate } = await import("../src/sync/engine.js"));
+ });
+
+ after(() => {
+ mock.reset();
+ });
+
+ beforeEach(() => {
+ createAsset.mock.resetCalls();
+ updateAssetMetadata.mock.resetCalls();
+ downloadFile.mock.resetCalls();
+ });
+
+ it("creates asset by downloading file and calling createAsset", async () => {
+ const candidate = {
+ row: makeRow({
+ type: "asset",
+ assetType: "Model",
+ fileUrl: "https://example.com/model.fbx",
+ robloxId: null,
+ }),
+ action: "create" as const,
+ };
+
+ const result = await processCandidate(candidate, {
+ dryRun: false,
+ targetKey: null,
+ });
+
+ assert.equal(result.outcome, "created");
+ assert.equal(createAsset.mock.callCount(), 1);
+ assert.equal(downloadFile.mock.callCount(), 1);
+ });
+
+ it("updates asset metadata only on update", async () => {
+ const candidate = {
+ row: makeRow({
+ type: "asset",
+ assetType: "Image",
+ fileUrl: "https://example.com/image.png",
+ robloxId: 55_001,
+ }),
+ action: "update" as const,
+ };
+
+ const result = await processCandidate(candidate, {
+ dryRun: false,
+ targetKey: null,
+ });
+
+ assert.equal(result.outcome, "updated");
+ assert.equal(updateAssetMetadata.mock.callCount(), 1);
+ assert.equal(createAsset.mock.callCount(), 0);
+ assert.equal(downloadFile.mock.callCount(), 0);
+ });
+});
diff --git a/test/fixtures/ntn-roblox.toml b/test/fixtures/ntn-roblox.toml
index 6039605..363502d 100644
--- a/test/fixtures/ntn-roblox.toml
+++ b/test/fixtures/ntn-roblox.toml
@@ -2,9 +2,11 @@
dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
badge_db_id = "cccccccccccccccccccccccccccccccc"
+asset_db_id = "dddddddddddddddddddddddddddddddd"
[roblox]
-universe_id = 1234567890
+universe_id = 123456789
+asset_creator = { is_group = false, id = 87654321 }
[logging]
level = "info"
diff --git a/test/helpers.ts b/test/helpers.ts
index 8e75671..ab6dd8f 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -1,18 +1,48 @@
-import type { NotionRow } from "../src/types.js";
+import type { AssetRow, NotionRow } from "../src/types.js";
export function makeRow(overrides: Partial = {}): NotionRow {
- return {
+ const base = {
pageId: "page-1",
name: "Test Item",
description: "desc",
iconUrl: null,
robloxId: null,
- syncStatus: "Pending",
+ syncStatus: "Pending" as const,
syncError: "",
lastSyncedAt: null,
- type: "developer-product",
- price: 100,
- isForSale: true,
...overrides,
};
+
+ if (base.type === "asset") {
+ return {
+ ...base,
+ type: "asset",
+ assetType: (base as Partial).assetType ?? "Model",
+ fileUrl: (base as Partial).fileUrl ?? null,
+ } as AssetRow;
+ }
+
+ if (base.type === "badge") {
+ return {
+ ...base,
+ type: "badge",
+ isActive: (base as { isActive?: boolean }).isActive ?? true,
+ } as NotionRow;
+ }
+
+ if (base.type === "game-pass") {
+ return {
+ ...base,
+ type: "game-pass",
+ price: (base as { price?: number | null }).price ?? 100,
+ isForSale: (base as { isForSale?: boolean }).isForSale ?? true,
+ } as NotionRow;
+ }
+
+ return {
+ ...base,
+ type: "developer-product",
+ price: (base as { price?: number | null }).price ?? 100,
+ isForSale: (base as { isForSale?: boolean }).isForSale ?? true,
+ } as NotionRow;
}
diff --git a/test/mapRow.test.ts b/test/mapRow.test.ts
new file mode 100644
index 0000000..1b92f49
--- /dev/null
+++ b/test/mapRow.test.ts
@@ -0,0 +1,193 @@
+import assert from "node:assert/strict";
+import { mkdtempSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { after, before, describe, it } from "node:test";
+import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints.js";
+import { loadSyncConfig, setConfig } from "../src/config.js";
+import { mapAssetPage, mapDeveloperProductPage } from "../src/notion/mapRow.js";
+
+function makePage(
+ properties: PageObjectResponse["properties"],
+): PageObjectResponse {
+ return {
+ object: "page",
+ id: "page-test-1",
+ created_time: "2026-01-01T00:00:00.000Z",
+ last_edited_time: "2026-01-01T00:00:00.000Z",
+ archived: false,
+ in_trash: false,
+ url: "https://notion.so/page-test-1",
+ properties,
+ parent: { type: "database_id", database_id: "db-1" },
+ icon: null,
+ cover: null,
+ created_by: { object: "user", id: "user-1" },
+ last_edited_by: { object: "user", id: "user-1" },
+ public_url: null,
+ } as PageObjectResponse;
+}
+
+function baseProperties(
+ extras: PageObjectResponse["properties"] = {},
+): PageObjectResponse["properties"] {
+ return {
+ Name: {
+ id: "name",
+ type: "title",
+ title: [
+ {
+ type: "text",
+ plain_text: "Test",
+ text: { content: "Test", link: null },
+ annotations: {
+ bold: false,
+ italic: false,
+ strikethrough: false,
+ underline: false,
+ code: false,
+ color: "default",
+ },
+ href: null,
+ },
+ ],
+ },
+ Description: { id: "desc", type: "rich_text", rich_text: [] },
+ "Sync Status": {
+ id: "status",
+ type: "select",
+ select: { id: "pending", name: "Pending", color: "default" },
+ },
+ "Sync Error": { id: "err", type: "rich_text", rich_text: [] },
+ "Last Synced At": { id: "synced", type: "date", date: null },
+ ...extras,
+ };
+}
+
+describe("mapAssetPage", () => {
+ it("maps asset fields and validates Asset Type", () => {
+ const mapped = mapAssetPage(
+ makePage(
+ baseProperties({
+ "Asset Type": {
+ id: "type",
+ type: "select",
+ select: { id: "model", name: "Model", color: "default" },
+ },
+ File: {
+ id: "file",
+ type: "files",
+ files: [
+ {
+ name: "model.fbx",
+ type: "external",
+ external: { url: "https://example.com/model.fbx" },
+ },
+ ],
+ },
+ "Roblox ID": { id: "rid", type: "number", number: 12345 },
+ }),
+ ),
+ );
+
+ assert.ok(!("message" in mapped));
+ if ("message" in mapped) {
+ return;
+ }
+
+ assert.equal(mapped.type, "asset");
+ assert.equal(mapped.assetType, "Model");
+ assert.equal(mapped.fileUrl, "https://example.com/model.fbx");
+ assert.equal(mapped.robloxId, 12345);
+ });
+
+ it("returns mapping error for unsupported Asset Type", () => {
+ const mapped = mapAssetPage(
+ makePage(
+ baseProperties({
+ "Asset Type": {
+ id: "type",
+ type: "select",
+ select: { id: "mesh", name: "MeshPart", color: "default" },
+ },
+ File: { id: "file", type: "files", files: [] },
+ }),
+ ),
+ );
+
+ assert.ok("message" in mapped);
+ if ("message" in mapped) {
+ assert.match(mapped.message, /Invalid Asset Type/);
+ }
+ });
+});
+
+describe("mapDeveloperProductPage multi-universe", () => {
+ let multiTomlPath: string;
+
+ before(() => {
+ const dir = mkdtempSync(join(tmpdir(), "ntn-maprow-"));
+ multiTomlPath = join(dir, "ntn-roblox.toml");
+ writeFileSync(
+ multiTomlPath,
+ `[notion]
+dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+badge_db_id = "cccccccccccccccccccccccccccccccc"
+asset_db_id = "dddddddddddddddddddddddddddddddd"
+
+[roblox]
+universes = { main = 111, staging = 222 }
+asset_creator = { is_group = false, id = 87654321 }
+`,
+ "utf8",
+ );
+
+ setConfig(
+ loadSyncConfig({
+ env: {
+ NOTION_TOKEN: "test-notion-token",
+ ROBLOX_API_KEY: "test-roblox-api-key",
+ },
+ tomlPath: multiTomlPath,
+ }),
+ );
+ });
+
+ after(() => {
+ setConfig(
+ loadSyncConfig({
+ env: {
+ NOTION_TOKEN: "test-notion-token",
+ ROBLOX_API_KEY: "test-roblox-api-key",
+ },
+ }),
+ );
+ });
+
+ it("reads per-key Roblox ID columns with robloxId null", () => {
+ const mapped = mapDeveloperProductPage(
+ makePage(
+ baseProperties({
+ "Roblox ID (main)": { id: "rid-main", type: "number", number: 111 },
+ "Roblox ID (staging)": {
+ id: "rid-staging",
+ type: "number",
+ number: null,
+ },
+ Price: { id: "price", type: "number", number: 50 },
+ "Is For Sale": { id: "sale", type: "checkbox", checkbox: true },
+ Icon: { id: "icon", type: "files", files: [] },
+ }),
+ ),
+ );
+
+ assert.ok(!("message" in mapped));
+ if ("message" in mapped) {
+ return;
+ }
+
+ assert.equal(mapped.robloxId, null);
+ assert.deepEqual(mapped.robloxIds, { main: 111, staging: null });
+ });
+});
diff --git a/test/propertyNames.test.ts b/test/propertyNames.test.ts
new file mode 100644
index 0000000..38c30d8
--- /dev/null
+++ b/test/propertyNames.test.ts
@@ -0,0 +1,47 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { robloxIdPropertyName } from "../src/notion/propertyNames.js";
+import { aggregateTargetResults } from "../src/notion/writeback.js";
+
+describe("robloxIdPropertyName", () => {
+ it("returns Roblox ID for single-universe mode", () => {
+ assert.equal(robloxIdPropertyName(null), "Roblox ID");
+ });
+
+ it("returns per-key column name in multi-universe mode", () => {
+ assert.equal(robloxIdPropertyName("main"), "Roblox ID (main)");
+ assert.equal(robloxIdPropertyName("staging"), "Roblox ID (staging)");
+ });
+});
+
+describe("aggregateTargetResults", () => {
+ it("prefixes errors with target key in multi-universe mode", () => {
+ const aggregated = aggregateTargetResults([
+ {
+ targetKey: "main",
+ outcome: "error",
+ message: "API failed",
+ },
+ {
+ targetKey: "staging",
+ outcome: "created",
+ robloxId: 100,
+ },
+ ]);
+
+ assert.equal(aggregated.syncStatus, "Error");
+ assert.match(aggregated.syncError, /\[main\] API failed/);
+ assert.equal(aggregated.robloxIds.staging, 100);
+ });
+
+ it("returns Synced when all targets succeed", () => {
+ const aggregated = aggregateTargetResults([
+ { targetKey: "main", outcome: "updated", robloxId: 1 },
+ { targetKey: "staging", outcome: "created", robloxId: 2 },
+ ]);
+
+ assert.equal(aggregated.syncStatus, "Synced");
+ assert.equal(aggregated.syncError, "");
+ assert.deepEqual(aggregated.robloxIds, { main: 1, staging: 2 });
+ });
+});
diff --git a/test/tomlWrite.test.ts b/test/tomlWrite.test.ts
index 6cb485b..30e5106 100644
--- a/test/tomlWrite.test.ts
+++ b/test/tomlWrite.test.ts
@@ -7,6 +7,7 @@ const IDS = {
devProductDbId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
gamePassDbId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
badgeDbId: "cccccccccccccccccccccccccccccccc",
+ assetDbId: "dddddddddddddddddddddddddddddddd",
};
describe("updateNotionDatabaseIdsInToml", () => {
@@ -38,6 +39,7 @@ universe_id = 123
dev_product_db_id: string;
game_pass_db_id: string;
badge_db_id: string;
+ asset_db_id: string;
};
};
assert.equal(parsed.notion.dev_product_db_id, IDS.devProductDbId);
@@ -57,7 +59,7 @@ universe_id = 123
assert.match(
output,
- /\[notion\]\ndev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ngame_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nbadge_db_id = "cccccccccccccccccccccccccccccccc"\nparent_page_id = "parent-should-stay"/,
+ /\[notion\]\ndev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ngame_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nbadge_db_id = "cccccccccccccccccccccccccccccccc"\nasset_db_id = "dddddddddddddddddddddddddddddddd"\nparent_page_id = "parent-should-stay"/,
);
});
@@ -85,8 +87,7 @@ parent_page_id = "parent-should-stay"
assert.match(output, /dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"/);
assert.match(output, /game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"/);
- assert.match(output, /badge_db_id = "cccccccccccccccccccccccccccccccc"/);
- assert.match(output, /parent_page_id = "parent-should-stay"/);
+ assert.match(output, /asset_db_id = "dddddddddddddddddddddddddddddddd"/);
assert.doesNotMatch(output, /old-dev/);
});