@g9tm/lynxcontrols-native 0.0.0-stage → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +85 -2
  2. package/android/build.gradle.kts +45 -0
  3. package/android/consumer-rules.pro +3 -0
  4. package/android/src/main/AndroidManifest.xml +5 -0
  5. package/android/src/main/java/com/g9lynx/g9native/G9Bridge.kt +67 -0
  6. package/android/src/main/java/com/g9lynx/g9native/G9HostModule.kt +172 -0
  7. package/android/src/main/java/com/g9lynx/g9native/G9SqliteModule.kt +49 -0
  8. package/android/src/main/java/com/g9lynx/g9native/generated/G9HostModuleSpec.java +48 -0
  9. package/android/src/main/java/com/g9lynx/g9native/generated/G9SqliteModuleSpec.java +41 -0
  10. package/android/src/main/java/com/g9lynx/g9native/host/core/G9PreferencesCore.kt +100 -0
  11. package/android/src/main/java/com/g9lynx/g9native/sqlite/androidx/AndroidxSqlEngine.kt +88 -0
  12. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqlEngine.kt +61 -0
  13. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteCore.kt +260 -0
  14. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteService.kt +131 -0
  15. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteTransport.kt +125 -0
  16. package/android/src/main/java/com/g9lynx/g9native/sqlite/core/G9SqliteValues.kt +71 -0
  17. package/android/src/main/java/com/g9lynx/g9native/video/G9VideoUI.kt +220 -0
  18. package/android/src/main/java/com/g9lynx/g9native/video/core/G9VideoCore.kt +67 -0
  19. package/dist/.tsbuildinfo +1 -0
  20. package/dist/driver.d.ts +20 -0
  21. package/dist/driver.d.ts.map +1 -0
  22. package/dist/driver.js +159 -0
  23. package/dist/driver.js.map +1 -0
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +3 -0
  27. package/dist/media.d.ts +60 -0
  28. package/dist/media.d.ts.map +1 -0
  29. package/dist/media.jsx +119 -0
  30. package/dist/media.jsx.map +1 -0
  31. package/dist/protocol.d.ts +115 -0
  32. package/dist/protocol.d.ts.map +1 -0
  33. package/dist/protocol.js +1 -0
  34. package/dist/services.d.ts +36 -0
  35. package/dist/services.d.ts.map +1 -0
  36. package/dist/services.js +111 -0
  37. package/dist/services.js.map +1 -0
  38. package/dist/transport.d.ts +10 -0
  39. package/dist/transport.d.ts.map +1 -0
  40. package/dist/transport.js +77 -0
  41. package/dist/transport.js.map +1 -0
  42. package/generated/G9HostModule.ts +71 -0
  43. package/generated/G9SqliteModule.ts +68 -0
  44. package/ios/Tests/G9NativeTestSuite.h +25 -0
  45. package/ios/Tests/G9NativeTestSuite.m +560 -0
  46. package/ios/Tests/G9NativeTests.m +52 -0
  47. package/ios/g9lynx-native.podspec +29 -0
  48. package/ios/src/G9HostModule.h +14 -0
  49. package/ios/src/G9HostModule.m +121 -0
  50. package/ios/src/G9SqliteModule.h +14 -0
  51. package/ios/src/G9SqliteModule.m +167 -0
  52. package/ios/src/G9VideoUI.h +21 -0
  53. package/ios/src/G9VideoUI.m +250 -0
  54. package/ios/src/core/G9HostPaths.h +20 -0
  55. package/ios/src/core/G9HostPaths.m +23 -0
  56. package/ios/src/core/G9PreferencesCore.h +43 -0
  57. package/ios/src/core/G9PreferencesCore.m +114 -0
  58. package/ios/src/core/G9SqliteCore.h +62 -0
  59. package/ios/src/core/G9SqliteCore.m +497 -0
  60. package/ios/src/core/G9SqliteTransport.h +16 -0
  61. package/ios/src/core/G9SqliteTransport.m +85 -0
  62. package/ios/src/core/G9VideoCore.h +26 -0
  63. package/ios/src/core/G9VideoCore.m +37 -0
  64. package/ios/src/generated/G9HostModuleSpec.h +23 -0
  65. package/ios/src/generated/G9HostModuleSpec.m +2 -0
  66. package/ios/src/generated/G9SqliteModuleSpec.h +20 -0
  67. package/ios/src/generated/G9SqliteModuleSpec.m +2 -0
  68. package/lynx.lib.json +12 -0
  69. package/package.json +49 -4
  70. package/types/platform-native-module.d.ts +46 -0
