Files
Piotr Sarnacki 647be7e9c0 spacetime init rewrite (#3366)
This is a draft of the new functionality for `spacetime init`. In order
to run it with built-in templates you have to set the path to the config
file:

```
export SPACETIMEDB_CLI_TEMPLATES_FILE=crates/cli/.init-templates.json
```

In the future it will fetch the list from GH.

A few notes:

* the previous functionality of `spacetime init` does not work at the
moment
* the code needs a bit more cleanup and tests before merging
* there is a bit of a mix in how we generate empty server and client
projects. For Rust we use the existing way of generating. For TypeScript
we clone an empty project from the repo. I wanted to play with both ways
of doing things, and I'm still not sure which is better. Generation in
Rust means that the generated code will match the CLI version and not
necessarily whatever is in Git. On the other hand, for the builtin
templates we will be fetching the newest version from GH, which I guess
might also not what we want, ie. we probably want only stable templates.
More discussion is needed here
* we use `spacetimedb` directory for the server files
* I don't particularly like the inability to disable interactive mode
easily. We discussed disabling it by default if all of the required
arguments are passed, but I don't think it's feature proof. For example,
if someone relies on a non-interactive mode, and we add a new required
argument, instead of printing a message `missing --foo`, we will
automatically launch interactive mode, which is harder to debug. That's
why I think I'd prefer to implement `--non-interactive` argument
* it's kind of hard to keep the legacy behaviour. If you don't pass any
arguments, we go into interactive mode. In the legacy version, we would
print required arguments. If someone passes `--lang` or `--project-path`
explicitly, I guess we could run the legacy workflow, but not sure if
it's worth it, as the command was marked as unstable anyway
* the project path defaults to the project name, but I think we should
probably replace change whitespaces to dashes, or at least ask for the
project path with the project name being the default (or both)

---------

Signed-off-by: Tyler Cloutier <cloutiertyler@users.noreply.github.com>
Signed-off-by: John Detter <4099508+jdetter@users.noreply.github.com>
Co-authored-by: = <cloutiertyler@gmail.com>
Co-authored-by: Tyler Cloutier <cloutiertyler@users.noreply.github.com>
Co-authored-by: Tyler Cloutier <cloutiertyler@aol.com>
Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com>
2025-10-30 04:26:08 +00:00

254 lines
7.0 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using SpacetimeDB;
using SpacetimeDB.Types;
// our local client SpacetimeDB identity
Identity? local_identity = null;
// declare a thread safe queue to store commands
var input_queue = new ConcurrentQueue<(string Command, string Args)>();
void Main()
{
// Initialize the `AuthToken` module
AuthToken.Init(".spacetime_csharp_quickstart");
// Builds and connects to the database
DbConnection? conn = null;
conn = ConnectToDB();
// Registers callbacks to run in response to database events.
RegisterCallbacks(conn);
// Declare a threadsafe cancel token to cancel the process loop
var cancellationTokenSource = new CancellationTokenSource();
// Spawn a thread to call process updates and process commands
var thread = new Thread(() => ProcessThread(conn, cancellationTokenSource.Token));
thread.Start();
// Handles CLI input
InputLoop();
// This signals the ProcessThread to stop
cancellationTokenSource.Cancel();
thread.Join();
}
/// The URI of the SpacetimeDB instance hosting our chat module.
string HOST = Environment.GetEnvironmentVariable("SPACETIMEDB_HOST") ?? "http://localhost:3000";
/// The module name we chose when we published our module.
string DB_NAME = Environment.GetEnvironmentVariable("SPACETIMEDB_DB_NAME") ?? "quickstart-chat";
/// Load credentials from a file and connect to the database.
DbConnection ConnectToDB()
{
DbConnection? conn = null;
conn = DbConnection.Builder()
.WithUri(HOST)
.WithModuleName(DB_NAME)
.WithToken(AuthToken.Token)
.OnConnect(OnConnected)
.OnConnectError(OnConnectError)
.OnDisconnect(OnDisconnected)
.Build();
return conn;
}
/// Our `OnConnect` callback: save our credentials to a file.
void OnConnected(DbConnection conn, Identity identity, string authToken)
{
local_identity = identity;
AuthToken.SaveToken(authToken);
conn.SubscriptionBuilder()
.OnApplied(OnSubscriptionApplied)
.SubscribeToAllTables();
}
/// Our `OnConnectError` callback: print the error, then exit the process.
void OnConnectError(Exception e)
{
Console.Write($"Error while connecting: {e}");
}
/// Our `OnDisconnect` callback: print a note, then exit the process.
void OnDisconnected(DbConnection conn, Exception? e)
{
if (e != null)
{
Console.Write($"Disconnected abnormally: {e}");
}
else
{
Console.Write($"Disconnected normally.");
}
}
/// Register all the callbacks our app will use to respond to database events.
void RegisterCallbacks(DbConnection conn)
{
conn.Db.User.OnInsert += User_OnInsert;
conn.Db.User.OnUpdate += User_OnUpdate;
conn.Db.Message.OnInsert += Message_OnInsert;
conn.Reducers.OnSetName += Reducer_OnSetNameEvent;
conn.Reducers.OnSendMessage += Reducer_OnSendMessageEvent;
}
/// If the user has no set name, use the first 8 characters from their identity.
string UserNameOrIdentity(User user) => user.Name ?? user.Identity.ToString()[..8];
/// Our `User.OnInsert` callback: if the user is online, print a notification.
void User_OnInsert(EventContext ctx, User insertedValue)
{
if (insertedValue.Online)
{
Console.WriteLine($"{UserNameOrIdentity(insertedValue)} is online");
}
}
/// Our `User.OnUpdate` callback:
/// print a notification about name and status changes.
void User_OnUpdate(EventContext ctx, User oldValue, User newValue)
{
if (oldValue.Name != newValue.Name)
{
Console.WriteLine($"{UserNameOrIdentity(oldValue)} renamed to {newValue.Name}");
}
if (oldValue.Online != newValue.Online)
{
if (newValue.Online)
{
Console.WriteLine($"{UserNameOrIdentity(newValue)} connected.");
}
else
{
Console.WriteLine($"{UserNameOrIdentity(newValue)} disconnected.");
}
}
}
/// Our `Message.OnInsert` callback: print new messages.
void Message_OnInsert(EventContext ctx, Message insertedValue)
{
// We are filtering out messages inserted during the subscription being applied,
// since we will be printing those in the OnSubscriptionApplied callback,
// where we will be able to first sort the messages before printing.
if (ctx.Event is not Event<Reducer>.SubscribeApplied)
{
PrintMessage(ctx.Db, insertedValue);
}
}
void PrintMessage(RemoteTables tables, Message message)
{
var sender = tables.User.Identity.Find(message.Sender);
var senderName = "unknown";
if (sender != null)
{
senderName = UserNameOrIdentity(sender);
}
Console.WriteLine($"{senderName}: {message.Text}");
}
/// Our `OnSetNameEvent` callback: print a warning if the reducer failed.
void Reducer_OnSetNameEvent(ReducerEventContext ctx, string name)
{
var e = ctx.Event;
if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error))
{
Console.Write($"Failed to change name to {name}: {error}");
}
}
/// Our `OnSendMessageEvent` callback: print a warning if the reducer failed.
void Reducer_OnSendMessageEvent(ReducerEventContext ctx, string text)
{
var e = ctx.Event;
if (e.CallerIdentity == local_identity && e.Status is Status.Failed(var error))
{
Console.Write($"Failed to send message {text}: {error}");
}
}
/// Our `OnSubscriptionApplied` callback:
/// sort all past messages and print them in timestamp order.
void OnSubscriptionApplied(SubscriptionEventContext ctx)
{
Console.WriteLine("Connected");
PrintMessagesInOrder(ctx.Db);
}
void PrintMessagesInOrder(RemoteTables tables)
{
foreach (Message message in tables.Message.Iter().OrderBy(item => item.Sent))
{
PrintMessage(tables, message);
}
}
/// Our separate thread from main, where we can call process updates and process commands without blocking the main thread.
void ProcessThread(DbConnection conn, CancellationToken ct)
{
try
{
// loop until cancellation token
while (!ct.IsCancellationRequested)
{
conn.FrameTick();
ProcessCommands(conn.Reducers);
Thread.Sleep(100);
}
}
finally
{
conn.Disconnect();
}
}
/// Read each line of standard input, and either set our name or send a message as appropriate.
void InputLoop()
{
while (true)
{
var input = Console.ReadLine();
if (input == null)
{
break;
}
if (input.StartsWith("/name "))
{
input_queue.Enqueue(("name", input[6..]));
continue;
}
else
{
input_queue.Enqueue(("message", input));
}
}
}
void ProcessCommands(RemoteReducers reducers)
{
// process input queue commands
while (input_queue.TryDequeue(out var command))
{
switch (command.Command)
{
case "message":
reducers.SendMessage(command.Args);
break;
case "name":
reducers.SetName(command.Args);
break;
}
}
}
Main();