# Importing from Sngine

Notes taken from a real Sngine database (phpMyAdmin dump, MariaDB 10.6,
169 tables). Written down so the importer is built against what Sngine
actually stores, not against a guess.

## Shape of it

Sngine is PHP + MySQL, one database, integer ids everywhere
(`user_id`, `post_id`), and `datetime` columns rather than ISO strings.
Media is **not** in the database — the tables hold a path like
`photos/2026/08/abc.jpg` and the file itself sits in the site's uploads
folder. So an import is two halves: rows, then files.

Everything below is "their column → ours".

## Passwords carry over

`users.user_password` is a 60-character bcrypt hash starting `$2y$` —
PHP's prefix for the same algorithm bcryptjs uses. Verified: bcryptjs
`compareSync` accepts a `$2y$` hash and still rejects a wrong password.

**So nobody has to reset their password.** Copy the hash across and their
existing password keeps working. This is the single most valuable thing
in the whole migration — a forced reset loses a large share of any
community.

## People

`users` has 182 columns; almost all of them are settings we don't have.
The ones worth taking:

| Sngine | ours |
| --- | --- |
| `user_id` | `id` (as a string) |
| `user_name` | `username` |
| `user_firstname` + `user_lastname` | `name` |
| `user_email` | `email` |
| `user_password` | `passwordHash` |
| `user_picture` | `avatarUrl` (path — file copied) |
| `user_cover` | `coverUrl` |
| `user_biography` | `bio` |
| `user_verified` `'1'` | `verified` |
| `user_banned` `'1'` | `status: "banned"` |
| `user_group` = 1 | `isAdmin` |
| `user_registered` | `createdAt` |
| `user_website` | `website` |
| `user_work_title` / `user_work_place` | `work` |
| `user_current_city` | `location` |
| `user_country`, `user_birthdate`, `user_relationship` | same fields |
| `user_wallet_balance` | `walletBalance` |

`enum('0','1')` columns are strings in Sngine, not numbers — `'1'` is
true, `'0'` is false. Reading them as booleans in JavaScript gets it
wrong: `Boolean('0')` is `true`.

## Posts

`posts` holds every kind of post; `post_type` says which
(`post`, `share`, `reel`, `video`, …) and `user_type` says whether a
person or a page wrote it. A first import takes `user_type = 'user'`.

| Sngine | ours |
| --- | --- |
| `post_id` | `id` |
| `user_id` | `author` |
| `time` | `createdAt` |
| `text` | `text` |
| `reaction_*_count` (7 columns) added up | `likeCount` |
| `comments` | `commentCount` |
| `shares` | `repostCount` |
| `views` | `views` |
| `origin_id` | the post this one shares → `quotedPostId` |
| `is_paid`, `post_price` | `price` |
| `group_id` | `communityId` |
| `is_hidden`, `pre_approved` | `pendingApproval` |

Attachments live in their own tables, keyed by `post_id`:
`posts_photos.source` → `imageUrl`, `posts_videos.source` → `videoUrl`,
`posts_audios.source` → `audioUrl`, `posts_reels.source` → a reel.

## Reactions

`posts_reactions(post_id, user_id, reaction)` — one row per person, and
`reaction` is a name, not a number. Sngine's seven map onto ours:

| Sngine | ours |
| --- | --- |
| like | thumb |
| love | heart |
| haha | laugh |
| yay | party |
| wow | wow |
| sad | sad |
| angry | angry |

`posts_saved` → our saves. A repost is a `posts` row with
`post_type = 'share'` and `origin_id` pointing at the original.

## Comments

`posts_comments` is one table for comments **and** replies —
`node_type` says which: `'post'` means a comment on that post,
`'comment'` means a reply and `node_id` is the parent comment. That maps
straight onto our `postId` / `parentId`.

Comments carry the same seven reaction counters; added up they become
`likes`.

## Relationships

- `followings(user_id, following_id)` → our `follows`, same direction.
- `friends(user_one_id, user_two_id, status)` — Facebook-style mutual
  friendship. `status = 1` is accepted. We have no "friend", so an
  accepted friendship becomes **two** follow rows, one each way, which is
  what our Friends tab (mutual follows) then shows.
- `users_blocks(user_id, blocked_id)` → our `blocks`.

## Chat

Three tables: `conversations`, `conversations_users` (who is in it, plus
`deleted` per person) and `conversations_messages`. That lines up almost
exactly with what we now have — including `conversations_users.deleted`,
which is our `hiddenBy`.

`conversations_messages.message` is the text; `image`, `video` and
`voice_note` are paths.

## The rest

| Sngine | ours |
| --- | --- |
| `notifications` | notifications (`action` is the type, `seen` is read) |
| `stories` + `stories_media` | stories |
| `posts_polls` + `_options` + `_options_users` | poll and votes |
| `groups` | communities |
| `pages` | pages |
| `hashtags` + `hashtags_posts` | nothing — ours reads tags from the text |
| `system_options` | site settings, name by name |

## Files

Rows are the easy half. The uploads folder is the slow one: a site with
years of photos can be tens of gigabytes, and it has to be copied before
the paths mean anything. The importer should do rows first so the site is
browsable, then files in the background, and say plainly which images
aren't there yet.

## What the importer must not do

Read-only on their database. Never write to it, never drop anything.
Their old site has to keep working while they try ours — that is what
makes the migration safe to attempt.

## Checked against a real, live install

The notes above came from a test install (169 tables). They were then
checked against a real site — 207 tables, 454 members, a year of posts —
and the important finding is that **every table we read is identical**.
`posts`, `posts_comments`, `posts_reactions`, `followings`, `friends`,
`conversations`, `conversations_messages`, `notifications`, `stories`,
`posts_photos`, saves and polls: same columns, both installs. Only
`users` differed (different social-login providers enabled) and `stories`
had gained one column.

So the mapping holds across versions. Two rules keep it that way:

- **Read from a whitelist of tables, ignore everything else.** The real
  site had dating, restaurant, story-boost, games and contest tables from
  addons, plus a dozen `_bk_`/`_backup_` copies someone left behind. An
  importer that walked every table would trip over all of it.
- **Check each column exists before reading it**, from
  INFORMATION_SCHEMA. Which columns `users` has depends on the version
  and which social logins are switched on.

### Most of the database is not worth importing

Counted on the real site:

| | rows |
| --- | --- |
| everything worth importing | **2,657** |
| logs, view counts, sessions, caches | **470,335** |

`log_points` alone was 289,641 rows and `posts_views` 94,191. Their
content — people, posts, comments, reactions, chats, stories — is about
half a percent of the database.

Nothing from `users_sessions` or `log_sessions` should ever be imported:
those are live login tokens, and copying them across is both useless and
a bad habit.

### Passwords, confirmed on real data

All 454 accounts had a bcrypt hash — 422 `$2y$` and 32 `$2b$`. Both are
the same algorithm, and bcryptjs reads both. No account would need a
password reset.

### Media

Paths look like `photos/2026/01/sngine_<hash>.webp`, filed by year and
month, with `videos/`, `stories/` and the rest alongside. The real site
had 6,879 upload records — so the file copy is the slow half of any
migration, exactly as expected.