@@ -0,0 +1,88 @@
1
+ package com.g9lynx.g9native.sqlite.androidx
2
+
3
+ import androidx.sqlite.SQLiteConnection
4
+ import androidx.sqlite.SQLiteException
5
+ import androidx.sqlite.SQLiteStatement
6
+ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
7
+ import androidx.sqlite.driver.bundled.SQLITE_OPEN_CREATE
8
+ import androidx.sqlite.driver.bundled.SQLITE_OPEN_FULLMUTEX
9
+ import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY
10
+ import androidx.sqlite.driver.bundled.SQLITE_OPEN_READWRITE
11
+ import com.g9lynx.g9native.sqlite.core.G9SqlConnection
12
+ import com.g9lynx.g9native.sqlite.core.G9SqlEngine
13
+ import com.g9lynx.g9native.sqlite.core.G9SqlEngineException
14
+ import com.g9lynx.g9native.sqlite.core.G9SqlStatement
15
+
16
+ /**
17
+ * The production engine: androidx.sqlite `BundledSQLiteDriver` (`sqlite-bundled` 2.7.1), which ships its own SQLite so
18
+ * every Android version runs the same engine (framework SQLite on API 26 is 3.18 and lacks UPSERT). The same class runs
19
+ * on the JVM over `sqlite-bundled-jvm` in the contract tests.
20
+ */
21
+ class AndroidxSqlEngine : G9SqlEngine {
22
+ private val driver = BundledSQLiteDriver()
23
+
24
+ override val name = "androidx-bundled"
25
+
26
+ override fun open(path: String, readOnly: Boolean, create: Boolean): G9SqlConnection {
27
+ val flags =
28
+ (if (readOnly) SQLITE_OPEN_READONLY else SQLITE_OPEN_READWRITE) or
29
+ (if (create && !readOnly) SQLITE_OPEN_CREATE else 0) or
30
+ SQLITE_OPEN_FULLMUTEX
31
+ return translate { Connection(driver.open(path, flags)) }
32
+ }
33
+
34
+ private class Connection(private val connection: SQLiteConnection) : G9SqlConnection {
35
+ override fun prepare(sql: String): G9SqlStatement = translate { Statement(connection.prepare(sql)) }
36
+
37
+ override fun close() = connection.close()
38
+ }
39
+
40
+ private class Statement(private val statement: SQLiteStatement) : G9SqlStatement {
41
+ override fun bindNull(index: Int) = translate { statement.bindNull(index) }
42
+
43
+ override fun bindLong(index: Int, value: Long) = translate { statement.bindLong(index, value) }
44
+
45
+ override fun bindDouble(index: Int, value: Double) = translate { statement.bindDouble(index, value) }
46
+
47
+ override fun bindText(index: Int, value: String) = translate { statement.bindText(index, value) }
48
+
49
+ override fun bindBlob(index: Int, value: ByteArray) = translate { statement.bindBlob(index, value) }
50
+
51
+ override fun step(): Boolean = translate { statement.step() }
52
+
53
+ override fun columnCount(): Int = statement.getColumnCount()
54
+
55
+ override fun columnName(index: Int): String = statement.getColumnName(index)
56
+
57
+ // androidx returns the SQLITE_INTEGER … SQLITE_NULL values that G9SqlType mirrors.
58
+ override fun columnType(index: Int): Int = statement.getColumnType(index)
59
+
60
+ override fun getLong(index: Int): Long = statement.getLong(index)
61
+
62
+ override fun getDouble(index: Int): Double = statement.getDouble(index)
63
+
64
+ override fun getText(index: Int): String = statement.getText(index)
65
+
66
+ override fun getBlob(index: Int): ByteArray = statement.getBlob(index)
67
+
68
+ override fun reset() = translate { statement.reset() }
69
+
70
+ override fun clearBindings() = translate { statement.clearBindings() }
71
+
72
+ override fun close() = statement.close()
73
+ }
74
+
75
+ companion object {
76
+ private val CODE = Regex("""[Ee]rror code: (\d+)""")
77
+
78
+ /** androidx reports failures as `SQLiteException("Error code: N, message: …")`; N is SQLite's result code. */
79
+ inline fun <T> translate(block: () -> T): T =
80
+ try {
81
+ block()
82
+ } catch (error: SQLiteException) {
83
+ throw G9SqlEngineException(codeOf(error.message), error.message ?: "SQLite error")
84
+ }
85
+
86
+ fun codeOf(message: String?): Int = message?.let { CODE.find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 1
87
+ }
88
+ }
@@ -0,0 +1,61 @@
1
+ package com.g9lynx.g9native.sqlite.core
2
+
3
+ /**
4
+ * The SQLite engine the core runs on, shaped like the androidx.sqlite KMP API (`SQLiteDriver` / `SQLiteConnection` /
5
+ * `SQLiteStatement`) so the Android adapter is one call per method. Production: [com.g9lynx.g9native.sqlite.AndroidxSqlEngine]
6
+ * over `BundledSQLiteDriver`. JVM tests: the same adapter over `sqlite-bundled-jvm`, or a JDBC adapter where Google
7
+ * Maven is unreachable (plan/execution/Cloud-Runbook.md §8).
8
+ */
9
+ interface G9SqlEngine {
10
+ /** Short name for reports (`androidx-bundled`, `jdbc`). */
11
+ val name: String
12
+
13
+ /** Opens a connection. The core has already checked existence and created parent directories. */
14
+ fun open(path: String, readOnly: Boolean, create: Boolean): G9SqlConnection
15
+ }
16
+
17
+ interface G9SqlConnection : AutoCloseable {
18
+ fun prepare(sql: String): G9SqlStatement
19
+ }
20
+
21
+ /** One prepared statement. Bind indexes are 1-based, column indexes 0-based (as in SQLite and androidx). */
22
+ interface G9SqlStatement : AutoCloseable {
23
+ fun bindNull(index: Int)
24
+ fun bindLong(index: Int, value: Long)
25
+ fun bindDouble(index: Int, value: Double)
26
+ fun bindText(index: Int, value: String)
27
+ fun bindBlob(index: Int, value: ByteArray)
28
+
29
+ /** Advances; `true` while a row is available. Throws [G9SqlEngineException]. */
30
+ fun step(): Boolean
31
+
32
+ fun columnCount(): Int
33
+ fun columnName(index: Int): String
34
+
35
+ /** One of [G9SqlType]; valid after a `step()` that returned `true`. */
36
+ fun columnType(index: Int): Int
37
+ fun getLong(index: Int): Long
38
+ fun getDouble(index: Int): Double
39
+ fun getText(index: Int): String
40
+ fun getBlob(index: Int): ByteArray
41
+
42
+ fun reset()
43
+ fun clearBindings()
44
+ }
45
+
46
+ /** SQLite fundamental datatypes (`SQLITE_INTEGER` … `SQLITE_NULL`, the androidx `SQLITE_DATA_*` values). */
47
+ object G9SqlType {
48
+ const val INTEGER = 1
49
+ const val FLOAT = 2
50
+ const val TEXT = 3
51
+ const val BLOB = 4
52
+ const val NULL = 5
53
+ }
54
+
55
+ /** An engine failure with SQLite's (extended) result code. */
56
+ class G9SqlEngineException(val resultCode: Int, message: String) : RuntimeException(message)
57
+
58
+ /** Runs a statement that returns nothing (or whose rows are ignored, as a PRAGMA's). */
59
+ fun G9SqlConnection.exec(sql: String) {
60
+ prepare(sql).use { statement -> while (statement.step()) Unit }
61
+ }
@@ -0,0 +1,260 @@
1
+ package com.g9lynx.g9native.sqlite.core
2
+
3
+ import java.io.File
4
+ import java.util.concurrent.ConcurrentHashMap
5
+ import java.util.concurrent.atomic.AtomicLong
6
+
7
+ /** One statement of a batch: SQL and bridge-shaped parameters (see [fromBridge]). */
8
+ class G9Statement(val sql: String, val params: List<Any?>)
9
+
10
+ /** One statement's outcome. Row values are `null`, `Long`, `Double`, `String` or `ByteArray`. */
11
+ class G9SqlResult(
12
+ val columns: List<String>,
13
+ val rows: List<Array<Any?>>,
14
+ val changes: Long,
15
+ val lastInsertRowId: Long,
16
+ )
17
+
18
+ class G9OpenOptions(
19
+ val path: String,
20
+ val readOnly: Boolean = false,
21
+ val create: Boolean = false,
22
+ /** PRAGMA statements built by `pragmaStatements` on the JS side. */
23
+ val pragmas: List<String> = emptyList(),
24
+ )
25
+
26
+ class G9RunOptions(val tx: String? = null, val maxRows: Int? = null, val atomic: Boolean = false)
27
+
28
+ class G9Capabilities(
29
+ val sqliteVersion: String,
30
+ val maxVariables: Int,
31
+ val json1: Boolean,
32
+ val returning: Boolean,
33
+ val upsert: Boolean,
34
+ val interrupt: Boolean,
35
+ )
36
+
37
+ /**
38
+ * The Android SQLite driver core (plan/specs/06 §4): sessions, transaction tokens, a statement cache per session,
39
+ * value conversion and error mapping, over a [G9SqlEngine]. Not thread-safe per session: [G9SqliteDispatcher] runs
40
+ * every request of one session on that session's single thread. Cancellation applies to queued work only: androidx
41
+ * connections expose no `sqlite3_interrupt` (capabilities report `interrupt: false`).
42
+ */
43
+ class G9SqliteCore(private val engine: G9SqlEngine, private val statementCacheSize: Int = 64) {
44
+ private class Session(val connection: G9SqlConnection) {
45
+ // Access order: the first entry is the least recently used (evicted and closed past the cache size).
46
+ val statements = LinkedHashMap<String, G9SqlStatement>(16, 0.75f, true)
47
+ var tx: String? = null
48
+ var lastInsertRowId = 0L
49
+ }
50
+
51
+ private val sessions = ConcurrentHashMap<String, Session>()
52
+ private val txSessions = ConcurrentHashMap<String, String>()
53
+ private val counter = AtomicLong()
54
+
55
+ val engineName: String
56
+ get() = engine.name
57
+
58
+ /** The session a transaction token belongs to (the dispatcher queues tx work there), or `null`. */
59
+ fun sessionOfTx(tx: String): String? = txSessions[tx]
60
+
61
+ fun capabilities(): G9Capabilities {
62
+ val connection = engine.open(":memory:", readOnly = false, create = true)
63
+ try {
64
+ val version = connection.prepare("SELECT sqlite_version()").use { s -> s.step(); s.getText(0) }
65
+ val parts = version.split('.').map { it.toIntOrNull() ?: 0 }
66
+ val atLeast = { major: Int, minor: Int ->
67
+ (parts.getOrElse(0) { 0 } > major) || (parts.getOrElse(0) { 0 } == major && parts.getOrElse(1) { 0 } >= minor)
68
+ }
69
+ return G9Capabilities(version, 32766, json1 = true, returning = atLeast(3, 35), upsert = atLeast(3, 24), interrupt = false)
70
+ } finally {
71
+ connection.close()
72
+ }
73
+ }
74
+
75
+ fun open(options: G9OpenOptions): String {
76
+ val memory = options.path == "" || options.path == ":memory:"
77
+ if (!memory) {
78
+ val file = File(options.path)
79
+ if (!options.create && !file.exists()) {
80
+ throw G9SqliteException("not-found", "Database file does not exist: ${options.path}")
81
+ }
82
+ if (options.create) file.absoluteFile.parentFile?.mkdirs()
83
+ }
84
+ val connection =
85
+ try {
86
+ engine.open(if (memory) ":memory:" else options.path, options.readOnly, options.create)
87
+ } catch (error: Throwable) {
88
+ throw toG9Error(error)
89
+ }
90
+ for (sql in options.pragmas) {
91
+ try {
92
+ connection.exec(sql)
93
+ } catch (error: Throwable) {
94
+ // journal_mode cannot change on a read-only or in-memory database; tuning never fails an open.
95
+ if (!sql.startsWith("PRAGMA journal_mode")) {
96
+ connection.close()
97
+ throw toG9Error(error, sql)
98
+ }
99
+ }
100
+ }
101
+ val token = "s${counter.incrementAndGet()}"
102
+ sessions[token] = Session(connection)
103
+ return token
104
+ }
105
+
106
+ private fun session(token: String): Session =
107
+ sessions[token] ?: misuse("The database session is closed or unknown.")
108
+
109
+ private fun prepare(session: Session, sql: String): G9SqlStatement {
110
+ session.statements[sql]?.let { return it }
111
+ val statement = session.connection.prepare(sql)
112
+ session.statements[sql] = statement
113
+ if (session.statements.size > statementCacheSize) {
114
+ val eldest = session.statements.entries.iterator().next()
115
+ session.statements.remove(eldest.key)
116
+ runCatching { eldest.value.close() }
117
+ }
118
+ return statement
119
+ }
120
+
121
+ private fun bind(statement: G9SqlStatement, index: Int, raw: Any?, sql: String) {
122
+ when (val value = fromBridge(raw)) {
123
+ null -> statement.bindNull(index)
124
+ is String -> {
125
+ if (value.indexOf('\u0000') >= 0) misuse("Parameter $index is text containing NUL; use a BLOB.", sql)
126
+ statement.bindText(index, value)
127
+ }
128
+ is Long -> statement.bindLong(index, value)
129
+ is Double ->
130
+ when {
131
+ value.isNaN() -> misuse("Parameter $index is NaN.", sql)
132
+ value == Math.rint(value) && Math.abs(value) <= MAX_SAFE_INTEGER.toDouble() -> statement.bindLong(index, value.toLong())
133
+ else -> statement.bindDouble(index, value)
134
+ }
135
+ is ByteArray -> statement.bindBlob(index, value)
136
+ is G9Int64 -> statement.bindLong(index, value.decimal.toLongOrNull() ?: misuse("Parameter $index is not a 64-bit integer.", sql))
137
+ G9Undefined -> misuse("undefined is not a SQL value (use null).", sql)
138
+ else -> misuse("Parameter $index is not a SQL value (${value::class.java.simpleName}).", sql)
139
+ }
140
+ }
141
+
142
+ private fun read(statement: G9SqlStatement, column: Int): Any? =
143
+ when (statement.columnType(column)) {
144
+ G9SqlType.NULL -> null
145
+ G9SqlType.INTEGER -> statement.getLong(column)
146
+ G9SqlType.FLOAT -> statement.getDouble(column)
147
+ G9SqlType.TEXT -> statement.getText(column)
148
+ else -> statement.getBlob(column)
149
+ }
150
+
151
+ private fun execute(session: Session, statement: G9Statement, maxRows: Int?): G9SqlResult {
152
+ val prepared = prepare(session, statement.sql)
153
+ try {
154
+ statement.params.forEachIndexed { i, value -> bind(prepared, i + 1, value, statement.sql) }
155
+ val count = prepared.columnCount()
156
+ if (count > 0) {
157
+ val columns = List(count) { prepared.columnName(it) }
158
+ val rows = ArrayList<Array<Any?>>()
159
+ while (prepared.step()) {
160
+ if (maxRows != null && rows.size >= maxRows) {
161
+ throw G9SqliteException("too-many-rows", "The statement returned more than $maxRows rows.", sql = statement.sql)
162
+ }
163
+ rows.add(Array(count) { read(prepared, it) })
164
+ }
165
+ return G9SqlResult(columns, rows, 0, session.lastInsertRowId)
166
+ }
167
+ while (prepared.step()) Unit
168
+ val (changes, rowId) =
169
+ session.connection.prepare("SELECT changes(), last_insert_rowid()").use { s ->
170
+ s.step()
171
+ s.getLong(0) to s.getLong(1)
172
+ }
173
+ session.lastInsertRowId = rowId
174
+ return G9SqlResult(emptyList(), emptyList(), changes, rowId)
175
+ } finally {
176
+ runCatching { prepared.reset() }
177
+ runCatching { prepared.clearBindings() }
178
+ }
179
+ }
180
+
181
+ fun run(token: String, statements: List<G9Statement>, options: G9RunOptions): List<G9SqlResult> {
182
+ val session = session(token)
183
+ if (options.tx != null && session.tx != options.tx) {
184
+ misuse("The transaction is finished or belongs to another session.")
185
+ }
186
+ if (options.tx == null && session.tx != null) misuse("A transaction is open on this session; pass its token (tx).")
187
+ val atomic = options.atomic && options.tx == null && statements.isNotEmpty()
188
+ if (atomic) session.connection.exec("BEGIN")
189
+ val results = ArrayList<G9SqlResult>(statements.size)
190
+ try {
191
+ for (statement in statements) {
192
+ try {
193
+ results.add(execute(session, statement, options.maxRows))
194
+ } catch (error: Throwable) {
195
+ throw toG9Error(error, statement.sql, results.size)
196
+ }
197
+ }
198
+ if (atomic) session.connection.exec("COMMIT")
199
+ } catch (error: Throwable) {
200
+ if (atomic) runCatching { session.connection.exec("ROLLBACK") } // SQLite may already have rolled back.
201
+ throw toG9Error(error)
202
+ }
203
+ return results
204
+ }
205
+
206
+ fun begin(token: String, mode: String): String {
207
+ val session = session(token)
208
+ if (session.tx != null) misuse("A transaction is already open on this session.")
209
+ try {
210
+ session.connection.exec(if (mode == "immediate") "BEGIN IMMEDIATE" else "BEGIN DEFERRED")
211
+ } catch (error: Throwable) {
212
+ throw toG9Error(error)
213
+ }
214
+ val tx = "x${counter.incrementAndGet()}"
215
+ session.tx = tx
216
+ txSessions[tx] = token
217
+ return tx
218
+ }
219
+
220
+ private fun sessionForTx(tx: String): Session {
221
+ val token = txSessions[tx] ?: misuse("The transaction is finished or unknown.")
222
+ val session = sessions[token]
223
+ if (session == null || session.tx != tx) misuse("The transaction is finished or unknown.")
224
+ return session
225
+ }
226
+
227
+ fun commit(tx: String) {
228
+ val session = sessionForTx(tx)
229
+ try {
230
+ session.connection.exec("COMMIT")
231
+ } catch (error: Throwable) {
232
+ // A failed COMMIT (busy) leaves the transaction open: the caller may retry or roll back.
233
+ throw toG9Error(error)
234
+ }
235
+ session.tx = null
236
+ txSessions.remove(tx)
237
+ }
238
+
239
+ fun rollback(tx: String) {
240
+ val session = sessionForTx(tx)
241
+ session.tx = null
242
+ txSessions.remove(tx)
243
+ // SQLite may already have rolled back on an error; the transaction is over either way.
244
+ runCatching { session.connection.exec("ROLLBACK") }
245
+ }
246
+
247
+ fun close(token: String) {
248
+ val session = sessions.remove(token) ?: misuse("The database session is closed or unknown.")
249
+ session.tx?.let { txSessions.remove(it) }
250
+ session.tx = null
251
+ for (statement in session.statements.values) runCatching { statement.close() }
252
+ session.statements.clear()
253
+ session.connection.close()
254
+ }
255
+
256
+ /** Closes every session (module teardown). */
257
+ fun closeAll() {
258
+ for (token in sessions.keys.toList()) runCatching { close(token) }
259
+ }
260
+ }
@@ -0,0 +1,131 @@
1
+ package com.g9lynx.g9native.sqlite.core
2
+
3
+ import java.util.concurrent.ConcurrentHashMap
4
+ import java.util.concurrent.ExecutorService
5
+ import java.util.concurrent.LinkedBlockingQueue
6
+ import java.util.concurrent.RejectedExecutionException
7
+ import java.util.concurrent.ThreadPoolExecutor
8
+ import java.util.concurrent.TimeUnit
9
+
10
+ /** Where a reply goes (the Lynx module invokes the JS callback; Lynx marshals it to the JS thread). */
11
+ typealias G9Reply = (Map<String, Any?>) -> Unit
12
+
13
+ /**
14
+ * The bridge-facing SQLite service: requests arrive as bridge data (`Map` / `List` from `toHashMap()` /
15
+ * `toArrayList()`), run on one single-thread executor per session (all statements of a connection on one thread) with
16
+ * a bounded queue, and reply with an envelope `{ ok, value | error }`. Opening and capability probes share one thread.
17
+ * Pure JVM: the Lynx module is a thin adapter around it.
18
+ */
19
+ class G9SqliteService(private val core: G9SqliteCore, private val queueCapacity: Int = 256) {
20
+ private val executors = ConcurrentHashMap<String, ExecutorService>()
21
+ private val shared = newExecutor("g9-sqlite-open")
22
+ @Volatile private var shutDown = false
23
+
24
+ private fun newExecutor(name: String): ThreadPoolExecutor =
25
+ ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, LinkedBlockingQueue(queueCapacity)) { runnable ->
26
+ Thread(runnable, name).apply { isDaemon = true }
27
+ }
28
+
29
+ private fun executorFor(session: String?): ExecutorService =
30
+ if (session == null) shared else executors.computeIfAbsent(session) { newExecutor("g9-sqlite-$it") }
31
+
32
+ private fun submit(session: String?, reply: G9Reply, work: () -> Any?) {
33
+ if (shutDown) {
34
+ reply(G9SqliteTransport.failure(G9SqliteException("misuse", "The SQLite module is shut down.")))
35
+ return
36
+ }
37
+ try {
38
+ executorFor(session).execute {
39
+ val envelope =
40
+ try {
41
+ G9SqliteTransport.ok(work())
42
+ } catch (error: Throwable) {
43
+ G9SqliteTransport.failure(error)
44
+ }
45
+ reply(envelope)
46
+ }
47
+ } catch (_: RejectedExecutionException) {
48
+ reply(G9SqliteTransport.failure(G9SqliteException("busy", "Too many queued requests for this database session.")))
49
+ }
50
+ }
51
+
52
+ fun capabilities(reply: G9Reply) =
53
+ submit(null, reply) {
54
+ val c = core.capabilities()
55
+ mapOf(
56
+ "sqliteVersion" to c.sqliteVersion,
57
+ "maxVariables" to c.maxVariables.toDouble(),
58
+ "json1" to c.json1,
59
+ "returning" to c.returning,
60
+ "upsert" to c.upsert,
61
+ "interrupt" to c.interrupt,
62
+ )
63
+ }
64
+
65
+ fun open(options: Map<*, *>, reply: G9Reply) =
66
+ submit(null, reply) {
67
+ core.open(
68
+ G9OpenOptions(
69
+ path = options["path"] as? String ?: misuse("open needs a path."),
70
+ readOnly = options["readOnly"] == true,
71
+ create = options["create"] == true,
72
+ pragmas = (options["pragmas"] as? List<*>)?.map { it as String } ?: emptyList(),
73
+ ),
74
+ )
75
+ }
76
+
77
+ fun run(session: String, statements: List<*>, options: Map<*, *>, reply: G9Reply) =
78
+ submit(session, reply) {
79
+ val batch =
80
+ statements.map { raw ->
81
+ val s = raw as? Map<*, *> ?: misuse("A statement must be an object { sql, params }.")
82
+ G9Statement(s["sql"] as? String ?: misuse("A statement needs sql."), (s["params"] as? List<*>) ?: emptyList<Any?>())
83
+ }
84
+ val results =
85
+ core.run(
86
+ session,
87
+ batch,
88
+ G9RunOptions(
89
+ tx = options["tx"] as? String,
90
+ maxRows = (options["maxRows"] as? Number)?.toInt(),
91
+ atomic = options["atomic"] == true,
92
+ ),
93
+ )
94
+ G9SqliteTransport.encode(results, options["transport"] as? String ?: G9SqliteTransport.COLUMNAR)
95
+ }
96
+
97
+ fun begin(session: String, mode: String, reply: G9Reply) = submit(session, reply) { core.begin(session, mode) }
98
+
99
+ fun commit(tx: String, reply: G9Reply) =
100
+ submit(core.sessionOfTx(tx), reply) {
101
+ core.commit(tx)
102
+ null
103
+ }
104
+
105
+ fun rollback(tx: String, reply: G9Reply) =
106
+ submit(core.sessionOfTx(tx), reply) {
107
+ core.rollback(tx)
108
+ null
109
+ }
110
+
111
+ fun close(session: String, reply: G9Reply) =
112
+ submit(session, reply) {
113
+ try {
114
+ core.close(session)
115
+ } finally {
116
+ // This task is the executor's last: later requests for the token get a fresh executor and a `misuse` reply.
117
+ executors.remove(session)?.shutdown()
118
+ }
119
+ null
120
+ }
121
+
122
+ /** Closes every database and stops the threads (module destroy). Queued requests still run first. */
123
+ fun shutdown() {
124
+ shutDown = true
125
+ for (executor in executors.values) executor.shutdown()
126
+ for (executor in executors.values) executor.awaitTermination(5, TimeUnit.SECONDS)
127
+ executors.clear()
128
+ shared.shutdown()
129
+ core.closeAll()
130
+ }
131
+ }
@@ -0,0 +1,125 @@
1
+ package com.g9lynx.g9native.sqlite.core
2
+
3
+ import java.nio.ByteBuffer
4
+ import java.nio.ByteOrder
5
+
6
+ /**
7
+ * How results cross to JS (plan/specs/06 §6). The contract is fixed; these bytes are not.
8
+ *
9
+ * - **C, `columnar` (default):** per result `columns`, `rowCount`, `changes`, `lastInsertRowId`, and the cells in row
10
+ * order as one tag byte each in `types` (0 null, 1 number, 2 int64 text, 3 text, 4 blob); numbers (and each blob's
11
+ * length) as little-endian float64 in `numbers`; text and int64 decimals in `strings`; blob bytes back to back in
12
+ * `blobs`. No per-cell boxing, no UTF-8 decoding in JS.
13
+ * - **A, `rows`:** `rows` as arrays of tagged cells (`null`, number, string, `{ int64 }`, ArrayBuffer).
14
+ *
15
+ * Output is plain Kotlin data (`Map`, `List`, `String`, `Double`, `Boolean`, `ByteArray`, `null`); the Lynx module
16
+ * copies it into bridge maps.
17
+ */
18
+ object G9SqliteTransport {
19
+ const val COLUMNAR = "columnar"
20
+ const val ROWS = "rows"
21
+
22
+ const val TAG_NULL: Byte = 0
23
+ const val TAG_NUMBER: Byte = 1
24
+ const val TAG_INT64: Byte = 2
25
+ const val TAG_TEXT: Byte = 3
26
+ const val TAG_BLOB: Byte = 4
27
+
28
+ fun wireNumber(value: Long): Any = when (val n = toBridgeNumber(value)) {
29
+ is G9Int64 -> mapOf("int64" to n.decimal)
30
+ else -> n
31
+ }
32
+
33
+ fun encode(results: List<G9SqlResult>, transport: String): Map<String, Any?> =
34
+ mapOf(
35
+ "transport" to (if (transport == ROWS) ROWS else COLUMNAR),
36
+ "results" to results.map { if (transport == ROWS) rows(it) else columnar(it) },
37
+ )
38
+
39
+ fun rows(result: G9SqlResult): Map<String, Any?> =
40
+ mapOf(
41
+ "columns" to result.columns,
42
+ "rows" to result.rows.map { row -> row.map { cell -> if (cell is Long) wireNumber(cell) else cell } },
43
+ "changes" to result.changes.toDouble(),
44
+ "lastInsertRowId" to wireNumber(result.lastInsertRowId),
45
+ )
46
+
47
+ fun columnar(result: G9SqlResult): Map<String, Any?> {
48
+ val cells = result.rows.sumOf { it.size }
49
+ val types = ByteArray(cells)
50
+ var numberCount = 0
51
+ var blobBytes = 0
52
+ for (row in result.rows) {
53
+ for (cell in row) {
54
+ when (cell) {
55
+ is Double -> numberCount++
56
+ is Long -> if (toBridgeNumber(cell) is Double) numberCount++
57
+ is ByteArray -> {
58
+ numberCount++
59
+ blobBytes += cell.size
60
+ }
61
+ }
62
+ }
63
+ }
64
+ val numbers = ByteBuffer.allocate(numberCount * 8).order(ByteOrder.LITTLE_ENDIAN)
65
+ val blobs = ByteBuffer.allocate(blobBytes)
66
+ val strings = ArrayList<String>()
67
+ var i = 0
68
+ for (row in result.rows) {
69
+ for (cell in row) {
70
+ types[i++] =
71
+ when (cell) {
72
+ null -> TAG_NULL
73
+ is Double -> {
74
+ numbers.putDouble(cell)
75
+ TAG_NUMBER
76
+ }
77
+ is Long ->
78
+ when (val n = toBridgeNumber(cell)) {
79
+ is Double -> {
80
+ numbers.putDouble(n)
81
+ TAG_NUMBER
82
+ }
83
+ else -> {
84
+ strings.add((n as G9Int64).decimal)
85
+ TAG_INT64
86
+ }
87
+ }
88
+ is String -> {
89
+ strings.add(cell)
90
+ TAG_TEXT
91
+ }
92
+ is ByteArray -> {
93
+ numbers.putDouble(cell.size.toDouble())
94
+ blobs.put(cell)
95
+ TAG_BLOB
96
+ }
97
+ else -> throw IllegalStateException("Unexpected cell ${cell::class.java.simpleName}")
98
+ }
99
+ }
100
+ }
101
+ return mapOf(
102
+ "columns" to result.columns,
103
+ "rowCount" to result.rows.size.toDouble(),
104
+ "changes" to result.changes.toDouble(),
105
+ "lastInsertRowId" to wireNumber(result.lastInsertRowId),
106
+ "types" to types,
107
+ "numbers" to numbers.array(),
108
+ "strings" to strings,
109
+ "blobs" to blobs.array(),
110
+ )
111
+ }
112
+
113
+ fun ok(value: Any?): Map<String, Any?> = mapOf("ok" to true, "value" to value)
114
+
115
+ fun failure(error: Throwable): Map<String, Any?> {
116
+ val e = toG9Error(error)
117
+ val fields = LinkedHashMap<String, Any?>()
118
+ fields["code"] = e.code
119
+ fields["message"] = e.message ?: e.code
120
+ e.sqliteCode?.let { fields["sqliteCode"] = it.toDouble() }
121
+ e.sql?.let { fields["sql"] = it }
122
+ e.completedStatements?.let { fields["completedStatements"] = it.toDouble() }
123
+ return mapOf("ok" to false, "error" to fields)
124
+ }
125
+ }